R/conditions.R

Defines functions append_arg arg_as_string format_arg_label glue_lines stop_native_implementation ensure_full_stop enumerate make_names_loc_bullet cnd_body.vctrs_error_names_must_be_unique cnd_header.vctrs_error_names_must_be_unique stop_names_must_be_unique cnd_body.vctrs_error_names_cannot_be_dot_dot cnd_header.vctrs_error_names_cannot_be_dot_dot stop_names_cannot_be_dot_dot cnd_body.vctrs_error_names_cannot_be_empty cnd_header.vctrs_error_names_cannot_be_empty stop_names_cannot_be_empty stop_names stop_recycle_incompatible_size stop_corrupt_ordered_levels stop_corrupt_factor_levels stop_scalar_type stop_unimplemented stop_unsupported maybe_warn_deprecated_lossy_cast allow_lossy_cast new_error_cast_lossy loss_type cnd_body.vctrs_error_cast_lossy cnd_header.vctrs_error_cast_lossy stop_lossy_cast maybe_lossy_cast cnd_body.vctrs_error_incompatible_size cnd_header.vctrs_error_incompatible_size stop_incompatible_size stop_incompatible_op cnd_type_message_df_label incompatible_attrib_bullets cnd_type_message_type_label cnd_type_message cnd_type_separator stop_incompatible_shape stop_incompatible_cast stop_incompatible_type stop_incompatible warn_vctrs stop_vctrs

Documented in allow_lossy_cast maybe_lossy_cast stop_incompatible_cast stop_incompatible_op stop_incompatible_size stop_incompatible_type

#' Custom conditions for vctrs package
#'
#' These functions are called for their side effect of raising
#' errors and warnings.
#' These conditions have custom classes and structures to make
#' testing easier.
#'
#' @inheritParams rlang::args_error_context
#' @param x,y,to Vectors
#' @param ...,class Only use these fields when creating a subclass.
#' @param x_arg,y_arg,to_arg Argument names for `x`, `y`, and `to`. Used in
#'   error messages to inform the user about the locations of incompatible
#'   types.
#' @param action An option to customize the incompatible type message depending
#'   on the context. Errors thrown from [vec_ptype2()] use `"combine"` and
#'   those thrown from [vec_cast()] use `"convert"`.
#' @param details Any additional human readable details.
#' @param message An overriding message for the error. `details` and
#'   `message` are mutually exclusive, supplying both is an error.
#'
#' @examples
#'
#' # Most of the time, `maybe_lossy_cast()` returns its input normally:
#' maybe_lossy_cast(
#'   c("foo", "bar"),
#'   NA,
#'   "",
#'   lossy = c(FALSE, FALSE),
#'   x_arg = "",
#'   to_arg = ""
#' )
#'
#' # If `lossy` has any `TRUE`, an error is thrown:
#' try(maybe_lossy_cast(
#'   c("foo", "bar"),
#'   NA,
#'   "",
#'   lossy = c(FALSE, TRUE),
#'   x_arg = "",
#'   to_arg = ""
#' ))
#'
#' # Unless lossy casts are allowed:
#' allow_lossy_cast(
#'   maybe_lossy_cast(
#'     c("foo", "bar"),
#'     NA,
#'     "",
#'     lossy = c(FALSE, TRUE),
#'     x_arg = "",
#'     to_arg = ""
#'   )
#' )
#'
#' @keywords internal
#' @name vctrs-conditions
NULL

stop_vctrs <- function(message = NULL,
                       class = NULL,
                       ...,
                       call = caller_env()) {
  abort(
    message,
    class = c(class, "vctrs_error"),
    ...,
    call = call
  )
}
warn_vctrs <- function(message = NULL,
                       class = NULL,
                       ...,
                       call = caller_env()) {
  warn(
    message,
    class = c(class, "vctrs_warning"),
    ...,
    call = call
  )
}

stop_incompatible <- function(x,
                              y,
                              ...,
                              details = NULL,
                              message = NULL,
                              class = NULL,
                              call = caller_env()) {
  stop_vctrs(
    message,
    class = c(class, "vctrs_error_incompatible"),
    x = x,
    y = y,
    details = details,
    ...,
    call = call
  )
}

#' @return
#' `stop_incompatible_*()` unconditionally raise an error of class
#' `"vctrs_error_incompatible_*"` and `"vctrs_error_incompatible"`.
#'
#' @rdname vctrs-conditions
#' @export
stop_incompatible_type <- function(x,
                                   y,
                                   ...,
                                   x_arg,
                                   y_arg,
                                   action = c("combine", "convert"),
                                   details = NULL,
                                   message = NULL,
                                   class = NULL,
                                   call = caller_env()) {
  obj_check_vector(x, arg = x_arg)
  obj_check_vector(y, arg = y_arg)

  action <- arg_match(action)

  message <- cnd_type_message(
    x,
    y,
    x_arg,
    y_arg,
    details,
    action,
    message,
    from_dispatch = match_from_dispatch(...)
  )

  subclass <- switch(
    action,
    combine = "vctrs_error_ptype2",
    convert = "vctrs_error_cast"
  )

  stop_incompatible(
    x, y,
    x_arg = x_arg,
    y_arg = y_arg,
    details = details,
    ...,
    message = message,
    class = c(class, subclass, "vctrs_error_incompatible_type"),
    call = call
  )
}

#' @rdname vctrs-conditions
#' @export
stop_incompatible_cast <- function(x,
                                   to,
                                   ...,
                                   x_arg,
                                   to_arg,
                                   details = NULL,
                                   message = NULL,
                                   class = NULL,
                                   call = caller_env()) {
  stop_incompatible_type(
    x = x,
    y = to,
    to = to,
    ...,
    x_arg = x_arg,
    y_arg = to_arg,
    to_arg = to_arg,
    action = "convert",
    details = details,
    message = message,
    class = class,
    call = call
  )
}

stop_incompatible_shape <- function(x,
                                    y,
                                    x_size,
                                    y_size,
                                    axis,
                                    x_arg,
                                    y_arg,
                                    call = caller_env()) {
  details <- format_error_bullets(c(
    x = glue::glue("Incompatible sizes {x_size} and {y_size} along axis {axis}.")
  ))
  stop_incompatible_type(
    x,
    y,
    x_arg = x_arg,
    y_arg = y_arg,
    details = details,
    call = call
  )
}

type_actions <- c(
  "combine", "convert"
)

cnd_type_separator <- function(action) {
  if (identical(action, "combine")) {
    "and"
  } else if (identical(action, "convert")) {
    "to"
  } else {
    abort("Internal error: Unknown `action`.")
  }
}

cnd_type_message <- function(x,
                             y,
                             x_arg,
                             y_arg,
                             details,
                             action,
                             message,
                             from_dispatch = FALSE,
                             fallback = NULL) {
  if (!is_null(message)) {
    if (!is_null(details)) {
      abort("Can't supply both `message` and `details`.")
    }
    return(message)
  }

  x_arg <- arg_as_string(x_arg)
  y_arg <- arg_as_string(y_arg)

  if (nzchar(x_arg)) {
    x_name <- paste0(" `", x_arg, "` ")
  } else {
    x_name <- " "
  }

  if (nzchar(y_arg)) {
    y_name <- paste0(" `", y_arg, "` ")
  } else {
    y_name <- " "
  }

  separator <- cnd_type_separator(action)

  if (is.data.frame(x) && is.data.frame(y)) {
    if (vec_is_coercible(new_data_frame(x), new_data_frame(y))) {
      x_type <- cnd_type_message_df_label(x)
      y_type <- cnd_type_message_df_label(y)
    } else {
      x_type <- vec_ptype_full(x)
      y_type <- vec_ptype_full(y)
    }
  } else {
    x_type <- cnd_type_message_type_label(x)
    y_type <- cnd_type_message_type_label(y)
  }

  converting <- action == "convert"

  # If we are here directly from dispatch, this means there is no
  # ptype2 method implemented and the is-same-class fallback has
  # failed because of diverging attributes. The author of the class
  # should implement a ptype2 method as documented in the FAQ
  # indicated below.
  if (from_dispatch && !converting && identical(class(x)[[1]], class(y)[[1]])) {
    details <- c(incompatible_attrib_bullets(), details)
    details <- format_error_bullets(details)
  }

  if (is_null(fallback)) {
    end <- "."
  } else {
    end <- glue::glue("; falling back to {fallback}.")
  }

  if (converting && nzchar(y_arg)) {
    header <- glue::glue("Can't convert{x_name}<{x_type}> to match type of{y_name}<{y_type}>{end}")
  } else {
    header <- glue::glue("Can't {action}{x_name}<{x_type}> {separator}{y_name}<{y_type}>{end}")
  }

  paste_line(header, details)
}

cnd_type_message_type_label <- function(x) {
  if (is.data.frame(x)) {
    class(x)[[1]]
  } else {
    vec_ptype_full(x)
  }
}
incompatible_attrib_bullets <- function() {
  c(
    x = "Some attributes are incompatible.",
    i = "The author of the class should implement vctrs methods.",
    i = "See <https://vctrs.r-lib.org/reference/faq-error-incompatible-attributes.html>."
  )
}

cnd_type_message_df_label <- function(x) {
  x <- class(x)[[1]]

  if (identical(x, "tbl_df")) {
    "tibble"
  } else {
    x
  }
}


#' @rdname vctrs-conditions
#' @export
stop_incompatible_op <- function(op,
                                 x,
                                 y,
                                 details = NULL,
                                 ...,
                                 message = NULL,
                                 class = NULL,
                                 call = caller_env()) {

  message <- message %||% glue_lines(
    "<{vec_ptype_full(x)}> {op} <{vec_ptype_full(y)}> is not permitted",
    details
  )

  stop_incompatible(
    x, y,
    op = op,
    details = details,
    ...,
    message = message,
    class = c(class, "vctrs_error_incompatible_op"),
    call = call
  )
}

#' @rdname vctrs-conditions
#' @export
stop_incompatible_size <- function(x,
                                   y,
                                   x_size,
                                   y_size,
                                   ...,
                                   x_arg,
                                   y_arg,
                                   details = NULL,
                                   message = NULL,
                                   class = NULL,
                                   call = caller_env()) {
  stop_incompatible(
    x,
    y,
    x_size = x_size,
    y_size = y_size,
    ...,
    x_arg = x_arg,
    y_arg = y_arg,
    details = details,
    message = message,
    class = c(class, "vctrs_error_incompatible_size"),
    call = call
  )
}

#' @export
cnd_header.vctrs_error_incompatible_size <- function(cnd, ...) {
  if (is_string(cnd$message) && nzchar(cnd$message)) {
    return(cnd$message)
  }

  x_size <- vec_cast(cnd$x_size, int())
  y_size <- vec_cast(cnd$y_size, int())

  stopifnot(
    length(x_size) == 1,
    length(y_size) == 1
  )

  x_arg <- arg_as_string(cnd$x_arg)
  y_arg <- arg_as_string(cnd$y_arg)

  if (nzchar(x_arg)) {
    x_tag <- glue::glue("`{x_arg}` (size {x_size})")
  } else {
    x_tag <- glue::glue("input of size {x_size}")
  }
  if (nzchar(y_arg)) {
    y_tag <- glue::glue("to match `{y_arg}` (size {y_size})")
  } else {
    y_tag <- glue::glue("to size {y_size}")
  }

  glue::glue("Can't recycle {x_tag} {y_tag}.")
}

#' @export
cnd_body.vctrs_error_incompatible_size <- function(cnd, ...) {
  cnd$details
}

#' Lossy cast error
#'
#' @description
#'
#' `r lifecycle::badge("experimental")`
#'
#' By default, lossy casts are an error. Use `allow_lossy_cast()` to
#' silence these errors and continue with the partial results. In this
#' case the lost values are typically set to `NA` or to a lower value
#' resolution, depending on the type of cast.
#'
#' Lossy cast errors are thrown by `maybe_lossy_cast()`. Unlike
#' functions prefixed with `stop_`, `maybe_lossy_cast()` usually
#' returns a result. If a lossy cast is detected, it throws an error,
#' unless it's been wrapped in `allow_lossy_cast()`. In that case, it
#' returns the result silently.
#'
#' @inheritParams stop_incompatible_cast
#' @inheritParams vec_cast
#' @inheritParams rlang::args_error_context
#' @param result The result of a potentially lossy cast.
#' @param to Type to cast to.
#' @param lossy A logical vector indicating which elements of `result`
#'   were lossy.
#'
#'   Can also be a single `TRUE`, but note that `locations` picks up
#'   locations from this vector by default. In this case, supply your
#'   own location vector, possibly empty.
#' @param loss_type The kind of lossy cast to be mentioned in error
#'   messages. Can be loss of precision (for instance from double to
#'   integer) or loss of generality (from character to factor).
#' @param locations An optional integer vector giving the
#'   locations where `x` lost information.
#' @param .deprecation If `TRUE`, the error is downgraded to a
#'   deprecation warning. This is useful for transitioning your class
#'   to a stricter conversion scheme. The warning advises your users
#'   to wrap their code with `allow_lossy_cast()`.
#' @keywords internal
#' @export
maybe_lossy_cast <- function(result, x, to,
                             lossy = NULL,
                             locations = NULL,
                             ...,
                             loss_type = c("precision", "generality"),
                             x_arg,
                             to_arg,
                             call = caller_env(),
                             details = NULL,
                             message = NULL,
                             class = NULL,
                             .deprecation = FALSE) {
  if (!any(lossy)) {
    return(result)
  }
  if (.deprecation) {
    maybe_warn_deprecated_lossy_cast(x, to, loss_type, x_arg, to_arg)
    return(result)
  }

  locations <- locations %||% which(lossy)

  withRestarts(
    vctrs_restart_error_cast_lossy = function() result,
    stop_lossy_cast(
      x = x,
      to = to,
      result = result,
      locations = locations,
      ...,
      loss_type = loss_type,
      x_arg = x_arg,
      to_arg = to_arg,
      details = details,
      message = message,
      class = class,
      call = call
    )
  )
}
stop_lossy_cast <- function(x,
                            to,
                            result,
                            locations = NULL,
                            ...,
                            loss_type,
                            x_arg,
                            to_arg,
                            details = NULL,
                            message = NULL,
                            class = NULL,
                            call = caller_env()) {
  stop_incompatible_cast(
    x = x,
    to = to,
    result = result,
    locations = locations,
    ...,
    loss_type = loss_type,
    x_arg = x_arg,
    to_arg = to_arg,
    details = details,
    class = c(class, "vctrs_error_cast_lossy"),
    call = call
  )
}

#' @export
cnd_header.vctrs_error_cast_lossy <- function(cnd, ...) {
  x_label <- format_arg_label(vec_ptype_full(cnd$x), cnd$x_arg)
  to_label <- format_arg_label(vec_ptype_full(cnd$y), cnd$y_arg)
  loss_type <- loss_type(cnd$loss_type)
  glue::glue("Can't convert from {x_label} to {to_label} due to loss of {loss_type}.")
}
#' @export
cnd_body.vctrs_error_cast_lossy <- function(cnd, ...) {
  if (length(cnd$locations)) {
    format_error_bullets(inline_list("Locations: ", cnd$locations))
  } else {
    character()
  }
}

loss_type <- function(x) {
  stopifnot(
    is_character(x),
    all(x %in% c("precision", "generality"))
  )
  x[[1]]
}

# Used in maybe_warn_deprecated_lossy_cast()
new_error_cast_lossy <- function(x, to, loss_type, x_arg = "", to_arg = "") {
  error_cnd(
    c("vctrs_error_cast_lossy", "vctrs_error_incompatible_type"),
    x = x,
    y = to,
    loss_type = loss_type,
    x_arg = x_arg,
    y_arg = to_arg
  )
}

#' @rdname vctrs-conditions
#' @param x_ptype,to_ptype Suppress only the casting errors where `x`
#'   or `to` match these [prototypes][vec_ptype].
#' @export
allow_lossy_cast <- function(expr, x_ptype = NULL, to_ptype = NULL) {
  withCallingHandlers(
    vctrs_error_cast_lossy = function(err) {
      if (!is_null(x_ptype) && !vec_is(err$x, x_ptype)) {
        return()
      }
      if (!is_null(to_ptype) && !vec_is(err$y, to_ptype)) {
        return()
      }

      invokeRestart("vctrs_restart_error_cast_lossy")
    },
    expr
  )
}

maybe_warn_deprecated_lossy_cast <- function(x,
                                             to,
                                             loss_type,
                                             x_arg,
                                             to_arg,
                                             user_env = caller_env(2)) {
  # Returns `TRUE` if `allow_lossy_cast()` is on the stack and accepts
  # to handle the condition
  handled <- withRestarts(
    vctrs_restart_error_cast_lossy = function() TRUE,
    {
      # Signal fully formed condition but strip the error classes in
      # case someone is catching: This is not an abortive condition.
      cnd <- new_error_cast_lossy(
        x,
        to,
        loss_type = loss_type,
        x_arg = x_arg,
        to_arg = to_arg
      )

      class(cnd) <- setdiff(class(cnd), c("error", "rlang_error"))
      signalCondition(cnd)
      FALSE
    }
  )

  if (handled) {
    return(invisible())
  }

  from <- format_arg_label(vec_ptype_abbr(x), x_arg)
  to <- format_arg_label(vec_ptype_abbr(to), to_arg)

  lifecycle::deprecate_warn(
    when = "0.2.0",
    what = I("Coercion with lossy casts"),
    with = "allow_lossy_cast()",
    details = paste0(
      glue::glue("We detected a lossy transformation from { from } to { to }. "),
      "The result will contain lower-resolution values or missing values. ",
      "To suppress this warning, wrap your code with `allow_lossy_cast()`."
    ),
    always = TRUE,
    user_env = user_env
  )

  invisible()
}

stop_unsupported <- function(x, method, call = caller_env()) {
  msg <- glue::glue("`{method}.{class(x)[[1]]}()` not supported.")
  stop_vctrs(
    "vctrs_error_unsupported",
    message = msg,
    x = x,
    method = method,
    call = call
  )
}

stop_unimplemented <- function(x, method, call = caller_env()) {
  msg <- glue::glue("`{method}.{class(x)[[1]]}()` not implemented.")
  stop_vctrs(
    "vctrs_error_unimplemented",
    message = msg,
    x = x,
    method = method,
    call = call
  )
}

stop_scalar_type <- function(x, arg = NULL, call = caller_env()) {
  if (is_null(arg) || !nzchar(arg)) {
    arg <- "Input"
  } else {
    arg <- glue::backtick(arg)
  }
  msg <- glue::glue("{arg} must be a vector, not {obj_type_friendly(x)}.")
  stop_vctrs(
    msg,
    "vctrs_error_scalar_type",
    actual = x,
    call = call
  )
}

stop_corrupt_factor_levels <- function(x,
                                       arg = "x",
                                       call = caller_env()) {
  msg <- glue::glue("`{arg}` is a corrupt factor with non-character levels")
  abort(msg, call = call)
}

stop_corrupt_ordered_levels <- function(x, arg = "x", call = caller_env()) {
  msg <- glue::glue("`{arg}` is a corrupt ordered factor with non-character levels")
  abort(msg, call = call)
}

stop_recycle_incompatible_size <- function(x_size,
                                           size,
                                           x_arg = "x",
                                           call = caller_env()) {
  stop_vctrs(
    x_size = x_size,
    y_size = size,
    x_arg = x_arg,
    # FIXME: tibble is the only package that uses `vctrs_error_recycle_incompatible_size`
    class = c("vctrs_error_incompatible_size", "vctrs_error_recycle_incompatible_size"),
    call = call
  )
}


# Names -------------------------------------------------------------------

stop_names <- function(class = NULL, ..., call = caller_env()) {
  stop_vctrs(
    class = c(class, "vctrs_error_names"),
    ...,
    call = call
  )
}

stop_names_cannot_be_empty <- function(names, call = caller_env()) {
  stop_names(
    class = "vctrs_error_names_cannot_be_empty",
    names = names,
    call = call
  )
}

#' @export
cnd_header.vctrs_error_names_cannot_be_empty <- function(cnd, ...) {
  "Names can't be empty."
}

#' @export
cnd_body.vctrs_error_names_cannot_be_empty <- function(cnd, ...) {
  locations <- detect_empty_names(cnd$names)

  if (length(locations) == 1) {
    bullet <- glue::glue("Empty name found at location {locations}.")
  } else {
    bullet <- glue::glue("Empty names found at locations {ensure_full_stop(enumerate(locations))}")
  }

  bullet <- c(x = bullet)
  format_error_bullets(bullet)
}

stop_names_cannot_be_dot_dot <- function(names, call = caller_env()) {
  stop_names(
    class = "vctrs_error_names_cannot_be_dot_dot",
    names = names,
    call = call
  )
}

#' @export
cnd_header.vctrs_error_names_cannot_be_dot_dot <- function(cnd, ...) {
  "Names can't be of the form `...` or `..j`."
}

#' @export
cnd_body.vctrs_error_names_cannot_be_dot_dot <- function(cnd, ...) {
  names <- cnd$names

  locations <- detect_dot_dot(names)
  names <- names[locations]

  split <- vec_group_loc(names)

  info <- map2_chr(split$key, split$loc, make_names_loc_bullet)

  header <- "These names are invalid:"
  header <- c(x = header)
  header <- format_error_bullets(header)

  message <- bullets(info, header = header)
  message <- indent(message, 2)

  message
}

stop_names_must_be_unique <- function(names,
                                      arg = "",
                                      call = caller_env()) {
  stop_names(
    class = "vctrs_error_names_must_be_unique",
    arg = arg,
    names = names,
    call = call
  )
}

#' @export
cnd_header.vctrs_error_names_must_be_unique <- function(cnd, ...) {
  "Names must be unique."
}

#' @export
cnd_body.vctrs_error_names_must_be_unique <- function(cnd, ...) {
  names <- cnd$names

  dups <- vec_group_loc(names)
  dup_indicator <- map_lgl(dups$loc, function(x) length(x) != 1L)
  dups <- vec_slice(dups, dup_indicator)

  header <- "These names are duplicated:"
  header <- c(x = header)
  header <- format_error_bullets(header)

  info <- map2_chr(dups$key, dups$loc, make_names_loc_bullet)

  message <- bullets(info, header = header)
  message <- indent(message, 2)

  arg <- arg_as_string(cnd$arg)
  if (arg != "") {
    hint <- c(i = glue::glue("Use argument `{cnd$arg}` to specify repair strategy."))
    message <- c(message, format_error_bullets(hint))
  }

  message
}


make_names_loc_bullet <- function(x, loc) {
  if (length(loc) == 1) {
    glue::glue("{glue::double_quote(x)} at location {loc}.")
  } else {
    glue::glue("{glue::double_quote(x)} at locations {ensure_full_stop(enumerate(loc))}")
  }
}

enumerate <- function(x, max = 5L, allow_empty = FALSE) {
  n <- length(x)

  if (n == 0L && !allow_empty) {
    abort("Internal error: Enumeration can't be empty.")
  }
  if (n > max) {
    paste0(glue::glue_collapse(x[seq2(1, max)], ", "), ", etc.")
  } else {
    if (n == 2) {
      last <- " and "
    } else {
      last <- ", and "
    }
    glue::glue_collapse(x, ", ", last = last)
  }
}

ensure_full_stop <- function(x) {
  n <- nchar(x)
  if (substr(x, n, n) == ".") {
    x
  } else {
    paste0(x, ".")
  }
}


stop_native_implementation <- function(fn) {
  cli::cli_abort(
    c(
      "{.fn {fn}} is implemented at C level.",
      " " = "This R function is purely indicative and should never be called."
    ),
    .internal = TRUE
  )
}


# Helpers -----------------------------------------------------------------

glue_lines <- function(..., env = parent.frame()) {
  out <- map_chr(chr(...), glue::glue, .envir = env)
  paste(out, collapse = "\n")
}

format_arg_label <- function(type, arg = "") {
  type <- paste0("<", type, ">")
  if (nzchar(arg)) {
    paste0("`", arg, "` ", type)
  } else {
    type
  }
}

arg_as_string <- function(arg) {
  if (is_null(arg)) {
    ""
  } else if (is_string(arg)) {
    arg
  } else {
    as_label(arg)
  }
}
append_arg <- function(x, arg) {
  if (is_null(arg)) {
    return(x)
  }

  arg <- arg_as_string(arg)
  if (nzchar(arg)) {
    glue::glue("{x} `{arg}`")
  } else {
    x
  }
}

Try the vctrs package in your browser

Any scripts or data that you put into this service are public.

vctrs documentation built on May 29, 2024, 11:39 a.m.