diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml index 0f2fe08..f3ef4a8 100644 --- a/.github/workflows/R-CMD-check.yaml +++ b/.github/workflows/R-CMD-check.yaml @@ -22,9 +22,7 @@ jobs: config: - {os: macos-latest, r: 'release'} - {os: windows-latest, r: 'release'} - - {os: ubuntu-latest, r: 'devel', http-user-agent: 'release'} - - {os: ubuntu-latest, r: 'release'} - - {os: ubuntu-latest, r: 'oldrel-1'} + - {os: ubuntu-latest, r: 'release'} env: GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} diff --git a/.lintr b/.lintr index 017041b..32c57f4 100644 --- a/.lintr +++ b/.lintr @@ -3,7 +3,49 @@ linters: linters_with_defaults( implicit_integer_linter(), indentation_linter(indent = 4L), object_name_linter(styles = c("snake_case", "symbols"), regexes = character()), - object_usage_linter = NULL + unused_import_linter( + allow_ns_usage = FALSE, + except_packages = c("bit64", "data.table", "tidyverse"), + interpret_glue = TRUE + ), + object_usage_linter = NULL, + sprintf_linter(), + outer_negation_linter(), + missing_argument_linter(), + missing_package_linter(), + duplicate_argument_linter(), + length_test_linter(), + redundant_equals_linter(), + equals_na_linter(), + unreachable_code_linter(), + boolean_arithmetic_linter(), + # package linters + package_hooks_linter(), + backport_linter(r_version = getRversion(), except = character()), + yoda_test_linter(), + expect_true_false_linter(), + expect_comparison_linter(), + expect_identical_linter(), + expect_type_linter(), + ifelse_censor_linter(), + scalar_in_linter(), + keyword_quote_linter(), + nonportable_path_linter(lax = TRUE), + paren_body_linter(), + paste_linter( + allow_empty_sep = FALSE, + allow_to_string = FALSE, + allow_file_path = c("double_slash", "always", "never") + ), + class_equals_linter(), + condition_message_linter(), + nested_ifelse_linter(), + string_boundary_linter(allow_grepl = FALSE), + inner_combine_linter(), + seq_linter(), + sort_linter(), + regex_subset_linter(), + library_call_linter(allow_preamble = TRUE) ) exclusions: list("man/", "inst/", "src/", ".vscode/", ".Rproj.user/", "R/import-standalone-obj-type.R", "R/import-standalone-types-check.R") encoding: "UTF-8" diff --git a/DESCRIPTION b/DESCRIPTION index 134ce9b..24247c7 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -14,8 +14,9 @@ Imports: httr2, nanonext, mirai, - plumber, - utils + plumber (>= 0.4.0), + utils, + stats Suggests: box, docopt, diff --git a/NAMESPACE b/NAMESPACE index 5986ed5..a7ca49a 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -3,3 +3,4 @@ export(install_hotwater) export(run) export(uninstall_hotwater) +importFrom(stats,start) diff --git a/R/config.R b/R/config.R index 61244cc..b6f08f1 100644 --- a/R/config.R +++ b/R/config.R @@ -12,7 +12,7 @@ new_config <- function(...) { new_port(host = host) ignore <- dots$ignore %||% utils::glob2rx( - paste0( + paste( c("*.sqlite", "*.git*"), collapse = "|" ) @@ -39,11 +39,15 @@ new_config <- function(...) { validate_config <- function(config) { stopifnot(is_config(config)) + if (length(config$entry_path) > 1L) { + error_invalid_path_length(config$entry_path) + } + if (!file.exists(config$entry_path) || dir.exists(config$entry_path)) { error_invalid_path(config$entry_path) } - if (!is.null(config$dirs) && any(!dir.exists(config$dirs))) { + if (!is.null(config$dirs) && !all(dir.exists(config$dirs))) { invalid <- config$dirs[!dir.exists(config$dirs)] error_invalid_dir(invalid) } @@ -74,5 +78,5 @@ new_port <- function(used, host = "127.0.0.1") { } is_config <- function(x) { - "hotwater_config" %in% class(x) + inherits(x, "hotwater_config") } \ No newline at end of file diff --git a/R/engine.R b/R/engine.R index e3e2984..4a078a4 100644 --- a/R/engine.R +++ b/R/engine.R @@ -1,3 +1,7 @@ +# this file contains the construction, destruction, and running of the engine. +# "engine" refers to the superclass that contains the configuration, runner, +# and publisher for the given hotwater. also, it's amusing to call it a "hotwater engine". + new_engine <- function(config) { stopifnot(is_config(config)) structure( @@ -12,7 +16,7 @@ new_engine <- function(config) { config$host, config$socket_port ), - autostart = TRUE + autostart = FALSE ) ) ), @@ -26,9 +30,7 @@ run_engine <- function(engine) { teardown_engine(engine) buildup_engine(engine) } - on.exit({ - teardown_engine(engine) - }) + on.exit({ teardown_engine(engine) }) # nolint: brace_linter. cli_welcome() buildup_engine(engine) @@ -42,7 +44,7 @@ run_engine <- function(engine) { ) repeat { - Sys.sleep(0.05) + Sys.sleep(0.05) # todo, allow this to be configured at some point current_state <- watch_directory( engine, current_state, @@ -61,12 +63,18 @@ buildup_engine <- function(engine) { cli_server_start_progress(engine) res <- new_runner(engine) + + if (engine$publisher$listener[[1L]][["state"]] != "started") { + start(engine$publisher$listener[[1L]]) + } + if (!res) { cli::cli_progress_done(result = "failed") } else { publish_browser_reload(engine) cli::cli_progress_done() } + cli_watching_directory(engine) } @@ -75,6 +83,7 @@ teardown_engine <- function(engine) { cli_server_stop_progress() resp <- kill_engine(engine) + if (isTRUE(resp)) { cli::cli_process_done() } else { @@ -83,5 +92,5 @@ teardown_engine <- function(engine) { } is_engine <- function(x) { - "hotwater_engine" %in% class(x) + inherits(x, "hotwater_engine") } \ No newline at end of file diff --git a/R/errors.R b/R/errors.R index b41d6b8..01bf4af 100644 --- a/R/errors.R +++ b/R/errors.R @@ -8,11 +8,24 @@ new_hotwater_warning <- function(type) { error_invalid_path <- function(path) { cli::cli_abort( - "Invalid path: {.file {path}}", + c( + "Invalid path: {.file {path}}", + x = "{.file {path}} not a valid path to a file" + ), class = new_hotwater_error("invalid_path") ) } +error_invalid_path_length <- function(path) { + cli::cli_abort( + c( + "Invalid path: {.file {path}}", + x = "{.file {path}} must be length 1L" + ), + class = new_hotwater_error("invalid_path_length") + ) +} + error_invalid_dir <- function(dir) { cli::cli_abort( "Invalid directory: {.file {dir}}", diff --git a/R/hotwater-package.R b/R/hotwater-package.R new file mode 100644 index 0000000..c696cb9 --- /dev/null +++ b/R/hotwater-package.R @@ -0,0 +1,7 @@ +#' @keywords internal +"_PACKAGE" + +## usethis namespace: start +#' @importFrom stats start +## usethis namespace: end +NULL diff --git a/R/middleware.R b/R/middleware.R index 6ee2870..e5d0e9f 100644 --- a/R/middleware.R +++ b/R/middleware.R @@ -1,27 +1,39 @@ +# middleware for the engine, ensures that we can tell if the API is up and running, +# and that we can embed some javascript to autoreload local web pages using the API. + injection <- function(engine) { - system.file("middleware", "injection.html", package = "hotwater", mustWork = TRUE) |> - readLines() |> - paste0(collapse = "\n") |> - sprintf(engine$publisher$listener[[1L]]$url) + injection_lines <- readLines( + system.file("middleware", "injection.html", package = "hotwater", mustWork = TRUE) + ) + + sprintf( + paste(injection_lines, collapse = "\n"), + engine$publisher$listener[[1L]]$url + ) } middleware <- function(engine) { js <- injection(engine) hook <- postserialise_hotwater(js) function(pr) { - pr |> - # remove hotwater from the api spec - plumber::pr_set_api_spec(function(spec) { - spec$paths[["/__hotwater__"]] <- NULL - spec - }) |> - # the dummy path is needed for pinging the server from hotwater - plumber::pr_get( - "/__hotwater__", function() "running", - serializer = plumber::serializer_text(), - preempt = "__first__" - ) |> - plumber::pr_hook("postserialize", hook) + # remove hotwater from the api spec + plumber::pr_set_api_spec(pr, function(spec) { + spec$paths[["/__hotwater__"]] <- NULL + spec + }) + # the dummy path is needed for pinging the server from hotwater + plumber::pr_get( + pr, + "/__hotwater__", + function() "running", + serializer = plumber::serializer_text(), + preempt = "__first__" + ) + plumber::pr_hook( + pr, + "postserialize", + hook + ) } } @@ -30,10 +42,9 @@ postserialise_hotwater <- function(js) { if (length(value$error) > 0L) { return(value) } - if (grepl("text/html", value$headers[["Content-Type"]])) { + if (grepl("text/html", value$headers[["Content-Type"]])) { # nolint: nonportable_path_linter. value$headers[["Cache-Control"]] <- "no-cache" - value$body <- c(value$body, js) |> - paste0(collapse = "\n") + value$body <- paste(c(value$body, js), collapse = "\n") } value } @@ -55,9 +66,9 @@ is_plumber_running <- function(engine) { engine$config$host, engine$config$port ) - res <- httr2::request(url) |> - httr2::req_perform() |> - httr2::resp_status() + res <- httr2::resp_status( + httr2::req_perform(httr2::request(url)) + ) res == 200L }, error = function(e) { diff --git a/R/mirai.R b/R/mirai.R index c047181..84dd9d8 100644 --- a/R/mirai.R +++ b/R/mirai.R @@ -1,3 +1,6 @@ +# this file contains the runner of the hotwater engine. +# the "runner" is the subprocess that spawns the plumber API. + new_runner <- function(engine) { stopifnot(is_engine(engine)) @@ -21,14 +24,13 @@ new_runner <- function(engine) { if (requireNamespace("box", quietly = TRUE)) { box::set_script_path(mod) } - plumber::pr(path) |> - mdware() |> - plumber::pr_run( - port = port, - host = host, - quiet = TRUE, - debug = TRUE - ) + plumber::pr_run( + mdware(plumber::pr(path)), + port = port, + host = host, + quiet = TRUE, + debug = TRUE + ) }, .args = list( port = port, @@ -45,7 +47,10 @@ new_runner <- function(engine) { while (i < timeout && is_runner_alive(engine) && !is_plumber_running(engine)) { i <- i + 1L - try(cli::cli_progress_update(.envir = parent.frame(n = 1L)), silent = TRUE) + try( + cli::cli_progress_update(.envir = parent.frame(n = 1L)), + silent = TRUE + ) Sys.sleep(0.1) } diff --git a/R/run.R b/R/run.R index 4fabefe..258e80e 100644 --- a/R/run.R +++ b/R/run.R @@ -42,14 +42,12 @@ #' @seealso [plumber::options_plumber], #' [plumber::get_option_or_env], [plumber::serializer_html] #' -#' @examples -#' if (interactive()) { +#' @examplesIf interactive() #' # start a hotwater session on port 9999 #' hotwater::run( #' path = system.file("examples", "plumber.R", package = "hotwater"), #' port = 9999L #' ) -#' } #' #' @return NULL #' @export diff --git a/R/script.R b/R/script.R index 9f0d742..abb41b4 100644 --- a/R/script.R +++ b/R/script.R @@ -24,11 +24,11 @@ NULL common_install_paths <- list( unix = c( - "~/.local/bin/", - "~/bin/", - "/usr/bin/", - "/usr/local/bin/", - "/bin/" + file.path("~", ".local", "bin"), + file.path("~", "bin"), + file.path("usr", "bin"), + file.path("usr", "local", "bin"), + file.path("bin") ), windows = c() # does windows even work with this? ) @@ -38,18 +38,16 @@ common_install_paths <- list( #' If hotwater is installed, users may run `hotwater` from the command line #' rather than from an R terminal. #' -#' @param install_folder \[default "~/.local/bin/"] folder to install hotwater +#' @param install_folder folder to install hotwater #' script into. To run as expected, make sure that the folder supplied is on your #' `PATH` envar. #' @seealso [hotwater::uninstall_hotwater] -#' @examples -#' if (interactive()) { +#' @examplesIf interactive() #' hotwater::install_hotwater() -#' } #' @return NULL #' #' @export -install_hotwater <- function(install_folder = "~/.local/bin/") { +install_hotwater <- function(install_folder) { p <- file.path(install_folder, "hotwater") if (file.exists(p)) { @@ -70,16 +68,13 @@ install_hotwater <- function(install_folder = "~/.local/bin/") { #' Uninstall global hotwater script #' -#' @param install_folder \[default "~/.local/bin/"] folder to uninstall hotwater -#' from. -#' @examples -#' if (interactive()) { -#' hotwater::uninstall_hotwater() -#' } +#' @param install_folder folder to uninstall hotwater from. +#' @examplesIf interactive() +#' hotwater::uninstall_hotwater() #' @seealso [hotwater::install_hotwater] #' @return NULL #' @export -uninstall_hotwater <- function(install_folder = "~/.local/bin/") { +uninstall_hotwater <- function(install_folder) { p <- file.path(install_folder, "hotwater") if (file.exists(p)) { success <- file.remove(p) diff --git a/R/utils.R b/R/utils.R index ad6b791..26c9409 100644 --- a/R/utils.R +++ b/R/utils.R @@ -1,7 +1,9 @@ `%nin%` <- Negate(`%in%`) -`%||%` <- function(x, y) { - if (is.null(x)) y else x +if (!exists("%||%", baseenv())) { + `%||%` <- function(x, y) { + if (is.null(x)) y else x + } } `%|NA|%` <- function(x, y) { # nolint: object_name_linter. diff --git a/R/zzz.R b/R/zzz.R index fef93ed..c88566d 100644 --- a/R/zzz.R +++ b/R/zzz.R @@ -1,4 +1,4 @@ -.onLoad <- function(...) { +.onLoad <- function(lib, pkg) { ns <- asNamespace("hotwater") if (is.null(ns[["hotwater"]])) { ns[["hotwater"]] <- new.env(parent = ns) diff --git a/TODO.md b/TODO.md index bb97b75..1cd663b 100644 --- a/TODO.md +++ b/TODO.md @@ -15,3 +15,8 @@ ## 4 - The CLI messages are a bit all over the place, and errors don't always cause the progress bar to fail + +## 5 + +- An error doesn't always cause hotwater to attempt to restart. Should it? An error can indicate that the API has to be changed +before trying again diff --git a/exec/hotwater b/exec/hotwater index 5e6eaa1..7d97e4d 100644 --- a/exec/hotwater +++ b/exec/hotwater @@ -1,7 +1,7 @@ #!/usr/bin/env Rscript if (!requireNamespace("hotwater", quietly = TRUE)) { - cli::cli_inform("Bootstrapping hotwater...") + base::message("Bootstrapping hotwater...") utils::install.packages("hotwater", repos = "https://elianhugh.r-universe.dev") } diff --git a/inst/examples/plumber.R b/inst/examples/plumber.R index f612fe1..37062ca 100644 --- a/inst/examples/plumber.R +++ b/inst/examples/plumber.R @@ -1,5 +1,5 @@ #' @get / #' @serializer html function() { - "Hello world." + "Hello, world!" } diff --git a/inst/middleware/injection.html b/inst/middleware/injection.html index 0884d8b..2e657b7 100644 --- a/inst/middleware/injection.html +++ b/inst/middleware/injection.html @@ -3,12 +3,12 @@ if (!document.getElementById('hotwater-reloader')) { document.currentScript.id = "hotwater-reloader" document.body.appendChild(document.currentScript) - var ws = new WebSocket('%s', ['pub.sp.nanomsg.org']); + let ws = new WebSocket('%s', ['pub.sp.nanomsg.org']); ws.onmessage = () => { window.location.reload(); }; } else { - var checker = document.currentScript; + let checker = document.currentScript; checker.parentNode.removeChild(checker); } })(); diff --git a/man/hotwater-package.Rd b/man/hotwater-package.Rd new file mode 100644 index 0000000..8dd4a5a --- /dev/null +++ b/man/hotwater-package.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/hotwater-package.R +\docType{package} +\name{hotwater-package} +\alias{hotwater} +\alias{hotwater-package} +\title{hotwater: Live Reload for Plumber APIs} +\description{ +Enhances development for plumber APIs by enabling live reloading. Monitors API files for changes and automatically refreshes the server and connected web clients, allowing for faster API iteration. +} +\seealso{ +Useful links: +\itemize{ + \item \url{https://github.com/ElianHugh/hotwater} + \item Report bugs at \url{https://github.com/ElianHugh/hotwater/issues} +} + +} +\author{ +\strong{Maintainer}: Elian Thiele-Evans \email{elianhte@gmail.com} (\href{https://orcid.org/0000-0001-8008-3165}{ORCID}) + +} +\keyword{internal} diff --git a/man/install_hotwater.Rd b/man/install_hotwater.Rd index 11c3194..3c912d7 100644 --- a/man/install_hotwater.Rd +++ b/man/install_hotwater.Rd @@ -4,10 +4,10 @@ \alias{install_hotwater} \title{Install global hotwater script} \usage{ -install_hotwater(install_folder = "~/.local/bin/") +install_hotwater(install_folder) } \arguments{ -\item{install_folder}{[default "~/.local/bin/"] folder to install hotwater +\item{install_folder}{folder to install hotwater script into. To run as expected, make sure that the folder supplied is on your \code{PATH} envar.} } @@ -16,9 +16,9 @@ If hotwater is installed, users may run \code{hotwater} from the command line rather than from an R terminal. } \examples{ -if (interactive()) { +\dontshow{if (interactive()) (if (getRversion() >= "3.4") withAutoprint else force)(\{ # examplesIf} hotwater::install_hotwater() -} +\dontshow{\}) # examplesIf} } \seealso{ \link{uninstall_hotwater} diff --git a/man/run.Rd b/man/run.Rd index 38b05d5..4b52c36 100644 --- a/man/run.Rd +++ b/man/run.Rd @@ -45,14 +45,13 @@ inject a websocket into the HTML client that listens for the plumber server refresh. } \examples{ -if (interactive()) { +\dontshow{if (interactive()) (if (getRversion() >= "3.4") withAutoprint else force)(\{ # examplesIf} # start a hotwater session on port 9999 hotwater::run( path = system.file("examples", "plumber.R", package = "hotwater"), port = 9999L ) -} - +\dontshow{\}) # examplesIf} } \seealso{ \link[plumber:options_plumber]{plumber::options_plumber}, diff --git a/man/uninstall_hotwater.Rd b/man/uninstall_hotwater.Rd index 4f8cf8a..a3c5597 100644 --- a/man/uninstall_hotwater.Rd +++ b/man/uninstall_hotwater.Rd @@ -4,19 +4,18 @@ \alias{uninstall_hotwater} \title{Uninstall global hotwater script} \usage{ -uninstall_hotwater(install_folder = "~/.local/bin/") +uninstall_hotwater(install_folder) } \arguments{ -\item{install_folder}{[default "~/.local/bin/"] folder to uninstall hotwater -from.} +\item{install_folder}{folder to uninstall hotwater from.} } \description{ Uninstall global hotwater script } \examples{ -if (interactive()) { - hotwater::uninstall_hotwater() -} +\dontshow{if (interactive()) (if (getRversion() >= "3.4") withAutoprint else force)(\{ # examplesIf} + hotwater::uninstall_hotwater() +\dontshow{\}) # examplesIf} } \seealso{ \link{install_hotwater} diff --git a/tests/testthat/_snaps/middleware.md b/tests/testthat/_snaps/middleware.md index 0fe2f60..54192f2 100644 --- a/tests/testthat/_snaps/middleware.md +++ b/tests/testthat/_snaps/middleware.md @@ -3,5 +3,5 @@ Code injection(dummy_engine) Output - [1] "" + [1] "" diff --git a/tests/testthat/helpers.R b/tests/testthat/helpers.R index ae5f912..f08f11a 100644 --- a/tests/testthat/helpers.R +++ b/tests/testthat/helpers.R @@ -1,12 +1,12 @@ new_test_engine <- function() { - new_engine( - new_config( - path = system.file("examples", "plumber.R", package = "hotwater") - ) + config <- new_config( + path = system.file("examples", "plumber.R", package = "hotwater") ) + new_engine(config) } cleanup_test_engine <- function(engine) { kill_engine(engine) close(engine$publisher) + Sys.sleep(0.5) } diff --git a/tests/testthat/test-config.R b/tests/testthat/test-config.R index e4318ae..0f3adb7 100644 --- a/tests/testthat/test-config.R +++ b/tests/testthat/test-config.R @@ -1,6 +1,33 @@ test_that("config is validated", { - bad <- new_config( - path = "." + bad <- new_config(path = ".") + bad2 <- new_config( + path = c( + system.file("examples", "plumber.R", package = "hotwater"), + system.file("examples", "plumber.R", package = "hotwater") + ) + ) + bad3 <- new_config( + path = system.file("examples", "plumber.R", package = "hotwater"), + dirs = system.file("examples", "plumber.R", package = "hotwater") + ) + bad4 <- new_config( + path = system.file("examples", "plumber.R", package = "hotwater"), + port = "not a port" + ) + expect_error( + validate_config(bad), + class = new_hotwater_error("invalid_path") + ) + expect_error( + validate_config(bad2), + class = new_hotwater_error("invalid_path_length") + ) + expect_error( + validate_config(bad3), + class = new_hotwater_error("invalid_dir") + ) + expect_error( + validate_config(bad4), + class = new_hotwater_error("invalid_port") ) - expect_error(validate_config(bad)) }) diff --git a/tests/testthat/test-engine.R b/tests/testthat/test-engine.R index 341fe0d..2520b09 100644 --- a/tests/testthat/test-engine.R +++ b/tests/testthat/test-engine.R @@ -8,7 +8,7 @@ test_that("engine reuse", { ) expect_true( should_reuse_engine( - old = engine$config, + old_config = engine$config, new_config( dirs = engine$config$dirs, path = engine$config$entry_path, diff --git a/tests/testthat/test-script.R b/tests/testthat/test-script.R index 403aa0e..f3504fd 100644 --- a/tests/testthat/test-script.R +++ b/tests/testthat/test-script.R @@ -3,7 +3,9 @@ test_that("hotwater install/uninstall works", { local({ hw_install_folder <- withr::local_tempdir("install_path") # should work first time - expect_no_error(install_hotwater(hw_install_folder)) + expect_no_error( + suppressMessages(install_hotwater(hw_install_folder)) + ) # error because file already exists expect_error( install_hotwater(hw_install_folder), @@ -11,7 +13,9 @@ test_that("hotwater install/uninstall works", { ) # work first time - expect_no_error(uninstall_hotwater(hw_install_folder)) + expect_no_error( + suppressMessages(uninstall_hotwater(hw_install_folder)) + ) # warning because no file exists expect_warning( uninstall_hotwater(hw_install_folder),