diff --git a/DESCRIPTION b/DESCRIPTION index 406f50a..633c0c6 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: fisdata Title: Query Data from the FIS Web Page -Version: 0.2.0.9001 +Version: 0.2.0.9002 Authors@R: person("Stefan", "Lanz", , "slanz1137@gmail.com", role = c("aut", "cre")) Description: Query data from the web site of the International Ski and @@ -28,17 +28,18 @@ Imports: rlang, cli, magrittr, - clipr, cachem, + jsonlite +Suggests: ggplot2, - grid, scales, colorspace, ggnewscale, ggiraph, + grid, png, - jpeg -Suggests: + jpeg, + clipr, roxygen2, spelling, withr, diff --git a/NAMESPACE b/NAMESPACE index f661033..3af1eb9 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,5 +1,6 @@ # Generated by roxygen2: do not edit by hand +export("%>%") export(browse_url) export(fd_def) export(get_athlete_image) @@ -17,10 +18,13 @@ export(query_events) export(query_race) export(query_results) export(query_standings) +export(read_fisdata_defaults) export(reset_fisdata_defaults) export(set_fisdata_defaults) export(show_url) export(summarise_results) +export(write_current_fisdata_defaults) +export(write_fisdata_defaults) importFrom(dplyr,"%>%") importFrom(rlang,":=") importFrom(rlang,.data) diff --git a/R/defaults.R b/R/defaults.R index 0406912..ec3e6ad 100644 --- a/R/defaults.R +++ b/R/defaults.R @@ -38,58 +38,20 @@ set_fisdata_defaults <- function(sector = NULL, } } - if (!is.null(sector)) { - use_sector <- find_code(sector, "sector") - options(fisdata_sector = use_sector) - alert_default("sector", use_sector, verbose) - } - - if (!is.null(season)) { - season_int <- suppressWarnings(as.integer(season)) - if (season == "") { - options(fisdata_season = "") - alert_default("season", season, verbose) - } else if (is.na(season_int) | season_int < 1950 | - season_int > lubridate::year(today()) + 1) { - cli::cli_warn("'{season}' is not a valid season.") - } else { - options(fisdata_season = as.character(season_int)) - alert_default("season", season_int, verbose) - } - } - - if (!is.null(gender)) { - use_gender <- standardise_gender(gender) - if (!use_gender %in% c("", "M", "W")) { - cli::cli_warn("'{gender}' is not a valid gender code.") - } else { - options(fisdata_gender = use_gender) - alert_default("gender", use_gender, verbose) - } - } - - if (!is.null(category)) { - use_category <- find_code(category, "category") - options(fisdata_category = use_category) - alert_default("category", use_category, verbose) - } - - if (!is.null(discipline)) { - # when matching the discipline, use the default value for the sector - # if set_fisdata_defaults has been called with a sector, it's value has - # already been set as the default such that it will also be used here - use_discipline <- find_code(discipline, "discipline", - sector = fd_def("sector")) - options(fisdata_discipline = use_discipline) - alert_default("discipline", use_discipline, verbose) - } - - if (!is.null(active_only)) { - if (!active_only %in% c(TRUE, FALSE)) { - cli::cli_warn("'{active_only}' is not valid for active_only.") - } else { - options(fisdata_active_only = active_only) - alert_default("active_only", active_only, verbose) + defs <- prepare_defaults(sector = sector, + season = season, + gender = gender, + category = category, + discipline = discipline, + active_only = active_only) + + for (name in names(defs)) { + value <- defs[[name]] + if (!is.null(value)) { + options( + magrittr::set_names(list(value), paste0("fisdata_", name)) + ) + alert_default(name, verbose) } } } @@ -127,13 +89,165 @@ fd_def <- function(name = c("sector", "season", "gender", } +#' Read and Write Defaults from a JSON File +#' +#' Default settings can be written to a JSON file and read again from +#' this file. If the file `.fisdata.json` exists in the user's home +#' it is read automatically when fisdata is loaded in an interactive +#' session (see 'Details' for how to configure this behaviour). +#' +#' @param file name of the JSON file to read or write +#' @param overwrite should an existing file be overwritten? +#' @inheritParams query_athletes +#' @inheritParams query_results +#' +#' @details +#' When fisdata is loaded in an interactive session, it tries to load defaults +#' from a file `fisdata.json`. You can use another file by setting the environment +#' variable `FISDATA_DEFAULTS_FILE` to the path to this file before loading +#' fisdata. To do this once, you can use [Sys.setenv()], to configure R to always +#' load a different file, you can set `FISDATA_DEFAULTS_FILE` in your `.Renviron` +#' file. +#' +#' @returns +#' `write_fisdata_defaults()` and `write_current_fisdata_defaults()` return the json-string that +#' was written to the file (invisibly). `read_fisdata_defaults()` returns the default +#' values that were read as a tibble (invisibly). +#' +#' @export + +write_fisdata_defaults <- function(file = "~/.fisdata.json", + overwrite = FALSE, + sector = "", + season = "", + gender = "", + category = "", + discipline = "", + active_only = FALSE) { + defs <- prepare_defaults(sector = sector, + season = season, + gender = gender, + category = category, + discipline = discipline, + active_only = active_only) + write_fisdata_defaults_(defs, file, overwrite) +} + + +#' @rdname write_fisdata_defaults +#' @export + +write_current_fisdata_defaults <- function(file = "~/.fisdata.json", + overwrite = FALSE) { + write_fisdata_defaults_(get_fisdata_defaults(), file, overwrite) +} + + +# helper function that writes a list or tibble of defaults to a JSON file +write_fisdata_defaults_ <- function(defaults, + file = "~/.fisdata.json", + overwrite = FALSE, + error_call = rlang::caller_env()) { + + if (file.exists(file) && !overwrite) { + cli::cli_abort("The file {file} exists. Use `overwrite = TRUE` to overwrite it.") + } + + # don't write NULL for any default to the file + def_is_null <- purrr::map_lgl(defaults, is.null) + if (any(def_is_null)) { + null_names <- names(def_is_null)[def_is_null] + cli::cli_abort( + c( + "!" = "Defaults must no be NULL when writing to a json file.", + "i" = "The following value{?s} {?is/are} NULL: {null_names}." + ), + call = error_call + ) + } + + json <- defaults %>% + # convert to a list to avoid having an unnecessary length-one array + as.list() %>% + jsonlite::toJSON(pretty = TRUE, auto_unbox = TRUE) + + writeLines(json, file) + + invisible(json) +} + + +#' @param apply should the defaults be applied? +#' @param verbose should the function create output. This defaults +#' to `TRUE` in interactive sessions or when `apply` is `FALSE`. +#' @rdname write_fisdata_defaults +#' +#' @export + +read_fisdata_defaults <- function(file = "~/.fisdata.json", + apply = TRUE, + verbose = !apply || interactive()) { + + if (!file.exists(file)) { + cli::cli_abort("The file {file} does not exist.") + } + + raw <- purrr::possibly(jsonlite::fromJSON)(file) + if (is.null(raw)) { + cli::cli_abort("Failed to parse file {file} as JSON.") + } + + # check that all the expected values are present. + expected <- c("sector", "season", "gender", "category", "discipline", "active_only") + is_present <- expected %in% names(raw) + if (any(!is_present)) { + cli::cli_abort("Some defaults have no value set: {expected[!is_present]}") + } + + # run the contents of the file through prepare_defs() to check + # that the values are valid. + error_call = rlang::current_call() + defs <- tryCatch( + do.call(prepare_defaults, raw[names(raw) %in% expected]), + error = function(e) { + cli::cli_abort(c("The contents of file {file} are not valid.", + "i" = "Error message: {e$message}"), + call = error_call) + }, + warning = function(w) { + cli::cli_abort(c("The contents of file {file} are not valid.", + "i" = "Warning message: {w$message}"), + call = error_call) + } + ) + + # if verbose and the default are not to be applied, print them here + # in case the defaults are applied, set_fisdata_defaults() will create output. + if (verbose && !apply) { + cli::cli_alert_info("The file {file} contains the following defaults:") + print(dplyr::as_tibble(defs)) + } + + # if requested, apply the defaults + if (apply) { + do.call(set_fisdata_defaults, c(defs, list(verbose = verbose))) + } + + invisible(dplyr::as_tibble(defs)) +} + + # issue a message describing the default value that has been set. -alert_default <- function(type, value, verbose) { +# The function must be called AFTER setting the default. +alert_default <- function(type, verbose) { if (!verbose) { return(NULL) } + # get the default value that has been set + value <- fd_def(type) + # if the value is an empty string, issue a message saying this if (value == "") { cli::cli_alert_info("The default for '{type}' has been set to ''.") @@ -151,3 +265,66 @@ alert_default <- function(type, value, verbose) { } } } + + +# prepare inputs for default values +prepare_defaults <- function(sector = NULL, + season = NULL, + gender = NULL, + category = NULL, + discipline = NULL, + active_only = NULL) { + + if (!is.null(sector)) { + sector <- find_code(sector, "sector") + } + + if (!is.null(season)) { + season_int <- suppressWarnings(as.integer(season)) + if (season == "") { + season <- "" + } else if (is.na(season_int) | season_int < 1950 | + season_int > lubridate::year(today()) + 1) { + cli::cli_warn("'{season}' is not a valid season.") + season <- NULL + } else { + season <- as.character(season_int) + } + } + + if (!is.null(gender)) { + std_gender <- standardise_gender(gender) + if (!std_gender %in% c("", "M", "W")) { + cli::cli_warn("'{gender}' is not a valid gender code.") + gender <- NULL + } else { + gender <- std_gender + } + } + + if (!is.null(category)) { + category <- find_code(category, "category") + } + + if (!is.null(discipline)) { + # discipline depends on sector. If a sector has been passed to this + # function, use it. Otherwise, use the current default. + discipline_sector <- if (!is.null(sector)) sector else fd_def("sector") + discipline <- find_code(discipline, "discipline", + sector = discipline_sector) + } + + if (!is.null(active_only)) { + if (!active_only %in% c(TRUE, FALSE)) { + cli::cli_warn("'{active_only}' is not valid for active_only.") + active_only <- NULL + } + } + + list(sector = sector, + season = season, + gender = gender, + category = category, + discipline = discipline, + active_only = active_only) +} \ No newline at end of file diff --git a/R/fisdata-package.R b/R/fisdata-package.R index 6641390..791a23a 100644 --- a/R/fisdata-package.R +++ b/R/fisdata-package.R @@ -27,6 +27,23 @@ cache <- new.env() # set options to default values reset_fisdata_defaults() + + # determine the file that defaults should be read from. The file name is + # taken from the environment variable FISDATA_DEFAULTS_FILE. If the variable + # is unset, fall back to "~/.fisdata.json". + defaults_file <- Sys.getenv("FISDATA_DEFAULTS_FILE", "~/.fisdata.json") + + # if the file exists and R is running interactively, + # read defaults from it. + if (interactive() && file.exists(defaults_file)) { + cli::cli_alert_info("Reading default values from {defaults_file} ...") + read_fisdata_defaults(defaults_file, verbose = TRUE) + } } # nocov end + + +# reexport %>% for convenience +#' @export +dplyr::`%>%` \ No newline at end of file diff --git a/R/helpers.R b/R/helpers.R index b771f1e..1bf5ea8 100644 --- a/R/helpers.R +++ b/R/helpers.R @@ -24,7 +24,14 @@ show_url <- function(fisdata_df) { url <- attr(fisdata_df, "url") if (interactive() && !is.null(url)) { - clipr::write_clip(url) # nocov + if (rlang::is_installed("clipr")) { # nocov start + clipr::write_clip(url) + } else { + cli::cli_alert_info( + c("Install {.pkg clipr} with {.run install.packages(\"clipr\")} ", + "to automatically copy the url to the clipboard.") + ) + } # nocov end } url } diff --git a/R/img_audio.R b/R/img_audio.R index 351efa6..b967a4a 100644 --- a/R/img_audio.R +++ b/R/img_audio.R @@ -25,6 +25,8 @@ #' @export get_athlete_image <- function(athlete, file = NULL) { + rlang::check_installed(c("grid", "png", "jpeg"), "to process images") + athlete <- ensure_one_athlete(athlete) id <- athlete$competitor_id diff --git a/R/plot.R b/R/plot.R index 15e2f32..5cd4f7f 100644 --- a/R/plot.R +++ b/R/plot.R @@ -32,6 +32,10 @@ plot_rank_summary <- function(results, interactive = TRUE, width = NULL, height = NULL) { + rlang::check_installed( + c("ggplot2", "colorspace", "ggnewscale", "ggiraph"), + "to create plots." + ) by <- match_groupings(by, c("category", "discipline")) @@ -101,6 +105,10 @@ plot_results_summary <- function(results, interactive = TRUE, width = NULL, height = NULL) { + rlang::check_installed( + c("ggplot2", "scales", "ggiraph"), + "to create plots." + ) variable <- match.arg(variable) @@ -218,7 +226,11 @@ plot_ranks_over_time <- function(results, interactive = TRUE, width = NULL, height = NULL) { - + rlang::check_installed( + c("ggplot2", "colorspace", "ggnewscale", "ggiraph"), + "to create plots." + ) + by <- if (length(by) > 0 && all(!is.na(by))) match.arg(by) time <- match.arg(time) type <- match.arg(type) @@ -328,6 +340,10 @@ plot_results_over_time <- function(results, interactive = TRUE, width = NULL, height = NULL) { + rlang::check_installed( + c("ggplot2", "scales", "ggiraph"), + "to create plots." + ) variable <- match.arg(variable) by <- if (length(by) > 0 && all(!is.na(by))) match.arg(by) diff --git a/inst/WORDLIST b/inst/WORDLIST index 601035d..dea917c 100644 --- a/inst/WORDLIST +++ b/inst/WORDLIST @@ -14,6 +14,7 @@ Wengen dnf ggiraph ggplot +json parasports png pos diff --git a/man/reexports.Rd b/man/reexports.Rd new file mode 100644 index 0000000..3238477 --- /dev/null +++ b/man/reexports.Rd @@ -0,0 +1,16 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/fisdata-package.R +\docType{import} +\name{reexports} +\alias{reexports} +\alias{\%>\%} +\title{Objects exported from other packages} +\keyword{internal} +\description{ +These objects are imported from other packages. Follow the links +below to see their documentation. + +\describe{ + \item{dplyr}{\code{\link[dplyr:reexports]{\%>\%}}} +}} + diff --git a/man/write_fisdata_defaults.Rd b/man/write_fisdata_defaults.Rd new file mode 100644 index 0000000..029a006 --- /dev/null +++ b/man/write_fisdata_defaults.Rd @@ -0,0 +1,82 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/defaults.R +\name{write_fisdata_defaults} +\alias{write_fisdata_defaults} +\alias{write_current_fisdata_defaults} +\alias{read_fisdata_defaults} +\title{Read and Write Defaults from a JSON File} +\usage{ +write_fisdata_defaults( + file = "~/.fisdata.json", + overwrite = FALSE, + sector = "", + season = "", + gender = "", + category = "", + discipline = "", + active_only = FALSE +) + +write_current_fisdata_defaults(file = "~/.fisdata.json", overwrite = FALSE) + +read_fisdata_defaults( + file = "~/.fisdata.json", + apply = TRUE, + verbose = !apply || interactive() +) +} +\arguments{ +\item{file}{name of the JSON file to read or write} + +\item{overwrite}{should an existing file be overwritten?} + +\item{sector}{abbreviation of the sector, e.g., "AL" for +alpine skiing. Not case-sensitive. +See the dataset \link{sectors} for possible values. +If a string not matching a sector code is used, a similar string +is searched for in the description column of \link{sectors}.} + +\item{season}{year when the season ended, i.e., 2020 stands for the season +2019/2020. It is not possible to filter for multiple seasons at once.} + +\item{gender}{abbreviation of the gender: "M" for male/men, +"F" or "W" for female/women.} + +\item{category}{abbreviation of the category of the race, e.g., "WC" for +"World Cup". Not case-sensitive. +See the dataset \link{categories} for possible values. +If a string not matching a category code is used, a similar string +is searched for in the description column of \link{categories}.} + +\item{discipline}{abbreviation for the discipline, e.g., "DH" for +"Downhill". Not case sensitive. +See the dataset \link{disciplines} for possible values. +If a string not matching a discipline code is used, a similar string +is searched for in the description column of \link{disciplines}.} + +\item{active_only}{should the query be restricted to active athletes.} + +\item{apply}{should the defaults be applied?} + +\item{verbose}{should the function create output. This defaults +to \code{TRUE} in interactive sessions or when \code{apply} is \code{FALSE}.} +} +\value{ +\code{write_fisdata_defaults()} and \code{write_current_fisdata_defaults()} return the json-string that +was written to the file (invisibly). \code{read_fisdata_defaults()} returns the default +values that were read as a tibble (invisibly). +} +\description{ +Default settings can be written to a JSON file and read again from +this file. If the file \code{.fisdata.json} exists in the user's home +it is read automatically when fisdata is loaded in an interactive +session (see 'Details' for how to configure this behaviour). +} +\details{ +When fisdata is loaded in an interactive session, it tries to load defaults +from a file \code{fisdata.json}. You can use another file by setting the environment +variable \code{FISDATA_DEFAULTS_FILE} to the path to this file before loading +fisdata. To do this once, you can use \code{\link[=Sys.setenv]{Sys.setenv()}}, to configure R to always +load a different file, you can set \code{FISDATA_DEFAULTS_FILE} in your \code{.Renviron} +file. +} diff --git a/pkgdown/_pkgdown.yml b/pkgdown/_pkgdown.yml index 18c8023..6d06b7f 100644 --- a/pkgdown/_pkgdown.yml +++ b/pkgdown/_pkgdown.yml @@ -32,6 +32,7 @@ reference: - show_url - browse_url - set_fisdata_defaults + - write_fisdata_defaults - title: Datasets desc: Datasets describing values that can be used in the querying functions diff --git a/tests/testthat/test-defaults.R b/tests/testthat/test-defaults.R index bce9688..0dbfd48 100644 --- a/tests/testthat/test-defaults.R +++ b/tests/testthat/test-defaults.R @@ -2,6 +2,8 @@ library(stringr) library(lubridate, warn.conflicts = FALSE) library(tibble) library(glue) +library(withr) +library(jsonlite) fd_opts <- paste0( "fisdata_", @@ -51,7 +53,7 @@ test_that("set_fisdata_defaults() works with valid inputs", { }) -test_that("set_fisdata_defaults() works with \"\"", { +test_that('set_fisdata_defaults() works with ""', { set_fisdata_defaults(sector = "") expect_equal(getOption("fisdata_sector"), "") @@ -196,6 +198,162 @@ test_that("fd_def() works", { }) -# reset all defaults to their initial state -reset_fisdata_defaults() +test_that("write_current_fisdata_defaults() works", { + reset_fisdata_defaults() + set_fisdata_defaults(sector = "AL", gender = "F", category = "WC") + ref <- toJSON( + list(sector = "AL", season = "", gender = "W", category = "WC", + discipline = "", active_only = FALSE), + auto_unbox = TRUE, + pretty = TRUE + ) + local_file("fisdata.json") + expect_equal(write_current_fisdata_defaults("fisdata.json"), ref) + expect_true(file.exists("fisdata.json")) + expect_equal(paste(readLines("fisdata.json"), collapse = "\n"), ref, + ignore_attr = TRUE) +}) + + +test_that("write_current_fisdata_defaults() handles existing file", { + local_file("fisdata.json") + write_current_fisdata_defaults("fisdata.json") + expect_error(write_current_fisdata_defaults("fisdata.json"), + "The file fisdata.json exists.") + expect_silent(write_current_fisdata_defaults("fisdata.json", overwrite = TRUE)) +}) + + +test_that("write_fisdata_defaults() works", { + ref <- toJSON( + list(sector = "CC", season = "", gender = "M", category = "WC", + discipline = "", active_only = FALSE), + auto_unbox = TRUE, + pretty = TRUE + ) + local_file("fisdata.json") + expect_equal( + write_fisdata_defaults("fisdata.json", sector = "CC", gender = "M", category = "WC"), + ref + ) + expect_true(file.exists("fisdata.json")) + expect_equal(paste(readLines("fisdata.json"), collapse = "\n"), ref, + ignore_attr = TRUE) +}) + + +test_that("write_fisdata_defaults() handles existing file", { + local_file("fisdata.json") + write_fisdata_defaults("fisdata.json", sector = "CC", gender = "M") + expect_error(write_fisdata_defaults("fisdata.json", sector = "CC", gender = "M"), + "The file fisdata.json exists.") + expect_silent(write_current_fisdata_defaults("fisdata.json", overwrite = TRUE)) +}) + + +test_that("write_fisdata_defaults() rejects NULL as default", { + local_file("fisdata.json") + expect_error(write_fisdata_defaults("fisdata.json", sector = "CC", discipline = NULL), + "Defaults must no be NULL.*NULL: discipline") + expect_false(file.exists("fisdata.json")) +}) + + +test_that("read_fisdata_defaults() reads defaults without applying them", { + local_file("fisdata.json") + write_fisdata_defaults("fisdata.json", sector = "AL", season = "2024", gender = "W", + category = "WC", discipline = "SL", active_only = TRUE) + reset_fisdata_defaults() + + expect_equal( + read_fisdata_defaults("fisdata.json", apply = FALSE, verbose = FALSE), + tibble(sector = "AL", season = "2024", gender = "W", category = "WC", + discipline = "SL", active_only = TRUE) + ) + expect_equal( + get_fisdata_defaults(), + tibble(sector = "", season = "", gender = "", + category = "", discipline = "", active_only = FALSE) + ) +}) + + +test_that("read_fisdata_defaults() applies defaults", { + local_file("fisdata.json") + write_fisdata_defaults("fisdata.json", sector = "CC", season = "2025", gender = "M", + category = "WC", discipline = "SP", active_only = TRUE) + reset_fisdata_defaults() + + expect_equal( + read_fisdata_defaults("fisdata.json", verbose = FALSE), + tibble(sector = "CC", season = "2025", gender = "M", + category = "WC", discipline = "SP", active_only = TRUE) + ) + expect_equal( + get_fisdata_defaults(), + tibble(sector = "CC", season = "2025", gender = "M", + category = "WC", discipline = "SP", active_only = TRUE) + ) +}) + +test_that("read_fisdata_defaults() handles invalid files", { + local_file("fisdata.json") + + expect_error(read_fisdata_defaults("fisdata.json"), "does not exist") + + writeLines("{", "fisdata.json") + expect_error(read_fisdata_defaults("fisdata.json"), "Failed to parse") + + writeLines( + toJSON( + list(sector = "AL", season = "2024", gender = "W", category = "WC", + discipline = "SL"), + auto_unbox = TRUE + ), + "fisdata.json" + ) + expect_error(read_fisdata_defaults("fisdata.json"), "Some defaults have no value set: active_only") + + writeLines( + toJSON( + list(sector = c("AL", "CC"), season = "1940", gender = "W", category = "WC", + discipline = "SL", active_only = TRUE), + auto_unbox = TRUE + ), + "fisdata.json" + ) + expect_error(read_fisdata_defaults("fisdata.json"), "contents.*not valid") + + writeLines( + toJSON( + list(sector = "AL", season = "1940", gender = "W", category = "WC", + discipline = "SL", active_only = TRUE), + auto_unbox = TRUE + ), + "fisdata.json" + ) + expect_error(read_fisdata_defaults("fisdata.json"), "'1940' is not a valid season") +}) + + +test_that("read_fisdata_defaults() creates output in the appropriate situations", { + local_file("fisdata.json") + write_fisdata_defaults("fisdata.json", sector = "CC", season = "2025", gender = "M", + category = "WC", discipline = "SP", active_only = TRUE) + + expect_silent(read_fisdata_defaults("fisdata.json", apply = FALSE, verbose = FALSE)) + expect_silent(read_fisdata_defaults("fisdata.json", apply = TRUE, verbose = FALSE)) + read_fisdata_defaults("fisdata.json", apply = FALSE, verbose = TRUE) %>% + expect_message("contains the following defaults") %>% + expect_output("tibble.*sector +season +gender +category") + read_fisdata_defaults("fisdata.json", apply = TRUE, verbose = TRUE) %>% + expect_message("sector.*CC.*Cross-Country") %>% + expect_message("season.*2025") %>% + expect_message("gender.*M") %>% + expect_message("category.*WC.*World Cup") %>% + expect_message("discipline.*SP") %>% + expect_message("active_only.*'TRUE'") +}) + +reset_fisdata_defaults()