diff --git a/NAMESPACE b/NAMESPACE index f10d58e..fa64dd6 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,17 +1,27 @@ # Generated by roxygen2: do not edit by hand S3method(as.character,gdal_opts) +S3method(as.list,gdal_config) S3method(as.list,gdal_opts) S3method(as_config_option,default) S3method(as_config_option,gdal_config_opts) S3method(as_config_option,gdal_vsi_opts) S3method(as_gdal_args,character) S3method(as_gdal_args,default) +S3method(as_gdal_args,gdal_config) S3method(as_gdal_args,gdal_opts) S3method(as_gdal_args,list) +S3method(as_gdal_config,character) +S3method(as_gdal_config,default) +S3method(as_gdal_config,gdal_config) +S3method(as_gdal_config,gdal_config_file) +S3method(as_gdal_config,gdal_opts) +S3method(as_gdal_config,list) S3method(as_gdal_config_opts,character) S3method(as_gdal_config_opts,default) +S3method(as_gdal_config_opts,gdal_config) S3method(as_gdal_config_opts,gdal_config_opts) +S3method(as_gdal_config_opts,gdal_config_sitrep) S3method(as_gdal_config_opts,list) S3method(as_gdal_config_opts,tbl_df) S3method(as_gdal_creation_opts,character) @@ -33,7 +43,12 @@ S3method(as_gdalg,default) S3method(as_gdalg,gdalg) S3method(as_gdalg,json) S3method(as_gdalg,list) +S3method(as_tibble,gdal_config_sitrep) +S3method(c,gdal_config) S3method(c,gdal_opts) +S3method(format,gdal_config) +S3method(format,gdal_config_file) +S3method(format,gdal_config_sitrep) S3method(format,gdal_opts) S3method(format,gdalg) S3method(format,gpq_file_info) @@ -41,6 +56,9 @@ S3method(format,gpq_geo_metadata) S3method(format,gpq_inspect) S3method(format,gpq_row_groups) S3method(format,gpq_schema_info) +S3method(print,gdal_config) +S3method(print,gdal_config_file) +S3method(print,gdal_config_sitrep) S3method(print,gdal_opts) S3method(print,gdalg) S3method(print,gpq_file_info) @@ -50,6 +68,7 @@ S3method(print,gpq_row_groups) S3method(print,gpq_schema_info) export(as_config_option) export(as_gdal_args) +export(as_gdal_config) export(as_gdal_config_opts) export(as_gdal_creation_opts) export(as_gdal_open_opts) @@ -67,7 +86,16 @@ export(fgb_creation_opts) export(fgb_open_opts) export(fgb_validate_spatial_index_ram) export(gdal_config) +export(gdal_config_active) +export(gdal_config_file) +export(gdal_config_file_read) +export(gdal_config_file_write) +export(gdal_config_get) export(gdal_config_opts) +export(gdal_config_reset) +export(gdal_config_set) +export(gdal_config_sitrep) +export(gdal_config_unset) export(gdal_creation_opts) export(gdal_driver_names) export(gdal_drivers) @@ -107,6 +135,7 @@ export(gpq_row_groups) export(gpq_schema) export(gpq_schema_info) export(is_vsi_path) +export(local_gdal_config) export(local_hash) export(local_last_modified) export(ping) @@ -144,6 +173,7 @@ export(vsi_sync) export(vsi_type) export(vsi_zip) export(vsi_zip_curl) +export(with_gdal_config) export(xml_parse_gdal_driver_config_opts) export(xml_parse_gdal_options) importFrom(bit64,is.integer64) @@ -166,15 +196,25 @@ importFrom(crayon,green) importFrom(crayon,italic) importFrom(crayon,yellow) importFrom(dplyr,bind_rows) +importFrom(dplyr,case_when) importFrom(dplyr,filter) importFrom(dplyr,filter_out) importFrom(dplyr,first) importFrom(dplyr,group_by) +importFrom(dplyr,group_map) +importFrom(dplyr,if_else) importFrom(dplyr,mutate) importFrom(dplyr,replace_values) +importFrom(dplyr,select) importFrom(dplyr,summarise) +importFrom(dplyr,ungroup) +importFrom(gdalraster,get_config_option) importFrom(gdalraster,get_num_cpus) importFrom(gdalraster,get_usable_physical_ram) +importFrom(gdalraster,set_config_option) +importFrom(gdalraster,vsi_get_fs_options) +importFrom(gdalraster,vsi_get_fs_prefixes) +importFrom(gdalraster,vsi_set_path_option) importFrom(gdalraster,vsi_stat_size) importFrom(httr2,req_method) importFrom(httr2,req_perform) @@ -193,11 +233,19 @@ importFrom(openssl,sha1) importFrom(openssl,sha256) importFrom(openssl,sha512) importFrom(ps,errno) +importFrom(purrr,compact) importFrom(purrr,discard) +importFrom(purrr,imap) +importFrom(purrr,imap_chr) +importFrom(purrr,iwalk) +importFrom(purrr,keep) +importFrom(purrr,map) importFrom(purrr,map2) importFrom(purrr,map_chr) importFrom(purrr,map_dfr) importFrom(purrr,pluck) +importFrom(purrr,reduce) +importFrom(purrr,walk) importFrom(rlang,.data) importFrom(rlang,.env) importFrom(rlang,`!!!`) @@ -205,6 +253,7 @@ importFrom(rlang,`!!`) importFrom(rlang,`%||%`) importFrom(rlang,`:=`) importFrom(rlang,abort) +importFrom(rlang,arg_match) importFrom(rlang,arg_match0) importFrom(rlang,as_bytes) importFrom(rlang,call_inspect) @@ -223,17 +272,21 @@ importFrom(rlang,current_fn) importFrom(rlang,empty_env) importFrom(rlang,env_cache) importFrom(rlang,env_get) +importFrom(rlang,env_has) importFrom(rlang,env_poke) importFrom(rlang,error_cnd) +importFrom(rlang,exec) importFrom(rlang,inform) importFrom(rlang,inherits_all) importFrom(rlang,inherits_any) importFrom(rlang,is_empty) importFrom(rlang,is_named) importFrom(rlang,is_named2) +importFrom(rlang,is_string) importFrom(rlang,list2) importFrom(rlang,local_use_cli) importFrom(rlang,message_cnd) +importFrom(rlang,names2) importFrom(rlang,new_environment) importFrom(rlang,on_load) importFrom(rlang,run_on_load) @@ -248,20 +301,25 @@ importFrom(rvest,html_nodes) importFrom(rvest,html_text) importFrom(stats,setNames) importFrom(stringr,fixed) +importFrom(stringr,str_detect) importFrom(stringr,str_extract) importFrom(stringr,str_match) importFrom(stringr,str_remove) importFrom(stringr,str_remove_all) importFrom(stringr,str_split) +importFrom(stringr,str_starts) importFrom(stringr,str_trim) importFrom(tibble,add_column) importFrom(tibble,as_tibble) importFrom(tibble,tibble) +importFrom(tidyr,fill) +importFrom(tidyr,separate_wider_delim) importFrom(utils,capture.output) importFrom(utils,globalVariables) importFrom(utils,modifyList) importFrom(utils,packageVersion) importFrom(utils,unzip) +importFrom(withr,defer) importFrom(withr,local_locale) importFrom(xml2,read_html) importFrom(xml2,read_xml) diff --git a/R/aaa.R b/R/aaa.R index 0f473b5..0dac316 100644 --- a/R/aaa.R +++ b/R/aaa.R @@ -25,6 +25,110 @@ GDAL_VECTOR_DRIVERS <- c( "OGR_VRT" ) + +# VSI Prefixes ---------------------------------------------------------------------------------------------------- + +#' @keywords internal +#' @noRd +GDAL_VSI_PREFIXES <- c( + "/vsistdin/", + "/vsistdout/", + "/vsimem/", + "/vsicurl/", + "/vsizip/", + "/vsitar/", + "/vsigzip/", + "/vsicache/", + "/vsis3/", + "/vsigs/", + "/vsiaz/", + "/vsiadls/", + "/vsicrypt/" +) + +# core config options --------------------------------------------------------------------------------------------- + +# curated names of *core* (non-driver, non-VSI) GDAL configuration options, drawn from +# https://gdal.org/en/stable/user/configoptions.html (GDAL 3.13). this is the only known-option +# source that cannot be derived from the running GDAL build: VSI/network/credential options are +# enumerated at runtime via vsi_get_fs_options(), and per-driver config options come from the +# curated driver metadata table. used only for advisory typo checking and the gdal_config() +# snapshot probe - never blocking. keep small; when in doubt, leave a name out (a key that GDAL +# already resolves to a value is never flagged). +#' @keywords internal +#' @noRd +GDAL_CORE_CONFIG_OPTS <- c( + # performance / general + "GDAL_NUM_THREADS", + "GDAL_CACHEMAX", + "GDAL_FORCE_CACHING", + "GDAL_BAND_BLOCK_CACHE", + "GDAL_MAX_DATASET_POOL_SIZE", + "GDAL_MAX_DATASET_POOL_RAM_USAGE", + "GDAL_SWATH_SIZE", + "GDAL_DISABLE_READDIR_ON_OPEN", + "GDAL_READDIR_LIMIT_ON_OPEN", + "GDAL_DATA", + "GDAL_DRIVER_PATH", + "GDAL_SKIP", + "OGR_SKIP", + "GDAL_CONFIG_FILE", + "GDAL_RASTERIO_RESAMPLING", + "GDAL_XML_VALIDATION", + "GDAL_FILENAME_IS_UTF8", + "GDAL_PAM_ENABLED", + "GDAL_PAM_PROXY_DIR", + # cpl / logging + "CPL_DEBUG", + "CPL_LOG", + "CPL_LOG_ERRORS", + "CPL_TIMESTAMP", + "CPL_MAX_ERROR_REPORTS", + "CPL_ACCUM_ERROR_MSG", + "CPL_CURL_VERBOSE", + "CPL_CURL_GZIP", + "CPL_TMPDIR", + # vsi general + "VSI_CACHE", + "VSI_CACHE_SIZE", + # http core (not reliably part of the runtime vsi option metadata on all builds) + "GDAL_HTTP_TIMEOUT", + "GDAL_HTTP_CONNECTTIMEOUT", + "GDAL_HTTP_MAX_RETRY", + "GDAL_HTTP_RETRY_DELAY", + "GDAL_HTTP_RETRY_CODES", + "GDAL_HTTP_VERSION", + "GDAL_HTTP_USERAGENT", + "GDAL_HTTP_PROXY", + "GDAL_HTTPS_PROXY", + "GDAL_HTTP_PROXYUSERPWD", + "GDAL_HTTP_NETRC", + "GDAL_HTTP_SSL_VERIFYPEER", + "GDAL_HTTP_UNSAFESSL", + "GDAL_HTTP_TCP_KEEPALIVE", + # cloud extras not consistently enumerated by vsi_get_fs_options() + "AWS_REGION", + # proj / srs + "PROJ_LIB", + "PROJ_DATA", + "PROJ_NETWORK", + "PROJ_NETWORK_ENDPOINT", + "OSR_DEFAULT_AXIS_MAPPING_STRATEGY", + "OSR_WKT_FORMAT", + "CHECK_WITH_INVERT_PROJ", + "CENTER_LONG", + # ogr general + "OGR_INTERLEAVED_READING", + "OGR_ARC_STEPSIZE", + "OGR_ARC_MAX_GAP", + "OGR_WKT_PRECISION", + "OGR_ORGANIZE_POLYGONS", + "OGR_ENABLE_PARTIAL_REPROJECTION", + "OGR_SQL_LIKE_AS_ILIKE", + "OGR_FORCE_ASCII", + "OGR_CURRENT_DATE" +) + # magic bytes ----------------------------------------------------------------------------------------------------- #' @keywords internal diff --git a/R/gdal_config.R b/R/gdal_config.R index 3ee9ebf..b69e933 100644 --- a/R/gdal_config.R +++ b/R/gdal_config.R @@ -2,28 +2,1078 @@ # # Title : GDAL Configuration # By : Jimmy Briggs -# Date : 2026-06-15 +# Date : 2026-07-02 # # ------------------------------------------------------------------------ -# main config ----------------------------------------------------------------------------------------------------- +# class ----------------------------------------------------------------------------------------------------------- #' GDAL Configuration #' #' @name gdal_config #' #' @description -#' Creates a session-wide, global GDAL Configuration object of class `"gdal_config"`. +#' A `gdal_config` is an inert, composable *value* describing a GDAL configuration: one +#' [gdal_config_opts()] payload (options bound globally) plus zero or more path-bound +#' [gdal_vsi_opts()] payloads (options bound to a VSI path prefix, e.g. credentials for a specific +#' bucket). It is the unit the stateful verbs apply ([gdal_config_set()]), the scoped helpers pin +#' ([with_gdal_config()]), the session tracks ([gdal_config_active()]), and the GDAL configuration +#' file serializes ([gdal_config_file_write()]). #' -#' @param ... Configuration options as named `KEY = value` pairs. +#' - `gdal_config(...)`: construct from `KEY = value` pairs, [gdal_config_opts()] / +#' [gdal_vsi_opts()] objects, named lists, `"KEY=VALUE"` strings, or other `gdal_config` values +#' (later inputs win). Empty call gives an empty configuration. +#' - `as_gdal_config()`: coerce from the same inputs. +#' - `c()`: merge configurations (later values win, per key and per path). +#' +#' @section One key namespace, two binding tiers: +#' There is no separate "VSI option" key type - `AWS_ACCESS_KEY_ID` and `GDAL_HTTP_TIMEOUT` live in +#' the same namespace. What differs is the tier a *value* is bound at. GDAL resolves an option for +#' a path by checking the path-specific store first (`VSISetPathSpecificOption()`; longest-matching +#' prefix wins), then falling through the ordinary chain: thread-local, global in-memory +#' (`CPLSetConfigOption()`, which also holds config-file values loaded at GDAL initialization), +#' environment variable (live `getenv()`), built-in default. A [gdal_vsi_opts()] *without* a +#' `vsi_path` is therefore just config opts and merges into the global channel; *with* a +#' `vsi_path` it is kept path-bound in the `vsi` channel. +#' +#' @section Rendering: +#' `as_gdal_args()` renders the global channel as `--config KEY=VALUE` CLI tokens. Path-bound VSI +#' options **cannot** be expressed on the CLI at all - GDAL only accepts them in-process +#' ([gdalraster::vsi_set_path_option()]) or via the config file `[credentials]` section - so they +#' are omitted with a message by default; `flatten_vsi = TRUE` emits them as global `--config` +#' tokens instead (loses the path scoping; warns when paths carry conflicting values for the same +#' key). [gdal_config_file_write()] is the only lossless serialization of a full `gdal_config`. +#' +#' @param ... Configuration options: `KEY = value` pairs, [gdal_config_opts()] / [gdal_vsi_opts()] +#' objects, named lists, `"KEY=VALUE"` character vectors, or `gdal_config` values. Open/creation +#' options are rejected - they are per-call algorithm arguments (see [as_gdal_args()]), never +#' configuration. +#' @param x Object to coerce. +#' @inheritParams rlang::args_error_context #' #' @returns -#' gdal_config +#' A `gdal_config` object: a list with elements `opts` (a [gdal_config_opts()]) and `vsi` +#' (a named list of path-bound [gdal_vsi_opts()], keyed by path prefix). +#' +#' @seealso [gdal_config_set()], [gdal_config_active()], [gdal_config_sitrep()], +#' [gdal_config_file_read()], [with_gdal_config()] +#' +#' @export +#' +#' @importFrom rlang list2 names2 current_env +#' @importFrom stats setNames +#' @importFrom utils modifyList #' +#' @examples +#' gdal_config( +#' GDAL_NUM_THREADS = "ALL_CPUS", +#' gpkg_config_opts(sqlite_synchronous = "OFF"), +#' gdal_vsi_opts(AWS_S3_ENDPOINT = "t3.storage.dev", vsi_path = "/vsis3/my-bucket/") +#' ) +gdal_config <- function(...) { + dots <- rlang::list2(...) + nms <- rlang::names2(dots) + call <- rlang::current_env() + payload <- stats::setNames(list(), character()) + vsi <- list() + + merge_vsi <- function(vsi, path, opts_payload) { + current <- if (is.null(vsi[[path]])) list() else .gdal_opts_payload(vsi[[path]]) + vsi[[path]] <- as_gdal_vsi_opts(utils::modifyList(current, opts_payload), vsi_path = path) + vsi + } + + for (i in seq_along(dots)) { + x <- dots[[i]] + if (nzchar(nms[[i]])) { + payload <- utils::modifyList(payload, .gdal_opts_normalize(stats::setNames(list(x), nms[[i]]), call = call)) + next + } + if (is_gdal_config(x)) { + payload <- utils::modifyList(payload, .gdal_opts_payload(x$opts)) + for (path in names(x$vsi)) { + vsi <- merge_vsi(vsi, path, .gdal_opts_payload(x$vsi[[path]])) + } + next + } + if (is_gdal_open_opts(x) || is_gdal_creation_opts(x)) { + gdal_abort_config( + c( + "{.cls {class(x)[[1]]}} options are per-call algorithm arguments, not configuration.", + "i" = "Pass them to the algorithm via {.fn as_gdal_args} instead." + ), + cls = "gdal_config_channel_error", + call = call + ) + } + if (is_gdal_vsi_opts(x) && !is.null(attr(x, "vsi_path"))) { + vsi <- merge_vsi(vsi, attr(x, "vsi_path"), .gdal_opts_payload(x)) + next + } + if (is_gdal_config_opts(x) || is_gdal_vsi_opts(x)) { + payload <- utils::modifyList(payload, .gdal_opts_payload(x)) + next + } + if (is.list(x) || is.character(x)) { + payload <- utils::modifyList(payload, .gdal_opts_payload(as_gdal_config_opts(x, call = call))) + next + } + gdal_abort_config( + "Can't interpret {.obj_type_friendly {x}} as configuration options.", + cls = "gdal_config_channel_error", + call = call + ) + } + + new_gdal_config(as_gdal_config_opts(payload), vsi) +} + +#' @keywords internal +#' @noRd +#' @importFrom purrr walk map_chr +new_gdal_config <- function(opts = as_gdal_config_opts(list()), vsi = list()) { + check_inherits(opts, "gdal_config_opts") + purrr::walk(vsi, check_vsi_opts) + names(vsi) <- purrr::map_chr(vsi, attr, "vsi_path") + structure( + list(opts = opts, vsi = vsi), + class = c("gdal_config", "list") + ) +} + +# coercion -------------------------------------------------------------------------------------------------------- + +#' @rdname gdal_config +#' @export +#' @importFrom rlang caller_env +as_gdal_config <- function(x, ..., call = rlang::caller_env()) { + UseMethod("as_gdal_config") +} + +#' @rdname gdal_config +#' @export +as_gdal_config.default <- function(x, ..., call = rlang::caller_env()) { + gdal_abort_config( + "Can't coerce {.cls {class(x)}} to {.cls gdal_config}.", + cls = "gdal_config_coerce_error", + call = call + ) +} + +#' @rdname gdal_config +#' @export +as_gdal_config.gdal_config <- function(x, ..., call = rlang::caller_env()) { + x +} + +#' @rdname gdal_config +#' @export +as_gdal_config.gdal_opts <- function(x, ..., call = rlang::caller_env()) { + gdal_config(x) +} + +#' @rdname gdal_config +#' @export +as_gdal_config.list <- function(x, ..., call = rlang::caller_env()) { + gdal_config(x) +} + +#' @rdname gdal_config +#' @export +as_gdal_config.character <- function(x, ..., call = rlang::caller_env()) { + gdal_config(x) +} + +# methods --------------------------------------------------------------------------------------------------------- + +#' @export +#' @importFrom purrr compact +#' @importFrom rlang exec +c.gdal_config <- function(...) { + dots <- purrr::compact(list(...)) + if (length(dots) == 0L) { + return(NULL) + } + rlang::exec(gdal_config, !!!dots) +} + +#' @export +#' @importFrom cli cli_text cli_alert_info +#' @importFrom purrr imap_chr +format.gdal_config <- function(x, ...) { + n_opts <- length(.gdal_opts_payload(x$opts)) + n_vsi <- length(x$vsi) + + gpq_cli_fmt({ + cli::cli_text("{.cls gdal_config}") + if (n_opts == 0L && n_vsi == 0L) { + cli::cli_alert_info("Empty configuration.") + } + if (n_opts > 0L) { + payload <- .gdal_opts_payload(x$opts) + kv <- paste( + purrr::imap_chr(payload, function(value, key) paste0(key, "=", cli_redact(key, value))), + collapse = ", " + ) + cli::cli_alert_info("Configuration Options ({n_opts}): {.field {kv}}") + } + if (n_vsi > 0L) { + cli::cli_alert_info("VSI Path Options ({n_vsi} path{?s}):") + for (path in names(x$vsi)) { + payload <- .gdal_opts_payload(x$vsi[[path]]) + kv <- paste( + purrr::imap_chr(payload, function(value, key) paste0(key, "=", cli_redact(key, value))), + collapse = ", " + ) + cli::cli_text(" {.file {path}}: {.field {kv}}") + } + } + }) +} + +#' @export +print.gdal_config <- function(x, ...) { + cat(format(x, ...), sep = "\n") + invisible(x) +} + +#' @rdname as_gdal_config_opts +#' @export +#' @importFrom rlang caller_env +as_gdal_config_opts.gdal_config <- function(x, ..., driver = NULL, call = rlang::caller_env()) { + as_gdal_config_opts(x$opts, driver = driver, call = call) +} + #' @export -gdal_config <- function(...) {} +#' @importFrom purrr map +as.list.gdal_config <- function(x, ...) { + list( + opts = as.list(x$opts), + vsi = purrr::map(x$vsi, as.list) + ) +} + +#' Convert GDAL Configuration to Algorithm Arguments +#' +#' @description +#' Render the global channel of a [gdal_config()] as `--config KEY=VALUE` CLI tokens. Path-bound +#' VSI options cannot be expressed on the CLI (GDAL only accepts them in-process or via the config +#' file `[credentials]` section): by default they are omitted with a message; `flatten_vsi = TRUE` +#' emits them as global `--config` tokens instead - practically correct with a single credential +#' set, but the path scoping is lost (a warning is raised when paths carry conflicting values for +#' the same key). Use [gdal_config_file_write()] for a lossless serialization. +#' +#' @param flatten_vsi Logical; emit path-bound VSI options as global `--config` tokens. Defaults +#' to `FALSE`. +#' +#' @rdname as_gdal_args +#' @export +#' @importFrom purrr map keep compact reduce +#' @importFrom utils modifyList +as_gdal_args.gdal_config <- function(x, ..., flatten_vsi = FALSE) { + args <- as_gdal_args(x$opts, ...) + if (length(x$vsi) == 0L) { + return(args) + } + if (!isTRUE(flatten_vsi)) { + gdal_inform_config( + c( + "Omitting path-bound VSI options for {.file {names(x$vsi)}}: they cannot be expressed as + {.code --config} CLI flags.", + "i" = "Use {.fn gdal_config_file_write} to serialize them (gdalrc {.field [credentials]}), + or {.code flatten_vsi = TRUE} to emit them as global {.code --config} tokens." + ), + cls = "gdal_config_vsi_message" + ) + return(args) + } + payloads <- purrr::map(x$vsi, .gdal_opts_payload) + all_keys <- unlist(purrr::map(payloads, names), use.names = FALSE) + conflicting <- unique(all_keys[duplicated(all_keys)]) |> + purrr::keep(function(key) { + values <- purrr::compact(purrr::map(payloads, key)) + length(unique(unlist(values))) > 1L + }) + if (length(conflicting) > 0L) { + gdal_warn_config( + "Flattening VSI options with conflicting values across paths for {.field {conflicting}}; + the last path wins. The result does not preserve per-path credentials.", + cls = "gdal_config_flatten_warning" + ) + } + flat <- purrr::reduce(payloads, utils::modifyList, .init = list()) + c(args, as_gdal_args(as_gdal_config_opts(flat), ...)) +} + +# active ---------------------------------------------------------------------------------------------------------- + +#' Active GDAL Configuration +#' +#' @description +#' The [gdal_config()] value this session has applied through [gdal_config_set()] (including any +#' load-time package defaults). Retrieve it to reuse the session's configuration elsewhere - e.g. +#' render it for a CLI invocation (`as_gdal_args(gdal_config_active())`) or serialize it +#' ([gdal_config_file_write()]). +#' +#' @returns +#' A [gdal_config()] value (empty when nothing has been applied). +#' +#' @seealso [gdal_config_set()], [gdal_config_sitrep()] +#' +#' @export +#' +#' @importFrom purrr imap +#' +#' @examples +#' gdal_config_active() +gdal_config_active <- function() { + state <- .gdal_config_state() + vstate <- .gdal_vsi_state() + vsi <- purrr::imap(vstate$values, function(payload, path) { + as_gdal_vsi_opts(as.list(payload), vsi_path = path) + }) + new_gdal_config(as_gdal_config_opts(state$values), vsi) +} + +# verbs ----------------------------------------------------------------------------------------------------------- + +#' Get, Set, Unset, and Reset GDAL Configuration +#' +#' @name gdal_config_set +#' +#' @description +#' The stateful verbs of the configuration system: they apply [gdal_config()] values to the +#' running GDAL process (via [gdalraster::set_config_option()] / +#' [gdalraster::vsi_set_path_option()]), track what was applied in the session's active +#' configuration ([gdal_config_active()]), and record what was in effect beforehand so state can +#' always be restored. +#' +#' - `gdal_config_set(...)`: apply options (same inputs as [gdal_config()]). +#' - `gdal_config_get()`: effective value(s), read live from GDAL (never from package state). +#' - `gdal_config_unset()`: remove options, with explicit semantics for the environment-variable +#' fallback (`mode`). +#' - `gdal_config_reset()`: restore touched options to their pre-package state. +#' +#' For scoped (temporary) application, the recommended pattern around pipeline executions, see +#' [local_gdal_config()] / [with_gdal_config()]. +#' +#' @section How GDAL resolves a configuration option: +#' For a given key, GDAL checks, in order: a thread-local option, the process-global in-memory +#' option (set via `CPLSetConfigOption()`; this also holds values loaded from the +#' [GDAL config file][gdal_config_file()] at initialization), an environment variable of the same +#' name (read live via `getenv()`), and finally the built-in default. Two consequences drive this +#' API's design: +#' +#' - **Set is authoritative**: a value applied with `gdal_config_set()` wins over an environment +#' variable for as long as it is set. +#' - **Unset is not**: clearing the in-memory option (what `set_config_option(key, "")` does) +#' merely *reveals* the environment variable underneath again. You cannot force GDAL's built-in +#' default while an envvar exists - hence the explicit `mode` argument of `gdal_config_unset()` +#' (`"reveal"`, `"mask"`, or `"scrub"`). +#' +#' Because environment variables, other packages, and the config file all write to the same GDAL +#' state, the package never mirrors values: reads always go to GDAL, and the package only records +#' the *deltas* it applied (plus what was effective beforehand, for restore). +#' +#' @section Known options: +#' Advisory typo checking (classed warning, never blocking - custom application-defined options +#' are legal) draws on, in decreasing authority: the VSI/network/credential options enumerated +#' from the running GDAL build ([gdalraster::vsi_get_fs_options()] across all registered +#' filesystems), the curated per-driver configuration options (driver metadata table), a small +#' curated list of core CPL/GDAL/OGR names (`GDAL_CORE_CONFIG_OPTS`; GDAL does not expose these at +#' runtime), and GDAL-relevant environment variables present at load. Keys GDAL already resolves +#' to a value are never flagged. +#' +#' @inheritParams gdal_config +#' @param keys Character vector of configuration option names. For `gdal_config_get()` and +#' `gdal_config_reset()`, `NULL` (default) means all keys the package has touched. +#' @param mode How `gdal_config_unset()` should treat the environment-variable fallback: +#' - `"reveal"` (default): clear the in-memory option; if an envvar of the same name exists its +#' value re-surfaces (a classed warning says so). +#' - `"mask"`: set the option explicitly to its documented default (from the driver metadata or +#' the runtime VSI metadata; errors when no documented default is known) - "GDAL default +#' behavior" despite an envvar. +#' - `"scrub"`: also remove the environment variable (`Sys.unsetenv()`); the removed value is +#' recorded so `gdal_config_reset()` can restore it. A process-wide side effect - explicit +#' opt-in only. +#' +#' @returns +#' - `gdal_config_get()`: a named character vector of effective values (`""` = not set). +#' - `gdal_config_set()` / `gdal_config_unset()`: invisibly, a named list of the prior effective +#' values of the keys touched. +#' - `gdal_config_reset()`: invisibly, the character vector of keys restored. +#' +#' @seealso [gdal_config()], [gdal_config_active()], [gdal_config_sitrep()], [with_gdal_config()] +#' +#' @examples +#' \dontrun{ +#' gdal_config_set(GDAL_NUM_THREADS = "ALL_CPUS", CPL_TMPDIR = tempdir()) +#' gdal_config_get("GDAL_NUM_THREADS") +#' gdal_config_active() +#' gdal_config_reset() +#' +#' # driver-typed config opts flow straight in: +#' gdal_config_set(gpkg_config_opts(sqlite_synchronous = "OFF")) +#' gdal_config_reset() +#' } +NULL + +#' @rdname gdal_config_set +#' @export +gdal_config_set <- function(...) { + cfg <- gdal_config(...) + invisible(.gdal_config_apply(cfg)) +} + +#' @rdname gdal_config_set +#' @export +#' @importFrom gdalraster get_config_option +#' @importFrom stats setNames +gdal_config_get <- function(keys = NULL) { + if (is.null(keys)) { + keys <- names(.gdal_config_state()$restore) + } + if (length(keys) == 0L) { + return(stats::setNames(character(), character())) + } + check_character(keys) + keys <- toupper(keys) + vapply(stats::setNames(keys, keys), gdalraster::get_config_option, character(1)) +} + +#' @rdname gdal_config_set +#' @export +#' @importFrom gdalraster get_config_option +#' @importFrom rlang arg_match +gdal_config_unset <- function(keys, mode = c("reveal", "mask", "scrub")) { + mode <- rlang::arg_match(mode) + check_character(keys) + keys <- toupper(keys) + .gdal_config_check_keys(keys) + + state <- .gdal_config_state() + prior <- list() + + for (key in keys) { + prior[[key]] <- gdalraster::get_config_option(key) + env_val <- Sys.getenv(key) + + if (identical(mode, "mask")) { + default <- .gdal_config_known_default(key) + if (is.na(default)) { + gdal_abort_config( + c( + "No documented default is known for {.field {key}}; cannot {.arg mode} = {.val mask}.", + "i" = "Use {.val reveal} (or {.val scrub}) instead, or set an explicit value." + ), + cls = "gdal_config_default_error" + ) + } + .gdal_config_put(key, default) + next + } + + # reveal, and scrub (which additionally removes the envvar) + .gdal_config_put(key, "") + if (nzchar(env_val)) { + if (identical(mode, "scrub")) { + state$restore[[key]]$scrubbed_env <- env_val + Sys.unsetenv(key) + } else { + gdal_warn_config( + c( + "Unsetting {.field {key}} reveals the environment variable underneath: + GDAL now resolves it to {.val {env_val}}.", + "i" = "Use {.code mode = \"mask\"} to pin the documented default, or + {.code mode = \"scrub\"} to remove the environment variable too." + ), + cls = "gdal_config_env_warning" + ) + } + } + } + invisible(prior) +} + +#' @rdname gdal_config_set +#' @export +#' @importFrom gdalraster set_config_option vsi_set_path_option +#' @importFrom stats setNames +gdal_config_reset <- function(keys = NULL) { + state <- .gdal_config_state() + reset_vsi <- is.null(keys) + if (is.null(keys)) { + keys <- names(state$restore) + } else { + check_character(keys) + keys <- toupper(keys) + } + + restored <- character() + for (key in keys) { + entry <- state$restore[[key]] + if (is.null(entry)) { + next + } + if (!is.null(entry$scrubbed_env)) { + do.call(Sys.setenv, stats::setNames(list(entry$scrubbed_env), key)) + } + restore_to <- if (isTRUE(entry$prior_was_env) || !nzchar(entry$prior)) "" else entry$prior + gdalraster::set_config_option(key, restore_to) + state$restore[[key]] <- NULL + state$values[[key]] <- NULL + restored <- c(restored, key) + } + + if (reset_vsi) { + vstate <- .gdal_vsi_state() + for (path in names(vstate$values)) { + for (key in names(vstate$values[[path]])) { + gdalraster::vsi_set_path_option(path, key, "") + } + } + vstate$values <- list() + } + + invisible(restored) +} + +# scoped ---------------------------------------------------------------------------------------------------------- + +#' Scoped GDAL Configuration +#' +#' @name with_gdal_config +#' +#' @description +#' Apply GDAL configuration options for a limited scope and guarantee restoration afterwards - +#' even on error. This is the recommended pattern for workloads: the configuration channel is the +#' *execution environment* of a pipeline run (it cannot ride in algorithm arguments or a serialized +#' GDALG), so it should be pinned in place around the execution and cleaned up after. +#' +#' - `local_gdal_config()`: applies the options now and restores them when the calling frame (or +#' `.local_envir`) exits, in the style of the withr `local_*()` functions. +#' - `with_gdal_config()`: applies the options, evaluates `code`, restores, and returns the value +#' of `code`. +#' +#' Restoration is exact: the effective GDAL values *and* the package state are returned to their +#' condition at entry, so scoped usage never disturbs global configuration set via +#' [gdal_config_set()]. For VSI path-scoped options the prior value cannot be read back from GDAL, +#' so keys not previously set by the package are cleared on exit. +#' +#' @inheritParams gdal_config +#' @param .local_envir Environment whose exit triggers restoration. Defaults to the calling frame. +#' @param new Configuration options: a [gdal_config()] value or anything [gdal_config()] accepts. +#' @param code Code to execute with the configuration in place. +#' +#' @returns +#' - `local_gdal_config()`: invisibly, a named list of the prior effective values. +#' - `with_gdal_config()`: the value of `code`. +#' +#' @seealso [gdal_config()], [gdal_config_set()] +#' +#' @examples +#' \dontrun{ +#' # pin the execution environment around a pipeline run; restored even on error +#' with_gdal_config( +#' gdal_config(GDAL_NUM_THREADS = "ALL_CPUS", OGR_SQLITE_SYNCHRONOUS = "OFF"), +#' gdalraster::gdal_run("vector convert", c("--input", "in.gpkg", "--output", "out.parquet")) +#' ) +#' +#' # the CPL_DEBUG/GeoParquet guard (see the config docs) is just: +#' with_gdal_config(gdal_config(CPL_DEBUG = "OFF"), gdal_vector_info("data.parquet")) +#' } +NULL + +#' @rdname with_gdal_config +#' @export +#' @importFrom gdalraster get_config_option set_config_option vsi_set_path_option +#' @importFrom stats setNames +#' @importFrom withr defer +local_gdal_config <- function(..., .local_envir = parent.frame()) { + cfg <- gdal_config(...) + state <- .gdal_config_state() + vstate <- .gdal_vsi_state() + + keys <- names(.gdal_opts_payload(cfg$opts)) + snapshot <- lapply(stats::setNames(keys, keys), function(key) { + list(effective = gdalraster::get_config_option(key), env = Sys.getenv(key)) + }) + values_before <- state$values + restore_before <- state$restore + vsi_values_before <- vstate$values + + prior <- .gdal_config_apply(cfg) + + withr::defer( + { + for (key in names(snapshot)) { + s <- snapshot[[key]] + restore_to <- if (nzchar(s$env) && identical(s$effective, s$env)) "" else s$effective + gdalraster::set_config_option(key, restore_to) + } + for (path in names(cfg$vsi)) { + for (key in names(.gdal_opts_payload(cfg$vsi[[path]]))) { + prev <- vsi_values_before[[path]][[key]] + gdalraster::vsi_set_path_option(path, key, if (is.null(prev)) "" else prev) + } + } + state$values <- values_before + state$restore <- restore_before + vstate$values <- vsi_values_before + }, + envir = .local_envir + ) + + invisible(prior) +} + +#' @rdname with_gdal_config +#' @export +#' @importFrom rlang current_env +with_gdal_config <- function(new, code) { + local_gdal_config(new, .local_envir = rlang::current_env()) + code +} + +# sitrep ---------------------------------------------------------------------------------------------------------- + +#' GDAL Configuration Situational Report +#' +#' @description +#' A live, provenance-attributed report of the effective GDAL configuration landscape: for each +#' key with a visible signal, the effective value (read live from GDAL - the single source of +#' truth), the environment-variable value, what this package pinned, and a best-effort `source` +#' attribution: +#' +#' - `gdalvector`: currently pinned by this package ([gdal_config_set()]). +#' - `envvar`: resolved from an environment variable. +#' - `config_file`: matches a value declared in the discovered [GDAL config file][gdal_config_file()]. +#' - `external`: set in GDAL's in-memory store by something else (another package, a direct +#' [gdalraster::set_config_option()] call, ...). +#' - `unset`: no value in effect. +#' +#' Coverage spans keys pinned by the package, GDAL-relevant environment variables, config-file +#' entries, and a probe of the full known-option universe (runtime VSI metadata, driver metadata, +#' and the curated core names), so externally set in-memory values for any documented key are +#' discovered too. Secret-bearing values are redacted in printed output. A baseline sitrep taken +#' at package load is stashed internally ("the world as gdalvector found it"). +#' +#' @returns +#' A `gdal_config_sitrep` object: `options` (tibble: `key`, `effective`, `envvar`, `set`, +#' `source`), `set` (the active session payload), `vsi` (path-bound options per prefix), `file` +#' (the discovered config file), and `time`. Has cli `format()`/`print()` and +#' [tibble::as_tibble()] methods. +#' +#' @seealso [gdal_config_active()], [gdal_config_file()], [gdal_config_set()] +#' +#' @export +#' +#' @importFrom dplyr bind_rows case_when if_else +#' @importFrom gdalraster get_config_option +#' @importFrom purrr map +#' @importFrom tibble tibble +#' +#' @examples +#' \dontrun{ +#' gdal_config_sitrep() +#' tibble::as_tibble(gdal_config_sitrep()) +#' } +gdal_config_sitrep <- function() { + state <- .gdal_config_state() + file <- gdal_config_file() + env_now <- .gdal_config_env_snapshot() + + file_opts <- .gdal_opts_payload(file$configoptions) + names(file_opts) <- toupper(names(file_opts)) + + keys <- sort(unique(toupper(c( + names(state$values), + names(env_now), + names(file_opts), + .gdal_config_known_opts() + )))) + + rows <- purrr::map(keys, function(key) { + effective <- gdalraster::get_config_option(key) + envvar <- Sys.getenv(key) + pinned <- key %in% names(state$values) + in_file <- key %in% names(file_opts) + if (!nzchar(effective) && !nzchar(envvar) && !pinned && !in_file) { + return(NULL) + } + source <- dplyr::case_when( + pinned ~ "gdalvector", + !nzchar(effective) ~ "unset", + nzchar(envvar) && identical(effective, envvar) ~ "envvar", + in_file && identical(effective, unname(unlist(file_opts[key]))) ~ "config_file", + .default = "external" + ) + tibble::tibble( + key = key, + effective = effective, + envvar = dplyr::if_else(nzchar(envvar), envvar, NA_character_), + set = if (pinned) state$values[[key]] else NA_character_, + source = source + ) + }) + + structure( + list( + options = dplyr::bind_rows(rows), + set = state$values, + vsi = .gdal_vsi_state()$values, + file = file, + time = Sys.time() + ), + class = c("gdal_config_sitrep", "list") + ) +} + +#' Coerce a Configuration Sitrep to Configuration Options +#' +#' @description +#' Bridge from the live view back to the value classes: extract a [gdal_config_opts()] object +#' from a [gdal_config_sitrep()]. From there the full render surface applies - [as_gdal_args()] +#' (`--config` CLI tokens), [gdal_render()], [as_config_option()]. +#' +#' @param scope Which options to extract: `"session"` (default) takes only the options this +#' package pinned via [gdal_config_set()]; `"effective"` takes every option with a non-empty +#' effective value regardless of origin (envvars, config file, other packages) - useful for +#' reproducing the session's configuration in a child process or CLI invocation. +#' +#' @rdname as_gdal_config_opts +#' @export +#' @importFrom rlang arg_match caller_env +#' @importFrom stats setNames +as_gdal_config_opts.gdal_config_sitrep <- function( + x, + ..., + scope = c("session", "effective"), + driver = NULL, + call = rlang::caller_env() +) { + scope <- rlang::arg_match(scope) + payload <- if (identical(scope, "session")) { + x$set + } else { + tbl <- x$options + if (is.null(tbl) || nrow(tbl) == 0L) { + list() + } else { + tbl <- tbl[nzchar(tbl$effective), , drop = FALSE] + stats::setNames(as.list(tbl$effective), tbl$key) + } + } + as_gdal_config_opts(as.list(payload), driver = driver, call = call) +} + +#' @export +#' @importFrom tibble as_tibble +as_tibble.gdal_config_sitrep <- function(x, ...) { + x$options +} + +#' @export +#' @importFrom cli cli_text cli_alert_info +#' @importFrom purrr imap_chr +format.gdal_config_sitrep <- function(x, ...) { + opts <- x$options + n <- if (is.null(opts)) 0L else nrow(opts) + + gpq_cli_fmt({ + cli::cli_text("{.cls gdal_config_sitrep}") + if (!is.null(x$file$path)) { + n_file <- length(.gdal_opts_payload(x$file$configoptions)) + cli::cli_alert_info("Config file: {.file {x$file$path}} ({n_file} option{?s})") + } else { + cli::cli_alert_info("Config file: none detected") + } + if (n == 0L) { + cli::cli_alert_info("No GDAL configuration options in effect.") + } else { + cli::cli_alert_info("Options in effect ({n}):") + for (i in seq_len(n)) { + key <- opts$key[[i]] + val <- cli_redact(key, opts$effective[[i]]) + if (!nzchar(val)) { + val <- "" + } + cli::cli_text(" {.optname {key}} = {.optval {val}} [{opts$source[[i]]}]") + } + } + if (length(x$vsi) > 0L) { + cli::cli_alert_info("VSI path options ({length(x$vsi)} path{?s}):") + for (path in names(x$vsi)) { + vals <- x$vsi[[path]] + kv <- paste( + purrr::imap_chr(as.list(vals), function(value, key) paste0(key, "=", cli_redact(key, value))), + collapse = ", " + ) + cli::cli_text(" {.file {path}}: {.field {kv}}") + } + } + }) +} + +#' @export +print.gdal_config_sitrep <- function(x, ...) { + cat(format(x, ...), sep = "\n") + invisible(x) +} # initialization -------------------------------------------------------------------------------------------------- -gdal_config_init <- function() {} +# initialize the config state at package load: empty stores, a snapshot of GDAL-relevant +# environment variables, and config-file discovery. no GDAL side effects here - defaults (and the +# at-load baseline sitrep) are applied by gdal_config_init_defaults(), which runs after the driver +# metadata is available. +#' @keywords internal +#' @noRd +#' @importFrom rlang env_has +gdal_config_init <- function() { + if (!exists(".pkg_env")) { + return(invisible()) + } + if (!rlang::env_has(.pkg_env, "gdal")) { + return(invisible()) + } + + state <- .pkg_env$gdal$config + state$values <- list() + state$restore <- list() + state$env_at_load <- .gdal_config_env_snapshot() + state$file <- .gdal_config_file_discover() + state$at_load <- NULL + + vstate <- .pkg_env$gdal$vsi + vstate$values <- list() + vstate$known_tbl <- NULL + + invisible() +} + +# stash the at-load baseline sitrep ("the world as gdalvector found it", before any package +# defaults), then apply the curated fill-only session defaults: applied through gdal_config_set() +# (tracked, visible in the sitrep as source "gdalvector", reversible via gdal_config_reset()), +# only when the key currently resolves to nothing (a user envvar, gdalrc, or another package's +# setting always wins), and skipped entirely via options(gdalvector.config_defaults = FALSE). +#' @keywords internal +#' @noRd +#' @importFrom gdalraster get_config_option +#' @importFrom purrr iwalk +#' @importFrom rlang env_has +#' @importFrom stats setNames +gdal_config_init_defaults <- function() { + if (!exists(".pkg_env")) { + return(invisible()) + } + if (!rlang::env_has(.pkg_env, "gdal")) { + return(invisible()) + } + + state <- .pkg_env$gdal$config + state$at_load <- gdal_config_sitrep() + + if (isFALSE(getOption("gdalvector.config_defaults", default = TRUE))) { + return(invisible()) + } + defaults <- c(GDAL_HTTP_USERAGENT = pkg_user_agent()) + purrr::iwalk(defaults, function(value, key) { + current <- gdalraster::get_config_option(key) + if (!nzchar(current)) { + gdal_config_set(stats::setNames(list(value), key)) + } else if (identical(current, value)) { + # the value in GDAL is exactly our own default: a previous load of this package pinned it + # and the reload wiped the tracking state. re-adopt it so the active config reflects it and + # gdal_config_reset() can still restore the pre-package state (unset). + state$restore[[key]] <- list(prior = "", prior_was_env = FALSE, scrubbed_env = NULL) + state$values[[key]] <- value + } + # any other non-empty value came from the user/environment: fill-only, never override + }) + + invisible() +} + +# internal - state ------------------------------------------------------------------------------------------------ + +# the two state environments (created by pkg_env_init()): +# - config: `values` (named list KEY -> set value; the active configuration's global payload), +# `restore` (named list KEY -> list(prior, prior_was_env, scrubbed_env)), +# `env_at_load`, `file` (parsed gdal_config_file), `at_load` (baseline sitrep). +# - vsi: `values` (named list path -> named character vector of KEY = value), +# `known_tbl` (lazily cached runtime VSI option metadata, see .vsi_fs_options_tbl()). +# gdal_config_active() wraps `values` + vsi `values` into the classed gdal_config value on demand, +# so the classed view is always fresh while per-key mutation stays a plain list update. +#' @keywords internal +#' @noRd +.gdal_config_state <- function() { + .pkg_env$gdal$config +} + +#' @keywords internal +#' @noRd +.gdal_vsi_state <- function() { + .pkg_env$gdal$vsi +} + +# the single write primitive: record the pre-package state of a key on first touch (so reset +# always restores to the true pre-package state), apply the value to GDAL, and track it in +# `values`. an empty value drops the key from `values` (it is no longer pinned) while keeping the +# restore entry. +#' @keywords internal +#' @noRd +#' @importFrom gdalraster get_config_option set_config_option +.gdal_config_put <- function(key, value) { + state <- .gdal_config_state() + if (is.null(state$restore[[key]])) { + prior <- gdalraster::get_config_option(key) + state$restore[[key]] <- list( + prior = prior, + prior_was_env = nzchar(prior) && identical(prior, Sys.getenv(key)), + scrubbed_env = NULL + ) + } + gdalraster::set_config_option(key, value) + state$values[[key]] <- if (nzchar(value)) value else NULL + invisible() +} + +#' @keywords internal +#' @noRd +#' @importFrom gdalraster vsi_set_path_option +#' @importFrom stats setNames +.gdal_vsi_put <- function(path, key, value) { + vstate <- .gdal_vsi_state() + gdalraster::vsi_set_path_option(path, key, value) + current <- vstate$values[[path]] + if (is.null(current)) { + current <- stats::setNames(character(), character()) + } + current[[key]] <- value + vstate$values[[path]] <- current + invisible() +} + +# apply a gdal_config value to GDAL. returns the named list of prior effective values for the +# global-channel keys set (the manual-restore contract of gdal_config_set). +#' @keywords internal +#' @noRd +#' @importFrom gdalraster get_config_option +.gdal_config_apply <- function(cfg) { + payload <- .gdal_opts_payload(cfg$opts) + .gdal_config_check_keys(names(payload)) + + prior <- list() + for (key in names(payload)) { + if (identical(key, "GDAL_CONFIG_FILE")) { + gdal_warn_config( + c( + "{.field GDAL_CONFIG_FILE} is only read at GDAL initialization; + setting it now has no effect on the running process.", + "i" = "It will only affect child processes that inherit it as an environment variable." + ), + cls = "gdal_config_file_warning" + ) + } + prior[[key]] <- gdalraster::get_config_option(key) + .gdal_config_put(key, payload[[key]]) + } + + for (path in names(cfg$vsi)) { + vsi_payload <- .gdal_opts_payload(cfg$vsi[[path]]) + for (key in names(vsi_payload)) { + .gdal_vsi_put(path, key, vsi_payload[[key]]) + } + } + + prior +} + +# internal - known options ---------------------------------------------------------------------------------------- + +# advisory (warning, non-blocking) check of option names against the known sets - unknown keys are +# legal in GDAL (custom/app-defined), so this only guards against typos. +#' @keywords internal +#' @noRd +#' @importFrom gdalraster get_config_option +.gdal_config_check_keys <- function(keys) { + if (length(keys) == 0L) { + return(invisible(TRUE)) + } + known <- .gdal_config_known_opts() + unknown <- keys[!(toupper(keys) %in% known)] + # a key GDAL already resolves to a value is evidently in use somewhere; don't flag it + unknown <- unknown[!vapply(unknown, function(k) nzchar(gdalraster::get_config_option(k)), logical(1))] + if (length(unknown) > 0L) { + gdal_warn_config( + "Unknown configuration option{?s}: {.field {unknown}} (not in the known GDAL option sets; + a typo is silently ignored by GDAL).", + cls = "gdal_config_unknown_warning" + ) + return(invisible(FALSE)) + } + invisible(TRUE) +} + +# the known-option universe, in decreasing authority: runtime VSI/network options enumerated from +# the running GDAL build, curated per-driver config options (driver metadata table), the curated +# core CPL/GDAL/OGR names (no runtime enumeration exists), and any GDAL-relevant envvars present +# at load (an app-defined option set via the environment is known to the user's setup by +# definition). +#' @keywords internal +#' @noRd +.gdal_config_known_opts <- function() { + driver_opts <- character() + drivers_env <- .pkg_env$gdal$drivers + if (!is.null(drivers_env) && !is.null(drivers_env$opts_tbl)) { + tbl <- drivers_env$opts_tbl + driver_opts <- tbl$name[tbl$type == "config"] + } + toupper(unique(c( + .vsi_fs_options_tbl()$name, + driver_opts, + GDAL_CORE_CONFIG_OPTS, + names(.gdal_config_state()$env_at_load) + ))) +} + +# documented default for a config option key: the curated per-driver config metadata first, then +# the runtime VSI option metadata. +#' @keywords internal +#' @noRd +.gdal_config_known_default <- function(key) { + drivers_env <- .pkg_env$gdal$drivers + if (!is.null(drivers_env) && !is.null(drivers_env$opts_tbl)) { + tbl <- drivers_env$opts_tbl + hits <- tbl[tbl$type == "config" & toupper(tbl$name) == toupper(key) & !is.na(tbl$default), , drop = FALSE] + if (nrow(hits) > 0L) { + return(hits$default[[1]]) + } + } + vsi_tbl <- .vsi_fs_options_tbl() + if (nrow(vsi_tbl) > 0L) { + hits <- vsi_tbl[toupper(vsi_tbl$name) == toupper(key) & !is.na(vsi_tbl$default), , drop = FALSE] + if (nrow(hits) > 0L) { + return(hits$default[[1]]) + } + } + NA_character_ +} + +# internal - environment ------------------------------------------------------------------------------------------ + +# snapshot of GDAL-relevant environment variables (live). prefix-based: these families are the +# documented GDAL/PROJ/cloud-credential namespaces. +#' @keywords internal +#' @noRd +.gdal_config_env_snapshot <- function() { + env <- Sys.getenv(names = TRUE) + pattern <- paste0( + "^(GDAL_|CPL_|OGR_|OSR_|VSI_|SHAPE_|PROJ_|OSM_|CARTO_|", + "AWS_|AZURE_|GS_|GOOGLE_APPLICATION|OSS_|SWIFT_|SQLITE_USE_OGR_VFS)" + ) + hits <- env[grepl(pattern, names(env))] + hits[order(names(hits))] +} diff --git a/R/gdal_config_file.R b/R/gdal_config_file.R new file mode 100644 index 0000000..cde1ea6 --- /dev/null +++ b/R/gdal_config_file.R @@ -0,0 +1,316 @@ +# ------------------------------------------------------------------------ +# +# Title : GDAL Configuration File (gdalrc) I/O +# By : Jimmy Briggs +# Date : 2026-07-02 +# +# ------------------------------------------------------------------------ + +# https://gdal.org/en/stable/user/configoptions.html#gdal-configuration-file + +# class ----------------------------------------------------------------------------------------------------------- + +#' GDAL Configuration File +#' +#' @name gdal_config_file +#' +#' @description +#' Read, write, and discover [GDAL configuration files](https://gdal.org/en/stable/user/configoptions.html#gdal-configuration-file) +#' (gdalrc). A parsed file is a classed `gdal_config_file` whose components are the package's +#' option value classes, so a file round-trips directly into the configuration system: +#' +#' - `configoptions`: the `[configoptions]` section as a [gdal_config_opts()] (GDAL >= 3.3). +#' - `directives`: the `[directives]` section as a named character vector (GDAL >= 3.6; +#' e.g. `ignore-env-vars = "yes"`). +#' - `credentials`: the `[credentials]` section (GDAL >= 3.5) as a named list of +#' [gdal_vsi_opts()] - each `[.subsection]`'s `path` key becomes the `vsi_path` binding, so +#' path-bound credentials are first-class values. +#' +#' Functions: +#' +#' - `gdal_config_file_read(path)`: parse a gdalrc. +#' - `gdal_config_file_write(x, path)`: serialize a [gdal_config()] or `gdal_config_file` to a +#' gdalrc. This is the **only lossless serialization** of a full configuration - path-bound VSI +#' options cannot be expressed as CLI `--config` flags (see [as_gdal_args()]). +#' - `gdal_config_file(refresh)`: the file GDAL actually loaded at initialization, discovered via +#' the `GDAL_CONFIG_FILE` environment variable then the user gdalrc +#' (`%USERPROFILE%/.gdal/gdalrc` on Windows, `~/.gdal/gdalrc` otherwise), cached at package load. +#' - [as_gdal_config()]: convert a `gdal_config_file` to a [gdal_config()] value +#' (configoptions + credentials), e.g. to apply it in-process via [gdal_config_set()]. +#' +#' GDAL reads this file **once**, at driver registration (i.e. before this package loads), and +#' loads its `[configoptions]` into the in-memory configuration store (environment variables take +#' precedence over the file unless `ignore-env-vars=yes`). In-process it is therefore a +#' *discovery/provenance* input - setting `GDAL_CONFIG_FILE` after load has no effect on the +#' running process (and [gdal_config_set()] warns if you try). Written files target *future* +#' processes. +#' +#' @param path Path to a GDAL configuration file. For `gdal_config_file_write()`, the destination +#' (written with secrets as-is - it is a credentials file; printing remains redacted). +#' @param x A [gdal_config()] or `gdal_config_file` object to serialize. +#' @param refresh Logical; re-discover and re-parse instead of using the value cached at package +#' load. Defaults to `FALSE`. +#' +#' @returns +#' - `gdal_config_file_read()` / `gdal_config_file()`: a `gdal_config_file` object (`path`, +#' `configoptions`, `directives`, `credentials`). For `gdal_config_file()`, `path` is `NULL` +#' when no file was discovered. +#' - `gdal_config_file_write()`: invisibly, the written path. +#' +#' @seealso [gdal_config()], [gdal_config_set()], [gdal_config_sitrep()] +#' +#' @examples +#' \dontrun{ +#' # the file GDAL loaded at startup, if any +#' gdal_config_file() +#' +#' # serialize the session's configuration for reuse by CLI tools / other processes +#' gdal_config_file_write(gdal_config_active(), "gdalrc") +#' } +NULL + +#' @keywords internal +#' @noRd +#' @importFrom purrr walk map_chr +#' @importFrom stats setNames +new_gdal_config_file <- function( + path = NULL, + configoptions = as_gdal_config_opts(list()), + directives = stats::setNames(character(), character()), + credentials = list() +) { + check_inherits(configoptions, "gdal_config_opts") + purrr::walk(credentials, check_vsi_opts) + names(credentials) <- purrr::map_chr(credentials, attr, "vsi_path") + structure( + list( + path = path, + configoptions = configoptions, + directives = directives, + credentials = credentials + ), + class = c("gdal_config_file", "list") + ) +} + +# read ------------------------------------------------------------------------------------------------------------ + +#' @rdname gdal_config_file +#' @export +#' @importFrom dplyr group_by group_map +#' @importFrom purrr compact +#' @importFrom stats setNames +gdal_config_file_read <- function(path) { + check_path(path) + if (dir.exists(path)) { + gdal_abort_config( + "{.arg path} must be a GDAL configuration file, not a directory: {.path {path}}.", + cls = "gdal_config_file_error" + ) + } + entries <- .gdalrc_entries(path) + + section_kv <- function(section) { + hits <- entries[entries$section == section & is.na(entries$subsection), , drop = FALSE] + stats::setNames(hits$value, hits$key) + } + + cred_entries <- entries[entries$section == "credentials" & !is.na(entries$subsection), , drop = FALSE] + credentials <- if (nrow(cred_entries) == 0L) { + list() + } else { + cred_entries |> + dplyr::group_by(.data$subsection) |> + dplyr::group_map(function(rows, key) { + vsi_path <- rows$value[rows$key == "path"] + if (length(vsi_path) != 1L) { + gdal_warn_config( + "Skipping credentials subsection {.field [.{key$subsection}]}: it must declare exactly + one {.code path} key.", + cls = "gdal_config_file_warning" + ) + return(NULL) + } + opts <- rows[rows$key != "path", , drop = FALSE] + as_gdal_vsi_opts(stats::setNames(as.list(opts$value), opts$key), vsi_path = vsi_path) + }) |> + purrr::compact() + } + + new_gdal_config_file( + path = path, + configoptions = as_gdal_config_opts(as.list(section_kv("configoptions"))), + directives = section_kv("directives"), + credentials = credentials + ) +} + +# write ----------------------------------------------------------------------------------------------------------- + +#' @rdname gdal_config_file +#' @export +gdal_config_file_write <- function(x, path) { + check_string(path) + if (is_gdal_config(x)) { + x <- new_gdal_config_file(configoptions = x$opts, credentials = unname(x$vsi)) + } + check_inherits(x, "gdal_config_file") + + kv_lines <- function(payload) paste0(names(payload), "=", unlist(payload, use.names = FALSE)) + + lines <- character() + if (length(x$directives) > 0L) { + lines <- c(lines, "[directives]", kv_lines(x$directives), "") + } + config_payload <- .gdal_opts_payload(x$configoptions) + if (length(config_payload) > 0L) { + lines <- c(lines, "[configoptions]", kv_lines(config_payload), "") + } + if (length(x$credentials) > 0L) { + lines <- c(lines, "[credentials]") + for (i in seq_along(x$credentials)) { + cred <- x$credentials[[i]] + vsi_path <- attr(cred, "vsi_path") + label <- gsub("[^a-z0-9]+", "_", tolower(vsi_strip(vsi_path))) + label <- gsub("^_+|_+$", "", label) + if (!nzchar(label)) { + label <- paste0("credentials_", i) + } + lines <- c( + lines, + paste0("[.", label, "]"), + paste0("path=", vsi_path), + kv_lines(.gdal_opts_payload(cred)), + "" + ) + } + } + + writeLines(lines, path) + invisible(path) +} + +# discovery ------------------------------------------------------------------------------------------------------- + +#' @rdname gdal_config_file +#' @export +gdal_config_file <- function(refresh = FALSE) { + state <- .gdal_config_state() + if (isTRUE(refresh) || is.null(state$file)) { + state$file <- .gdal_config_file_discover() + } + state$file +} + +# coercion -------------------------------------------------------------------------------------------------------- + +#' @rdname gdal_config +#' @export +#' @importFrom rlang caller_env +as_gdal_config.gdal_config_file <- function(x, ..., call = rlang::caller_env()) { + new_gdal_config(x$configoptions, x$credentials) +} + +# format and print ------------------------------------------------------------------------------------------------ + +#' @export +#' @importFrom cli cli_text cli_alert_info +#' @importFrom purrr imap_chr +format.gdal_config_file <- function(x, ...) { + config_payload <- .gdal_opts_payload(x$configoptions) + + gpq_cli_fmt({ + cli::cli_text("{.cls gdal_config_file}") + if (is.null(x$path)) { + cli::cli_alert_info("No GDAL config file detected.") + } else { + cli::cli_alert_info("Path: {.file {x$path}}") + } + if (length(x$directives) > 0L) { + kv <- paste(names(x$directives), unname(x$directives), sep = "=", collapse = ", ") + cli::cli_alert_info("Directives: {.field {kv}}") + } + if (length(config_payload) > 0L) { + kv <- paste( + purrr::imap_chr(config_payload, function(value, key) paste0(key, "=", cli_redact(key, value))), + collapse = ", " + ) + cli::cli_alert_info("Configuration Options ({length(config_payload)}): {.field {kv}}") + } + if (length(x$credentials) > 0L) { + cli::cli_alert_info("Credentials ({length(x$credentials)} path{?s}):") + for (path in names(x$credentials)) { + payload <- .gdal_opts_payload(x$credentials[[path]]) + kv <- paste( + purrr::imap_chr(payload, function(value, key) paste0(key, "=", cli_redact(key, value))), + collapse = ", " + ) + cli::cli_text(" {.file {path}}: {.field {kv}}") + } + } + }) +} + +#' @export +print.gdal_config_file <- function(x, ...) { + cat(format(x, ...), sep = "\n") + invisible(x) +} + +# internal -------------------------------------------------------------------------------------------------------- + +# parse a gdalrc into a tidy entries tibble (section, subsection, key, value). the grammar: +# `[section]` headers, `[.subsection]` relative headers (inside [credentials]), `#` comments, and +# KEY=VALUE lines (values may contain further `=`). +#' @keywords internal +#' @noRd +#' @importFrom dplyr filter mutate if_else group_by ungroup select +#' @importFrom stringr str_trim str_starts str_detect str_remove_all fixed +#' @importFrom tibble tibble +#' @importFrom tidyr fill separate_wider_delim +.gdalrc_entries <- function(path) { + tibble::tibble(raw = stringr::str_trim(readLines(path, warn = FALSE))) |> + dplyr::filter(nzchar(.data$raw), !stringr::str_starts(.data$raw, stringr::fixed("#"))) |> + dplyr::mutate( + is_subsection = stringr::str_detect(.data$raw, "^\\[\\..+\\]$"), + is_section = !.data$is_subsection & stringr::str_detect(.data$raw, "^\\[.+\\]$"), + section = dplyr::if_else( + .data$is_section, + tolower(stringr::str_remove_all(.data$raw, "^\\[|\\]$")), + NA_character_ + ), + subsection = dplyr::if_else( + .data$is_subsection, + stringr::str_remove_all(.data$raw, "^\\[\\.|\\]$"), + NA_character_ + ) + ) |> + tidyr::fill("section") |> + dplyr::group_by(.data$section) |> + tidyr::fill("subsection") |> + dplyr::ungroup() |> + dplyr::filter(!.data$is_section, !.data$is_subsection, stringr::str_detect(.data$raw, stringr::fixed("="))) |> + tidyr::separate_wider_delim("raw", "=", names = c("key", "value"), too_many = "merge") |> + dplyr::mutate( + key = stringr::str_trim(.data$key), + value = stringr::str_trim(.data$value) + ) |> + dplyr::select("section", "subsection", "key", "value") +} + +# resolve the config file GDAL loaded at initialization: GDAL_CONFIG_FILE envvar first, then the +# user gdalrc. +#' @keywords internal +#' @noRd +.gdal_config_file_discover <- function() { + path <- Sys.getenv("GDAL_CONFIG_FILE") + if (!nzchar(path)) { + home <- if (is_windows()) Sys.getenv("USERPROFILE") else Sys.getenv("HOME") + candidate <- file.path(home, ".gdal", "gdalrc") + path <- if (nzchar(home) && file.exists(candidate)) candidate else "" + } + if (!nzchar(path) || !file.exists(path)) { + return(new_gdal_config_file()) + } + gdal_config_file_read(path) +} diff --git a/R/gdal_sitrep.R b/R/gdal_sitrep.R index 6e7df52..be060c0 100644 --- a/R/gdal_sitrep.R +++ b/R/gdal_sitrep.R @@ -6,68 +6,6 @@ # # ------------------------------------------------------------------------ -# relevant functions: -# ------------------------------------------------------------------------- -# gdalraster -------------------------------------------------------------- -# gdalraster::lib_versions() -# gdalraster::gdal_version() -# gdalraster::gdal_version_num() -# gdalraster::gdal_compute_version() -# gdalraster::proj_version() -# gdalraster::proj_networking() -# gdalraster::proj_search_paths() -# gdalraster::geos_version() -# gdalraster::has_geos() -# gdalraster::has_spatialite() -# gdalraster::http_enabled() -# gdalraster::gdal_global_reg_names() -# gdalraster::gdal_commands() -# gdalraster::gdal_usage() -# gdalraster::gdal_formats() -# gdalraster::identifyDriver() -# gdalraster::gdal_get_driver_md() -# gdalraster::inspectDataset() -# gdalraster::get_config_option() -# gdalraster::get_cache_max() -# gdalraster::get_cache_used() -# gdalraster::get_num_cpus() -# gdalraster::get_usable_physical_ram() -# gdalraster::getCreationOptions() -# gdalraster::push_error_handler() -# gdalraster::pop_error_handler() -# gdalraster::srs_info_from_db() -# gdalraster::vsi_clear_path_options() -# gdalraster::vsi_constants -# gdalraster::vsi_curl_clear_cache() -# gdalraster::vsi_get_fs_options() -# gdalraster::vsi_set_path_option() -# gdalraster::vsi_supports_rnd_write() -# gdalraster::vsi_supports_seq_write() -# sf --------------------------------------------------------------------- -# sf:::db_drivers -# sf:::extension_map -# sf:::prefix_map -# sf::sf_extSoftVersion() -# sf::gdal_compressors() -# sf::gdal_utils() -# sf::is_driver_available() -# sf::is_driver_can() -# sf::rawToHex -# sf::sf_proj_info() -# sf::st_drivers() -# sf::proj_tools() -# sf::s2() -# sf::sf_use_s2() -# sf::proj_info() -# sf::sf_proj_search_paths() -# terra ----------------------------------------------------------------- -# terra::libVersion() -# vapour ---------------------------------------------------------------- -# vapour::vapour_gdal_version() -# vapour::vapour_proj_version() -# geos ------------------------------------------------------------------ -# geos::geos_version() - # sitrep ---------------------------------------------------------------------------------------------------------- gdal_sitrep <- function() {} diff --git a/R/gdal_vsi.R b/R/gdal_vsi.R index 693894a..f37f340 100644 --- a/R/gdal_vsi.R +++ b/R/gdal_vsi.R @@ -170,6 +170,42 @@ vsi_list_options <- function(dsn) { xml_parse_gdal_options() } +# runtime-enumerated VSI/network/credential option metadata: vsi_get_fs_options() across every +# filesystem registered in the running GDAL build, parsed through the same xml parser as the +# driver metadata. computed lazily, cached in the package environment for the session. this is +# the runtime-backed portion of the known configuration-option universe (see gdal_config). +#' @keywords internal +#' @noRd +#' @importFrom dplyr bind_rows +#' @importFrom gdalraster vsi_get_fs_prefixes vsi_get_fs_options +#' @importFrom purrr map +#' @importFrom rlang is_string +.vsi_fs_options_tbl <- function() { + vstate <- .pkg_env$gdal$vsi + if (!is.null(vstate$known_tbl)) { + return(vstate$known_tbl) + } + prefixes <- tryCatch(gdalraster::vsi_get_fs_prefixes(), error = function(e) character()) + prefixes <- prefixes[endsWith(prefixes, "/")] + tbl <- prefixes |> + purrr::map(function(prefix) { + xml <- tryCatch( + gdalraster::vsi_get_fs_options(paste0(prefix, "placeholder"), as_list = FALSE), + error = function(e) NULL + ) + if (!rlang::is_string(xml) || !nzchar(xml)) { + return(NULL) + } + tryCatch(xml_parse_gdal_options(xml, scope = "all"), error = function(e) NULL) + }) |> + dplyr::bind_rows() + if (nrow(tbl) > 0L) { + tbl <- tbl[!duplicated(toupper(tbl$name)), , drop = FALSE] + } + vstate$known_tbl <- tbl + tbl +} + # handlers --------------------------------------------------------------------------------------------------------- #' List VSI Handlers Used by a Path diff --git a/R/gdalvector-conditions.R b/R/gdalvector-conditions.R index 1576b42..bbb810f 100644 --- a/R/gdalvector-conditions.R +++ b/R/gdalvector-conditions.R @@ -83,3 +83,15 @@ gdal_warn_opts <- function(msg, cls = NULL, ..., .envir = parent.frame()) { gdal_inform_opts <- function(msg, cls = NULL, ..., .envir = parent.frame()) { gdal_inform(msg = msg, cls = c(cls, "gdal_opts_message"), .envir = .envir, ...) } + +gdal_abort_config <- function(msg, cls = NULL, ..., call = rlang::caller_env(), .envir = parent.frame()) { + gdal_abort(msg = msg, cls = c(cls, "gdal_config_error"), call = call, .envir = .envir, ...) +} + +gdal_warn_config <- function(msg, cls = NULL, ..., .envir = parent.frame()) { + gdal_warn(msg = msg, cls = c(cls, "gdal_config_warning"), .envir = .envir, ...) +} + +gdal_inform_config <- function(msg, cls = NULL, ..., .envir = parent.frame()) { + gdal_inform(msg = msg, cls = c(cls, "gdal_config_message"), .envir = .envir, ...) +} diff --git a/R/utils_checks.R b/R/utils_checks.R index c4d2437..250bc73 100644 --- a/R/utils_checks.R +++ b/R/utils_checks.R @@ -42,7 +42,7 @@ check_abort <- gdal_abort_check # inherits ------------------------------------------------------------------------------------------------------ -#' Class Inheritence Checks +#' Class Inheritance Checks #' #' @description #' These functions perform checks that assert the underlying class of objects passed to them. diff --git a/R/utils_cli.R b/R/utils_cli.R index ce12e7a..c8e8d79 100644 --- a/R/utils_cli.R +++ b/R/utils_cli.R @@ -80,6 +80,19 @@ cli_kv <- function(x, title = NULL) { invisible(NULL) } +# redaction ------------------------------------------------------------------------------------------------------- + +# redact secret-bearing values by key name in printed output - the package-wide guard for +# anything that renders credentials (config sitreps, vsi path options, gdalrc contents). +#' @keywords internal +#' @noRd +cli_redact <- function(key, value) { + if (grepl("KEY|SECRET|TOKEN|PASSWORD|PWD|CREDENTIAL|CONNECTION_STRING", key, ignore.case = TRUE)) { + return("") + } + value +} + # json ------------------------------------------------------------------------------------------------------------ # emit an R object as pretty-printed JSON, the package-wide way to render embedded JSON metadata (e.g. PROJJSON diff --git a/R/utils_predicates.R b/R/utils_predicates.R index 29be66d..ab6d6a7 100644 --- a/R/utils_predicates.R +++ b/R/utils_predicates.R @@ -6,6 +6,29 @@ # # ------------------------------------------------------------------------ +# topic ----------------------------------------------------------------------------------------------------------- + +#' Predicates +#' +#' @name predicates +#' +#' @description +#' A collection of predicate functions for checking various conditions, types, and properties of objects. +#' These functions return logical values (TRUE or FALSE) based on the input provided. +#' +#' @section Functions: +#' +#' ### Types +#' +#' - `is_int64(x)`: Check if `x` is of type `integer64`. +#' +#' ### Virtual File System (VSI) +#' +#' - `is_vsi_path(x)`: Check if a path is a GDAL Virtual File System (VSI) path or URL. +#' +#' @keywords internal +NULL + # types ----------------------------------------------------------------------------------------------------------- #' @importFrom bit64 is.integer64 @@ -16,15 +39,17 @@ is_int64 <- function(x) { # vsi ------------------------------------------------------------------------------------------------------------- -#' Is VSI Path +#' Check if Virtual File System (VSI) #' #' @description -#' Check if a Path is a GDAL Virtual File System (VSI) Path or URL. +#' Check if a provided path or URL uses [GDAL's Virtual File System (VSI)](https://gdal.org/en/stable/user/virtual_file_systems.html). +#' +#' Virtual File Systems include prefixes such as `/vsistdin/`, `/vsistdout/`, `/vsimem/`, etc. #' -#' @param x Character string to check. +#' @param x Character string to check. Should represent a path or URL (or in rare cases, a connection string). #' #' @returns -#' Logical indicating if the path starts with a valid VSI prefix (i.e. `/vsicurl/` or `/vsizip/`). +#' Logical indicating if the path starts with a valid VSI prefix. #' #' @examples #' is_vsi_path("/vsizip/data.zip") # TRUE @@ -36,7 +61,11 @@ is_vsi_path <- function(x) { if (!is.character(x) || length(x) != 1L || !nzchar(x)) { return(FALSE) } - all(startsWith(x, "/vsi"), grepl("^/vsi[a-z0-9_]+/", x, ignore.case = TRUE)) + # pattern-based rather than a hardcoded prefix list: recognizes any /vsi/ the running + # GDAL build may register (streaming variants, /vsi7z/, /vsikerchunk_*, ...); see also + # GDAL_VSI_PREFIXES for the curated common set and gdalraster::vsi_get_fs_prefixes() for the + # runtime enumeration. + grepl("^/vsi[a-z0-9_]+/", x, ignore.case = TRUE) } # remote ---------------------------------------------------------------------------------------------------------- @@ -190,6 +219,18 @@ is_gdal_vsi_opts <- function(x) { inherits(x, "gdal_vsi_opts") } +is_gdal_config <- function(x) { + inherits(x, "gdal_config") +} + +is_gdal_config_sitrep <- function(x) { + inherits(x, "gdal_config_sitrep") +} + +is_gdal_config_file <- function(x) { + inherits(x, "gdal_config_file") +} + is_gdal_driver_name <- function(x) { toupper(x) %in% toupper(gdal_driver_names()) } diff --git a/R/zzz.R b/R/zzz.R index 8e6099f..3d3aec6 100644 --- a/R/zzz.R +++ b/R/zzz.R @@ -22,8 +22,12 @@ rlang::on_load({ pkg_env_init() gdal_config_init() gdal_drivers_init() + gdal_config_init_defaults() rlang::local_use_cli() }) +# ordering: gdal_config_init() needs .pkg_env (pkg_env_init); gdal_config_init_defaults() runs +# after gdal_drivers_init() so the at-load baseline sitrep and known-option checks can draw on +# the driver metadata table. # onLoad ---------------------------------------------------------------------------------------------------------- diff --git a/README.md b/README.md index 5a4cb66..98641e4 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ \__, /\__,_/\__,_/_/ |___/\___/\___/\__/\____/_/ /____/ -gdalvector v0.0.1 - Modern Package for GDAL Vector Data +gdalvector v0.0.4 - Modern Package for GDAL Vector Data ``` ## Overview diff --git a/inst/WORDLIST b/inst/WORDLIST index f0bafcf..542037b 100644 --- a/inst/WORDLIST +++ b/inst/WORDLIST @@ -1,2 +1,74 @@ +ABIs +API's +Algorithmix +ArrowArray +CLI +CMD +Changelog +Codec +DBF +DSN +FID +FeatureDataset +FileGDB +FlatGeobuf GDAL +GDAL's +GDALG +GENZ +GPKG +GeoPackage +GeoParquet +Geodatabase +MultiPatch +MultiPolygon +OGC +OGR +OID ORCID +OpenFileGDB +PROJ +PROJJSON +SHP +SMT +Shapelib +Sitrep +VSI +WAL +WKT +Xbase +api +autoindex +backtraces +cli +composable +configoptions +darwin +envvar +envvars +extensibility +filesystems +flatbuffer +gdalraster +gdalrc +gdb +geospatial +gpq +href +hrefs +kibibytes +libarrow +linux +md +nolock +pasteable +pkgdown +pre +roxygen +sha +sitrep +stateful +struct +tibble +tibbles +withr diff --git a/man/as_gdal_args.Rd b/man/as_gdal_args.Rd index 2677d82..2a42b94 100644 --- a/man/as_gdal_args.Rd +++ b/man/as_gdal_args.Rd @@ -1,13 +1,16 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/gdal_options.R -\name{as_gdal_args} +% Please edit documentation in R/gdal_config.R, R/gdal_options.R +\name{as_gdal_args.gdal_config} +\alias{as_gdal_args.gdal_config} \alias{as_gdal_args} \alias{as_gdal_args.gdal_opts} \alias{as_gdal_args.character} \alias{as_gdal_args.list} \alias{as_gdal_args.default} -\title{Convert GDAL Options to Algorithm Arguments} +\title{Convert GDAL Configuration to Algorithm Arguments} \usage{ +\method{as_gdal_args}{gdal_config}(x, ..., flatten_vsi = FALSE) + as_gdal_args(x, ...) \method{as_gdal_args}{gdal_opts}(x, cli = TRUE, long = FALSE, with_format = FALSE, ...) @@ -23,6 +26,9 @@ as_gdal_args(x, ...) \item{...}{Passed to methods.} +\item{flatten_vsi}{Logical; emit path-bound VSI options as global \code{--config} tokens. Defaults +to \code{FALSE}.} + \item{cli}{Logical; emit interleaved CLI tokens (\code{TRUE}, default) or a bare \code{KEY=VALUE} vector.} \item{long}{Logical; use long flag names (\code{--open-option}) rather than aliases (\code{--oo}).} @@ -34,6 +40,13 @@ known (open/creation only).} A character vector. } \description{ +Render the global channel of a \code{\link[=gdal_config]{gdal_config()}} as \verb{--config KEY=VALUE} CLI tokens. Path-bound +VSI options cannot be expressed on the CLI (GDAL only accepts them in-process or via the config +file \verb{[credentials]} section): by default they are omitted with a message; \code{flatten_vsi = TRUE} +emits them as global \code{--config} tokens instead - practically correct with a single credential +set, but the path scoping is lost (a warning is raised when paths carry conflicting values for +the same key). Use \code{\link[=gdal_config_file_write]{gdal_config_file_write()}} for a lossless serialization. + Render a \code{\link[=gdal_opts]{gdal_opts()}} object to the form consumed by the GDAL algorithm API (\code{\link[gdalraster:gdal_alg]{gdalraster::gdal_alg()}} / \code{\link[gdalraster:gdal_run]{gdalraster::gdal_run()}}). } diff --git a/man/as_gdal_config_opts.Rd b/man/as_gdal_config_opts.Rd index d00b651..f6fdae7 100644 --- a/man/as_gdal_config_opts.Rd +++ b/man/as_gdal_config_opts.Rd @@ -1,14 +1,26 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/gdal_options.R -\name{as_gdal_config_opts} +% Please edit documentation in R/gdal_config.R, R/gdal_options.R +\name{as_gdal_config_opts.gdal_config} +\alias{as_gdal_config_opts.gdal_config} +\alias{as_gdal_config_opts.gdal_config_sitrep} \alias{as_gdal_config_opts} \alias{as_gdal_config_opts.default} \alias{as_gdal_config_opts.gdal_config_opts} \alias{as_gdal_config_opts.list} \alias{as_gdal_config_opts.character} \alias{as_gdal_config_opts.tbl_df} -\title{Coerce to GDAL Configuration Options} +\title{Coerce a Configuration Sitrep to Configuration Options} \usage{ +\method{as_gdal_config_opts}{gdal_config}(x, ..., driver = NULL, call = rlang::caller_env()) + +\method{as_gdal_config_opts}{gdal_config_sitrep}( + x, + ..., + scope = c("session", "effective"), + driver = NULL, + call = rlang::caller_env() +) + as_gdal_config_opts(x, ..., driver = NULL, call = rlang::caller_env()) \method{as_gdal_config_opts}{default}(x, ..., driver = NULL, call = rlang::caller_env()) @@ -32,11 +44,20 @@ as_gdal_config_opts(x, ..., driver = NULL, call = rlang::caller_env()) running function, e.g. \code{caller_env()}. The function will be mentioned in error messages as the source of the error. See the \code{call} argument of \code{\link[rlang:abort]{abort()}} for more information.} + +\item{scope}{Which options to extract: \code{"session"} (default) takes only the options this +package pinned via \code{\link[=gdal_config_set]{gdal_config_set()}}; \code{"effective"} takes every option with a non-empty +effective value regardless of origin (envvars, config file, other packages) - useful for +reproducing the session's configuration in a child process or CLI invocation.} } \value{ A \code{\link[=gdal_config_opts]{gdal_config_opts()}} object. } \description{ +Bridge from the live view back to the value classes: extract a \code{\link[=gdal_config_opts]{gdal_config_opts()}} object +from a \code{\link[=gdal_config_sitrep]{gdal_config_sitrep()}}. From there the full render surface applies - \code{\link[=as_gdal_args]{as_gdal_args()}} +(\code{--config} CLI tokens), \code{\link[=gdal_render]{gdal_render()}}, \code{\link[=as_config_option]{as_config_option()}}. + Coerce a named list, a \code{KEY=VALUE} character vector, a driver-metadata tibble, or an existing \code{gdal_config_opts} to the \code{\link[=gdal_config_opts]{gdal_config_opts()}} class. } diff --git a/man/check_inherits.Rd b/man/check_inherits.Rd index 86434a7..3ac7f8a 100644 --- a/man/check_inherits.Rd +++ b/man/check_inherits.Rd @@ -5,7 +5,7 @@ \alias{check_inherits2} \alias{check_inherits_any} \alias{check_inherits_all} -\title{Class Inheritence Checks} +\title{Class Inheritance Checks} \usage{ check_inherits( x, diff --git a/man/gdal_config.Rd b/man/gdal_config.Rd index 07af31b..ca94eda 100644 --- a/man/gdal_config.Rd +++ b/man/gdal_config.Rd @@ -1,17 +1,94 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/gdal_config.R +% Please edit documentation in R/gdal_config.R, R/gdal_config_file.R \name{gdal_config} \alias{gdal_config} +\alias{as_gdal_config} +\alias{as_gdal_config.default} +\alias{as_gdal_config.gdal_config} +\alias{as_gdal_config.gdal_opts} +\alias{as_gdal_config.list} +\alias{as_gdal_config.character} +\alias{as_gdal_config.gdal_config_file} \title{GDAL Configuration} \usage{ gdal_config(...) + +as_gdal_config(x, ..., call = rlang::caller_env()) + +\method{as_gdal_config}{default}(x, ..., call = rlang::caller_env()) + +\method{as_gdal_config}{gdal_config}(x, ..., call = rlang::caller_env()) + +\method{as_gdal_config}{gdal_opts}(x, ..., call = rlang::caller_env()) + +\method{as_gdal_config}{list}(x, ..., call = rlang::caller_env()) + +\method{as_gdal_config}{character}(x, ..., call = rlang::caller_env()) + +\method{as_gdal_config}{gdal_config_file}(x, ..., call = rlang::caller_env()) } \arguments{ -\item{...}{Configuration options as named \code{KEY = value} pairs.} +\item{...}{Configuration options: \code{KEY = value} pairs, \code{\link[=gdal_config_opts]{gdal_config_opts()}} / \code{\link[=gdal_vsi_opts]{gdal_vsi_opts()}} +objects, named lists, \code{"KEY=VALUE"} character vectors, or \code{gdal_config} values. Open/creation +options are rejected - they are per-call algorithm arguments (see \code{\link[=as_gdal_args]{as_gdal_args()}}), never +configuration.} + +\item{x}{Object to coerce.} + +\item{call}{The execution environment of a currently +running function, e.g. \code{caller_env()}. The function will be +mentioned in error messages as the source of the error. See the +\code{call} argument of \code{\link[rlang:abort]{abort()}} for more information.} } \value{ -gdal_config +A \code{gdal_config} object: a list with elements \code{opts} (a \code{\link[=gdal_config_opts]{gdal_config_opts()}}) and \code{vsi} +(a named list of path-bound \code{\link[=gdal_vsi_opts]{gdal_vsi_opts()}}, keyed by path prefix). } \description{ -Creates a session-wide, global GDAL Configuration object of class \code{"gdal_config"}. +A \code{gdal_config} is an inert, composable \emph{value} describing a GDAL configuration: one +\code{\link[=gdal_config_opts]{gdal_config_opts()}} payload (options bound globally) plus zero or more path-bound +\code{\link[=gdal_vsi_opts]{gdal_vsi_opts()}} payloads (options bound to a VSI path prefix, e.g. credentials for a specific +bucket). It is the unit the stateful verbs apply (\code{\link[=gdal_config_set]{gdal_config_set()}}), the scoped helpers pin +(\code{\link[=with_gdal_config]{with_gdal_config()}}), the session tracks (\code{\link[=gdal_config_active]{gdal_config_active()}}), and the GDAL configuration +file serializes (\code{\link[=gdal_config_file_write]{gdal_config_file_write()}}). +\itemize{ +\item \code{gdal_config(...)}: construct from \code{KEY = value} pairs, \code{\link[=gdal_config_opts]{gdal_config_opts()}} / +\code{\link[=gdal_vsi_opts]{gdal_vsi_opts()}} objects, named lists, \code{"KEY=VALUE"} strings, or other \code{gdal_config} values +(later inputs win). Empty call gives an empty configuration. +\item \code{as_gdal_config()}: coerce from the same inputs. +\item \code{c()}: merge configurations (later values win, per key and per path). +} +} +\section{One key namespace, two binding tiers}{ + +There is no separate "VSI option" key type - \code{AWS_ACCESS_KEY_ID} and \code{GDAL_HTTP_TIMEOUT} live in +the same namespace. What differs is the tier a \emph{value} is bound at. GDAL resolves an option for +a path by checking the path-specific store first (\code{VSISetPathSpecificOption()}; longest-matching +prefix wins), then falling through the ordinary chain: thread-local, global in-memory +(\code{CPLSetConfigOption()}, which also holds config-file values loaded at GDAL initialization), +environment variable (live \code{getenv()}), built-in default. A \code{\link[=gdal_vsi_opts]{gdal_vsi_opts()}} \emph{without} a +\code{vsi_path} is therefore just config opts and merges into the global channel; \emph{with} a +\code{vsi_path} it is kept path-bound in the \code{vsi} channel. +} + +\section{Rendering}{ + +\code{as_gdal_args()} renders the global channel as \verb{--config KEY=VALUE} CLI tokens. Path-bound VSI +options \strong{cannot} be expressed on the CLI at all - GDAL only accepts them in-process +(\code{\link[gdalraster:vsi_set_path_option]{gdalraster::vsi_set_path_option()}}) or via the config file \verb{[credentials]} section - so they +are omitted with a message by default; \code{flatten_vsi = TRUE} emits them as global \code{--config} +tokens instead (loses the path scoping; warns when paths carry conflicting values for the same +key). \code{\link[=gdal_config_file_write]{gdal_config_file_write()}} is the only lossless serialization of a full \code{gdal_config}. +} + +\examples{ +gdal_config( + GDAL_NUM_THREADS = "ALL_CPUS", + gpkg_config_opts(sqlite_synchronous = "OFF"), + gdal_vsi_opts(AWS_S3_ENDPOINT = "t3.storage.dev", vsi_path = "/vsis3/my-bucket/") +) +} +\seealso{ +\code{\link[=gdal_config_set]{gdal_config_set()}}, \code{\link[=gdal_config_active]{gdal_config_active()}}, \code{\link[=gdal_config_sitrep]{gdal_config_sitrep()}}, +\code{\link[=gdal_config_file_read]{gdal_config_file_read()}}, \code{\link[=with_gdal_config]{with_gdal_config()}} } diff --git a/man/gdal_config_active.Rd b/man/gdal_config_active.Rd new file mode 100644 index 0000000..3477024 --- /dev/null +++ b/man/gdal_config_active.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/gdal_config.R +\name{gdal_config_active} +\alias{gdal_config_active} +\title{Active GDAL Configuration} +\usage{ +gdal_config_active() +} +\value{ +A \code{\link[=gdal_config]{gdal_config()}} value (empty when nothing has been applied). +} +\description{ +The \code{\link[=gdal_config]{gdal_config()}} value this session has applied through \code{\link[=gdal_config_set]{gdal_config_set()}} (including any +load-time package defaults). Retrieve it to reuse the session's configuration elsewhere - e.g. +render it for a CLI invocation (\code{as_gdal_args(gdal_config_active())}) or serialize it +(\code{\link[=gdal_config_file_write]{gdal_config_file_write()}}). +} +\examples{ +gdal_config_active() +} +\seealso{ +\code{\link[=gdal_config_set]{gdal_config_set()}}, \code{\link[=gdal_config_sitrep]{gdal_config_sitrep()}} +} diff --git a/man/gdal_config_file.Rd b/man/gdal_config_file.Rd new file mode 100644 index 0000000..f1c56a9 --- /dev/null +++ b/man/gdal_config_file.Rd @@ -0,0 +1,76 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/gdal_config_file.R +\name{gdal_config_file} +\alias{gdal_config_file} +\alias{gdal_config_file_read} +\alias{gdal_config_file_write} +\title{GDAL Configuration File} +\usage{ +gdal_config_file_read(path) + +gdal_config_file_write(x, path) + +gdal_config_file(refresh = FALSE) +} +\arguments{ +\item{path}{Path to a GDAL configuration file. For \code{gdal_config_file_write()}, the destination +(written with secrets as-is - it is a credentials file; printing remains redacted).} + +\item{x}{A \code{\link[=gdal_config]{gdal_config()}} or \code{gdal_config_file} object to serialize.} + +\item{refresh}{Logical; re-discover and re-parse instead of using the value cached at package +load. Defaults to \code{FALSE}.} +} +\value{ +\itemize{ +\item \code{gdal_config_file_read()} / \code{gdal_config_file()}: a \code{gdal_config_file} object (\code{path}, +\code{configoptions}, \code{directives}, \code{credentials}). For \code{gdal_config_file()}, \code{path} is \code{NULL} +when no file was discovered. +\item \code{gdal_config_file_write()}: invisibly, the written path. +} +} +\description{ +Read, write, and discover \href{https://gdal.org/en/stable/user/configoptions.html#gdal-configuration-file}{GDAL configuration files} +(gdalrc). A parsed file is a classed \code{gdal_config_file} whose components are the package's +option value classes, so a file round-trips directly into the configuration system: +\itemize{ +\item \code{configoptions}: the \verb{[configoptions]} section as a \code{\link[=gdal_config_opts]{gdal_config_opts()}} (GDAL >= 3.3). +\item \code{directives}: the \verb{[directives]} section as a named character vector (GDAL >= 3.6; +e.g. \code{ignore-env-vars = "yes"}). +\item \code{credentials}: the \verb{[credentials]} section (GDAL >= 3.5) as a named list of +\code{\link[=gdal_vsi_opts]{gdal_vsi_opts()}} - each \verb{[.subsection]}'s \code{path} key becomes the \code{vsi_path} binding, so +path-bound credentials are first-class values. +} + +Functions: +\itemize{ +\item \code{gdal_config_file_read(path)}: parse a gdalrc. +\item \code{gdal_config_file_write(x, path)}: serialize a \code{\link[=gdal_config]{gdal_config()}} or \code{gdal_config_file} to a +gdalrc. This is the \strong{only lossless serialization} of a full configuration - path-bound VSI +options cannot be expressed as CLI \code{--config} flags (see \code{\link[=as_gdal_args]{as_gdal_args()}}). +\item \code{gdal_config_file(refresh)}: the file GDAL actually loaded at initialization, discovered via +the \code{GDAL_CONFIG_FILE} environment variable then the user gdalrc +(\verb{\%USERPROFILE\%/.gdal/gdalrc} on Windows, \verb{~/.gdal/gdalrc} otherwise), cached at package load. +\item \code{\link[=as_gdal_config]{as_gdal_config()}}: convert a \code{gdal_config_file} to a \code{\link[=gdal_config]{gdal_config()}} value +(configoptions + credentials), e.g. to apply it in-process via \code{\link[=gdal_config_set]{gdal_config_set()}}. +} + +GDAL reads this file \strong{once}, at driver registration (i.e. before this package loads), and +loads its \verb{[configoptions]} into the in-memory configuration store (environment variables take +precedence over the file unless \code{ignore-env-vars=yes}). In-process it is therefore a +\emph{discovery/provenance} input - setting \code{GDAL_CONFIG_FILE} after load has no effect on the +running process (and \code{\link[=gdal_config_set]{gdal_config_set()}} warns if you try). Written files target \emph{future} +processes. +} +\examples{ +\dontrun{ +# the file GDAL loaded at startup, if any +gdal_config_file() + +# serialize the session's configuration for reuse by CLI tools / other processes +gdal_config_file_write(gdal_config_active(), "gdalrc") +} +} +\seealso{ +\code{\link[=gdal_config]{gdal_config()}}, \code{\link[=gdal_config_set]{gdal_config_set()}}, \code{\link[=gdal_config_sitrep]{gdal_config_sitrep()}} +} diff --git a/man/gdal_config_set.Rd b/man/gdal_config_set.Rd new file mode 100644 index 0000000..e539553 --- /dev/null +++ b/man/gdal_config_set.Rd @@ -0,0 +1,110 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/gdal_config.R +\name{gdal_config_set} +\alias{gdal_config_set} +\alias{gdal_config_get} +\alias{gdal_config_unset} +\alias{gdal_config_reset} +\title{Get, Set, Unset, and Reset GDAL Configuration} +\usage{ +gdal_config_set(...) + +gdal_config_get(keys = NULL) + +gdal_config_unset(keys, mode = c("reveal", "mask", "scrub")) + +gdal_config_reset(keys = NULL) +} +\arguments{ +\item{...}{Configuration options: \code{KEY = value} pairs, \code{\link[=gdal_config_opts]{gdal_config_opts()}} / \code{\link[=gdal_vsi_opts]{gdal_vsi_opts()}} +objects, named lists, \code{"KEY=VALUE"} character vectors, or \code{gdal_config} values. Open/creation +options are rejected - they are per-call algorithm arguments (see \code{\link[=as_gdal_args]{as_gdal_args()}}), never +configuration.} + +\item{keys}{Character vector of configuration option names. For \code{gdal_config_get()} and +\code{gdal_config_reset()}, \code{NULL} (default) means all keys the package has touched.} + +\item{mode}{How \code{gdal_config_unset()} should treat the environment-variable fallback: +\itemize{ +\item \code{"reveal"} (default): clear the in-memory option; if an envvar of the same name exists its +value re-surfaces (a classed warning says so). +\item \code{"mask"}: set the option explicitly to its documented default (from the driver metadata or +the runtime VSI metadata; errors when no documented default is known) - "GDAL default +behavior" despite an envvar. +\item \code{"scrub"}: also remove the environment variable (\code{Sys.unsetenv()}); the removed value is +recorded so \code{gdal_config_reset()} can restore it. A process-wide side effect - explicit +opt-in only. +}} +} +\value{ +\itemize{ +\item \code{gdal_config_get()}: a named character vector of effective values (\code{""} = not set). +\item \code{gdal_config_set()} / \code{gdal_config_unset()}: invisibly, a named list of the prior effective +values of the keys touched. +\item \code{gdal_config_reset()}: invisibly, the character vector of keys restored. +} +} +\description{ +The stateful verbs of the configuration system: they apply \code{\link[=gdal_config]{gdal_config()}} values to the +running GDAL process (via \code{\link[gdalraster:set_config_option]{gdalraster::set_config_option()}} / +\code{\link[gdalraster:vsi_set_path_option]{gdalraster::vsi_set_path_option()}}), track what was applied in the session's active +configuration (\code{\link[=gdal_config_active]{gdal_config_active()}}), and record what was in effect beforehand so state can +always be restored. +\itemize{ +\item \code{gdal_config_set(...)}: apply options (same inputs as \code{\link[=gdal_config]{gdal_config()}}). +\item \code{gdal_config_get()}: effective value(s), read live from GDAL (never from package state). +\item \code{gdal_config_unset()}: remove options, with explicit semantics for the environment-variable +fallback (\code{mode}). +\item \code{gdal_config_reset()}: restore touched options to their pre-package state. +} + +For scoped (temporary) application, the recommended pattern around pipeline executions, see +\code{\link[=local_gdal_config]{local_gdal_config()}} / \code{\link[=with_gdal_config]{with_gdal_config()}}. +} +\section{How GDAL resolves a configuration option}{ + +For a given key, GDAL checks, in order: a thread-local option, the process-global in-memory +option (set via \code{CPLSetConfigOption()}; this also holds values loaded from the +\link[=gdal_config_file]{GDAL config file} at initialization), an environment variable of the same +name (read live via \code{getenv()}), and finally the built-in default. Two consequences drive this +API's design: +\itemize{ +\item \strong{Set is authoritative}: a value applied with \code{gdal_config_set()} wins over an environment +variable for as long as it is set. +\item \strong{Unset is not}: clearing the in-memory option (what \code{set_config_option(key, "")} does) +merely \emph{reveals} the environment variable underneath again. You cannot force GDAL's built-in +default while an envvar exists - hence the explicit \code{mode} argument of \code{gdal_config_unset()} +(\code{"reveal"}, \code{"mask"}, or \code{"scrub"}). +} + +Because environment variables, other packages, and the config file all write to the same GDAL +state, the package never mirrors values: reads always go to GDAL, and the package only records +the \emph{deltas} it applied (plus what was effective beforehand, for restore). +} + +\section{Known options}{ + +Advisory typo checking (classed warning, never blocking - custom application-defined options +are legal) draws on, in decreasing authority: the VSI/network/credential options enumerated +from the running GDAL build (\code{\link[gdalraster:vsi_get_fs_options]{gdalraster::vsi_get_fs_options()}} across all registered +filesystems), the curated per-driver configuration options (driver metadata table), a small +curated list of core CPL/GDAL/OGR names (\code{GDAL_CORE_CONFIG_OPTS}; GDAL does not expose these at +runtime), and GDAL-relevant environment variables present at load. Keys GDAL already resolves +to a value are never flagged. +} + +\examples{ +\dontrun{ +gdal_config_set(GDAL_NUM_THREADS = "ALL_CPUS", CPL_TMPDIR = tempdir()) +gdal_config_get("GDAL_NUM_THREADS") +gdal_config_active() +gdal_config_reset() + +# driver-typed config opts flow straight in: +gdal_config_set(gpkg_config_opts(sqlite_synchronous = "OFF")) +gdal_config_reset() +} +} +\seealso{ +\code{\link[=gdal_config]{gdal_config()}}, \code{\link[=gdal_config_active]{gdal_config_active()}}, \code{\link[=gdal_config_sitrep]{gdal_config_sitrep()}}, \code{\link[=with_gdal_config]{with_gdal_config()}} +} diff --git a/man/gdal_config_sitrep.Rd b/man/gdal_config_sitrep.Rd new file mode 100644 index 0000000..a8f8ca1 --- /dev/null +++ b/man/gdal_config_sitrep.Rd @@ -0,0 +1,43 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/gdal_config.R +\name{gdal_config_sitrep} +\alias{gdal_config_sitrep} +\title{GDAL Configuration Situational Report} +\usage{ +gdal_config_sitrep() +} +\value{ +A \code{gdal_config_sitrep} object: \code{options} (tibble: \code{key}, \code{effective}, \code{envvar}, \code{set}, +\code{source}), \code{set} (the active session payload), \code{vsi} (path-bound options per prefix), \code{file} +(the discovered config file), and \code{time}. Has cli \code{format()}/\code{print()} and +\code{\link[tibble:as_tibble]{tibble::as_tibble()}} methods. +} +\description{ +A live, provenance-attributed report of the effective GDAL configuration landscape: for each +key with a visible signal, the effective value (read live from GDAL - the single source of +truth), the environment-variable value, what this package pinned, and a best-effort \code{source} +attribution: +\itemize{ +\item \code{gdalvector}: currently pinned by this package (\code{\link[=gdal_config_set]{gdal_config_set()}}). +\item \code{envvar}: resolved from an environment variable. +\item \code{config_file}: matches a value declared in the discovered \link[=gdal_config_file]{GDAL config file}. +\item \code{external}: set in GDAL's in-memory store by something else (another package, a direct +\code{\link[gdalraster:set_config_option]{gdalraster::set_config_option()}} call, ...). +\item \code{unset}: no value in effect. +} + +Coverage spans keys pinned by the package, GDAL-relevant environment variables, config-file +entries, and a probe of the full known-option universe (runtime VSI metadata, driver metadata, +and the curated core names), so externally set in-memory values for any documented key are +discovered too. Secret-bearing values are redacted in printed output. A baseline sitrep taken +at package load is stashed internally ("the world as gdalvector found it"). +} +\examples{ +\dontrun{ +gdal_config_sitrep() +tibble::as_tibble(gdal_config_sitrep()) +} +} +\seealso{ +\code{\link[=gdal_config_active]{gdal_config_active()}}, \code{\link[=gdal_config_file]{gdal_config_file()}}, \code{\link[=gdal_config_set]{gdal_config_set()}} +} diff --git a/man/is_vsi_path.Rd b/man/is_vsi_path.Rd index 12d200c..a8ef492 100644 --- a/man/is_vsi_path.Rd +++ b/man/is_vsi_path.Rd @@ -2,18 +2,20 @@ % Please edit documentation in R/utils_predicates.R \name{is_vsi_path} \alias{is_vsi_path} -\title{Is VSI Path} +\title{Check if Virtual File System (VSI)} \usage{ is_vsi_path(x) } \arguments{ -\item{x}{Character string to check.} +\item{x}{Character string to check. Should represent a path or URL (or in rare cases, a connection string).} } \value{ -Logical indicating if the path starts with a valid VSI prefix (i.e. \verb{/vsicurl/} or \verb{/vsizip/}). +Logical indicating if the path starts with a valid VSI prefix. } \description{ -Check if a Path is a GDAL Virtual File System (VSI) Path or URL. +Check if a provided path or URL uses \href{https://gdal.org/en/stable/user/virtual_file_systems.html}{GDAL's Virtual File System (VSI)}. + +Virtual File Systems include prefixes such as \verb{/vsistdin/}, \verb{/vsistdout/}, \verb{/vsimem/}, etc. } \examples{ is_vsi_path("/vsizip/data.zip") # TRUE diff --git a/man/predicates.Rd b/man/predicates.Rd new file mode 100644 index 0000000..b297591 --- /dev/null +++ b/man/predicates.Rd @@ -0,0 +1,25 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils_predicates.R +\name{predicates} +\alias{predicates} +\title{Predicates} +\description{ +A collection of predicate functions for checking various conditions, types, and properties of objects. +These functions return logical values (TRUE or FALSE) based on the input provided. +} +\section{Functions}{ + +\subsection{Types}{ +\itemize{ +\item \code{is_int64(x)}: Check if \code{x} is of type \code{integer64}. +} +} + +\subsection{Virtual File System (VSI)}{ +\itemize{ +\item \code{is_vsi_path(x)}: Check if a path is a GDAL Virtual File System (VSI) path or URL. +} +} +} + +\keyword{internal} diff --git a/man/with_gdal_config.Rd b/man/with_gdal_config.Rd new file mode 100644 index 0000000..cf0505e --- /dev/null +++ b/man/with_gdal_config.Rd @@ -0,0 +1,61 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/gdal_config.R +\name{with_gdal_config} +\alias{with_gdal_config} +\alias{local_gdal_config} +\title{Scoped GDAL Configuration} +\usage{ +local_gdal_config(..., .local_envir = parent.frame()) + +with_gdal_config(new, code) +} +\arguments{ +\item{...}{Configuration options: \code{KEY = value} pairs, \code{\link[=gdal_config_opts]{gdal_config_opts()}} / \code{\link[=gdal_vsi_opts]{gdal_vsi_opts()}} +objects, named lists, \code{"KEY=VALUE"} character vectors, or \code{gdal_config} values. Open/creation +options are rejected - they are per-call algorithm arguments (see \code{\link[=as_gdal_args]{as_gdal_args()}}), never +configuration.} + +\item{.local_envir}{Environment whose exit triggers restoration. Defaults to the calling frame.} + +\item{new}{Configuration options: a \code{\link[=gdal_config]{gdal_config()}} value or anything \code{\link[=gdal_config]{gdal_config()}} accepts.} + +\item{code}{Code to execute with the configuration in place.} +} +\value{ +\itemize{ +\item \code{local_gdal_config()}: invisibly, a named list of the prior effective values. +\item \code{with_gdal_config()}: the value of \code{code}. +} +} +\description{ +Apply GDAL configuration options for a limited scope and guarantee restoration afterwards - +even on error. This is the recommended pattern for workloads: the configuration channel is the +\emph{execution environment} of a pipeline run (it cannot ride in algorithm arguments or a serialized +GDALG), so it should be pinned in place around the execution and cleaned up after. +\itemize{ +\item \code{local_gdal_config()}: applies the options now and restores them when the calling frame (or +\code{.local_envir}) exits, in the style of the withr \verb{local_*()} functions. +\item \code{with_gdal_config()}: applies the options, evaluates \code{code}, restores, and returns the value +of \code{code}. +} + +Restoration is exact: the effective GDAL values \emph{and} the package state are returned to their +condition at entry, so scoped usage never disturbs global configuration set via +\code{\link[=gdal_config_set]{gdal_config_set()}}. For VSI path-scoped options the prior value cannot be read back from GDAL, +so keys not previously set by the package are cleared on exit. +} +\examples{ +\dontrun{ +# pin the execution environment around a pipeline run; restored even on error +with_gdal_config( + gdal_config(GDAL_NUM_THREADS = "ALL_CPUS", OGR_SQLITE_SYNCHRONOUS = "OFF"), + gdalraster::gdal_run("vector convert", c("--input", "in.gpkg", "--output", "out.parquet")) +) + +# the CPL_DEBUG/GeoParquet guard (see the config docs) is just: +with_gdal_config(gdal_config(CPL_DEBUG = "OFF"), gdal_vector_info("data.parquet")) +} +} +\seealso{ +\code{\link[=gdal_config]{gdal_config()}}, \code{\link[=gdal_config_set]{gdal_config_set()}} +} diff --git a/tests/testthat/test-gdal_config.R b/tests/testthat/test-gdal_config.R new file mode 100644 index 0000000..9334c02 --- /dev/null +++ b/tests/testthat/test-gdal_config.R @@ -0,0 +1,471 @@ +# ------------------------------------------------------------------------ +# +# Title : GDAL Configuration System +# By : Jimmy Briggs +# Date : 2026-07-02 +# +# ------------------------------------------------------------------------ + +# value class ----------------------------------------------------------------------------------------------------- + +test_that("gdal_config() constructs a value from heterogeneous inputs (later wins)", { + cfg <- gdal_config( + gdal_config_opts(GDAL_NUM_THREADS = "2"), + list(CPL_TMPDIR = "a"), + "CPL_TMPDIR=b", + GDAL_NUM_THREADS = "4" + ) + expect_true(is_gdal_config(cfg)) + expect_true(is_gdal_config_opts(cfg$opts)) + expect_identical(as.list(cfg$opts), list(GDAL_NUM_THREADS = "4", CPL_TMPDIR = "b")) + expect_length(cfg$vsi, 0L) +}) + +test_that("gdal_config() keeps path-bound vsi opts in the vsi channel, unbound in the global", { + cfg <- gdal_config( + gdal_vsi_opts(AWS_REGION = "us-west-2"), + gdal_vsi_opts(AWS_S3_ENDPOINT = "t3.storage.dev", vsi_path = "/vsis3/bucket/") + ) + expect_identical(as.list(cfg$opts), list(AWS_REGION = "us-west-2")) + expect_named(cfg$vsi, "/vsis3/bucket/") + expect_identical(as.list(cfg$vsi[["/vsis3/bucket/"]]), list(AWS_S3_ENDPOINT = "t3.storage.dev")) +}) + +test_that("empty construction and c() merge behave", { + empty <- gdal_config() + expect_true(is_gdal_config(empty)) + expect_length(as.list(empty$opts), 0L) + + a <- gdal_config(GDAL_NUM_THREADS = "2", gdal_vsi_opts(AWS_REGION = "a", vsi_path = "/vsis3/x/")) + b <- gdal_config(GDAL_NUM_THREADS = "8", gdal_vsi_opts(AWS_REGION = "b", vsi_path = "/vsis3/x/")) + merged <- c(a, b) + expect_identical(as.list(merged$opts), list(GDAL_NUM_THREADS = "8")) + expect_identical(as.list(merged$vsi[["/vsis3/x/"]]), list(AWS_REGION = "b")) +}) + +test_that("as_gdal_config() coerces opts objects, lists, characters, and self", { + expect_true(is_gdal_config(as_gdal_config(gdal_config_opts(CPL_DEBUG = "ON")))) + expect_true(is_gdal_config(as_gdal_config(list(CPL_DEBUG = "ON")))) + expect_true(is_gdal_config(as_gdal_config("CPL_DEBUG=ON"))) + cfg <- gdal_config(CPL_DEBUG = "ON") + expect_identical(as_gdal_config(cfg), cfg) + expect_error(as_gdal_config(42), class = "gdal_config_coerce_error") +}) + +test_that("gdal_config() rejects open/creation opts with a classed error", { + expect_error(gdal_config(gdal_open_opts(LIST_ALL_TABLES = "NO")), class = "gdal_config_channel_error") + expect_error(gdal_config(gdal_creation_opts(SPATIAL_INDEX = "YES")), class = "gdal_config_channel_error") + expect_error(gdal_config(42), class = "gdal_config_channel_error") +}) + +test_that("format()/print() render the value with secrets redacted", { + withr::local_options(cli.num_colors = 1, cli.width = 200) + cfg <- gdal_config( + GDAL_NUM_THREADS = "4", + gdal_vsi_opts(AWS_SECRET_ACCESS_KEY = "supersecretvalue", vsi_path = "/vsis3/bucket/") + ) + out <- paste(format(cfg), collapse = "\n") + expect_match(out, "GDAL_NUM_THREADS=4", fixed = TRUE) + expect_false(grepl("supersecretvalue", out, fixed = TRUE)) + expect_match(out, "", fixed = TRUE) +}) + +# rendering ------------------------------------------------------------------------------------------------------- + +test_that("as_gdal_args() renders the global channel and omits vsi with a message", { + cfg <- gdal_config( + GDAL_NUM_THREADS = "4", + gdal_vsi_opts(AWS_REGION = "us-east-1", vsi_path = "/vsis3/bucket/") + ) + expect_message(args <- as_gdal_args(cfg), class = "gdal_config_vsi_message") + expect_identical(args, c("--config", "GDAL_NUM_THREADS=4")) +}) + +test_that("as_gdal_args(flatten_vsi = TRUE) emits vsi options as global --config tokens", { + cfg <- gdal_config(gdal_vsi_opts(AWS_REGION = "us-east-1", vsi_path = "/vsis3/bucket/")) + args <- as_gdal_args(cfg, flatten_vsi = TRUE) + expect_identical(args, c("--config", "AWS_REGION=us-east-1")) +}) + +test_that("flattening warns when paths carry conflicting values for the same key", { + cfg <- gdal_config( + gdal_vsi_opts(AWS_ACCESS_KEY_ID = "key-a", vsi_path = "/vsis3/a/"), + gdal_vsi_opts(AWS_ACCESS_KEY_ID = "key-b", vsi_path = "/vsis3/b/") + ) + expect_warning(as_gdal_args(cfg, flatten_vsi = TRUE), class = "gdal_config_flatten_warning") +}) + +test_that("as_gdal_config_opts() extracts the global channel from a value", { + cfg <- gdal_config(GDAL_NUM_THREADS = "4") + opts <- as_gdal_config_opts(cfg) + expect_true(is_gdal_config_opts(opts)) + expect_identical(as.list(opts), list(GDAL_NUM_THREADS = "4")) +}) + +# set / get / reset ----------------------------------------------------------------------------------------------- + +test_that("gdal_config_set applies to GDAL, tracks in the active config, and reset restores", { + skip_if_not_installed("gdalraster") + withr::defer(gdal_config_reset()) + + before <- gdalraster::get_config_option("CPL_TMPDIR") + prior <- gdal_config_set(CPL_TMPDIR = "gdalvector-test-tmp") + expect_identical(prior[["CPL_TMPDIR"]], before) + expect_identical(unname(gdal_config_get("CPL_TMPDIR")), "gdalvector-test-tmp") + + active <- gdal_config_active() + expect_true(is_gdal_config(active)) + expect_identical(as.list(active$opts)[["CPL_TMPDIR"]], "gdalvector-test-tmp") + + restored <- gdal_config_reset("CPL_TMPDIR") + expect_identical(restored, "CPL_TMPDIR") + expect_identical(gdalraster::get_config_option("CPL_TMPDIR"), before) + expect_false("CPL_TMPDIR" %in% names(as.list(gdal_config_active()$opts))) +}) + +test_that("gdal_config_get with no touched keys returns an empty named character vector", { + skip_if_not_installed("gdalraster") + withr::defer(gdal_config_reset()) + gdal_config_reset() + out <- gdal_config_get() + expect_length(out, 0L) + expect_type(out, "character") +}) + +test_that("set accepts gdal_config values and driver-typed opts", { + skip_if_not_installed("gdalraster") + withr::defer(gdal_config_reset()) + + gdal_config_set(gdal_config(GDAL_NUM_THREADS = "6")) + expect_identical(unname(gdal_config_get("GDAL_NUM_THREADS")), "6") + + gdal_config_set(gpkg_config_opts(sqlite_synchronous = "OFF")) + expect_identical(unname(gdal_config_get("OGR_SQLITE_SYNCHRONOUS")), "OFF") +}) + +test_that("reset restores a pre-existing in-memory value (not blindly unset)", { + skip_if_not_installed("gdalraster") + withr::defer(gdalraster::set_config_option("CPL_TMPDIR", "")) + withr::defer(gdal_config_reset()) + + gdalraster::set_config_option("CPL_TMPDIR", "external-value") + gdal_config_set(CPL_TMPDIR = "package-value") + gdal_config_reset("CPL_TMPDIR") + expect_identical(gdalraster::get_config_option("CPL_TMPDIR"), "external-value") +}) + +test_that("unknown option names trigger an advisory (classed, non-blocking) warning", { + skip_if_not_installed("gdalraster") + withr::defer(gdal_config_reset()) + expect_warning( + gdal_config_set(GDALVECTOR_NOT_A_REAL_OPTION = "x"), + class = "gdal_config_unknown_warning" + ) + # advisory only: the value was still applied + expect_identical(unname(gdal_config_get("GDALVECTOR_NOT_A_REAL_OPTION")), "x") +}) + +test_that("setting GDAL_CONFIG_FILE warns that it has no effect post-load", { + skip_if_not_installed("gdalraster") + withr::defer(gdal_config_reset()) + expect_warning( + gdal_config_set(GDAL_CONFIG_FILE = "nonexistent.rc"), + class = "gdal_config_file_warning" + ) +}) + +# envvar asymmetry ------------------------------------------------------------------------------------------------ + +test_that("set overrides an envvar; unset(mode = 'reveal') surfaces it again with a warning", { + skip_if_not_installed("gdalraster") + withr::defer(gdal_config_reset()) + withr::local_envvar(GDAL_HTTP_TIMEOUT = "123") + + expect_identical(gdalraster::get_config_option("GDAL_HTTP_TIMEOUT"), "123") + gdal_config_set(GDAL_HTTP_TIMEOUT = "60") + expect_identical(gdalraster::get_config_option("GDAL_HTTP_TIMEOUT"), "60") + + expect_warning(gdal_config_unset("GDAL_HTTP_TIMEOUT"), class = "gdal_config_env_warning") + expect_identical(gdalraster::get_config_option("GDAL_HTTP_TIMEOUT"), "123") +}) + +test_that("unset(mode = 'scrub') removes the envvar and reset puts it back", { + skip_if_not_installed("gdalraster") + withr::defer(gdal_config_reset()) + withr::local_envvar(GDAL_HTTP_MAX_RETRY = "7") + + gdal_config_unset("GDAL_HTTP_MAX_RETRY", mode = "scrub") + expect_identical(Sys.getenv("GDAL_HTTP_MAX_RETRY"), "") + expect_identical(gdalraster::get_config_option("GDAL_HTTP_MAX_RETRY"), "") + + gdal_config_reset("GDAL_HTTP_MAX_RETRY") + expect_identical(Sys.getenv("GDAL_HTTP_MAX_RETRY"), "7") + expect_identical(gdalraster::get_config_option("GDAL_HTTP_MAX_RETRY"), "7") +}) + +test_that("unset(mode = 'mask') pins a documented default or errors informatively", { + skip_if_not_installed("gdalraster") + withr::defer(gdal_config_reset()) + + expect_error( + gdal_config_unset("GDAL_HTTP_USERAGENT", mode = "mask"), + class = "gdal_config_default_error" + ) + + tbl <- gdal_vector_driver_opts(type = "config") + tbl <- tbl[!is.na(tbl$default), , drop = FALSE] + skip_if(nrow(tbl) == 0L, "no curated config-option defaults available") + key <- tbl$name[[1]] + default <- tbl$default[[1]] + gdal_config_unset(key, mode = "mask") + expect_identical(unname(gdal_config_get(key)), default) +}) + +test_that("reset falls back to '' when the prior value came from an envvar", { + skip_if_not_installed("gdalraster") + withr::defer(gdal_config_reset()) + withr::local_envvar(GDAL_HTTP_TIMEOUT = "222") + + gdal_config_set(GDAL_HTTP_TIMEOUT = "60") + gdal_config_reset("GDAL_HTTP_TIMEOUT") + expect_identical(gdalraster::get_config_option("GDAL_HTTP_TIMEOUT"), "222") +}) + +# scoped application ---------------------------------------------------------------------------------------------- + +test_that("local_gdal_config applies within the frame and restores values and state on exit", { + skip_if_not_installed("gdalraster") + withr::defer(gdal_config_reset()) + + dbg_before <- gdalraster::get_config_option("CPL_DEBUG") + gdal_config_set(GDAL_NUM_THREADS = "2") + + fn <- function() { + local_gdal_config(GDAL_NUM_THREADS = "8", CPL_DEBUG = "OFF") + list( + threads = unname(gdal_config_get("GDAL_NUM_THREADS")), + debug = unname(gdal_config_get("CPL_DEBUG")) + ) + } + inside <- fn() + expect_identical(inside$threads, "8") + expect_identical(inside$debug, "OFF") + + expect_identical(unname(gdal_config_get("GDAL_NUM_THREADS")), "2") + expect_identical(gdalraster::get_config_option("CPL_DEBUG"), dbg_before) + + active <- as.list(gdal_config_active()$opts) + expect_identical(active[["GDAL_NUM_THREADS"]], "2") + expect_false("CPL_DEBUG" %in% names(active)) +}) + +test_that("with_gdal_config returns the code value and restores even on error", { + skip_if_not_installed("gdalraster") + withr::defer(gdal_config_reset()) + + before <- gdalraster::get_config_option("GDAL_NUM_THREADS") + out <- with_gdal_config( + gdal_config(GDAL_NUM_THREADS = "3"), + unname(gdal_config_get("GDAL_NUM_THREADS")) + ) + expect_identical(out, "3") + expect_identical(gdalraster::get_config_option("GDAL_NUM_THREADS"), before) + + expect_error(with_gdal_config(gdal_config(GDAL_NUM_THREADS = "3"), stop("boom"))) + expect_identical(gdalraster::get_config_option("GDAL_NUM_THREADS"), before) +}) + +# vsi routing ----------------------------------------------------------------------------------------------------- + +test_that("path-bound vsi opts route to the path-specific store; reset clears them", { + skip_if_not_installed("gdalraster") + withr::defer(gdal_config_reset()) + + gdal_config_set(gdal_vsi_opts(AWS_REGION = "us-east-1", vsi_path = "/vsis3/gdalvector-test-bucket/")) + active <- gdal_config_active() + expect_named(active$vsi, "/vsis3/gdalvector-test-bucket/") + expect_identical(as.list(active$vsi[[1]]), list(AWS_REGION = "us-east-1")) + + gdal_config_reset() + expect_length(gdal_config_active()$vsi, 0L) +}) + +test_that("unbound vsi opts are applied as plain config options", { + skip_if_not_installed("gdalraster") + withr::defer(gdal_config_reset()) + + gdal_config_set(gdal_vsi_opts(AWS_REGION = "us-west-2")) + expect_identical(unname(gdal_config_get("AWS_REGION")), "us-west-2") + expect_length(gdal_config_active()$vsi, 0L) +}) + +test_that("local_gdal_config clears scoped vsi path options on exit", { + skip_if_not_installed("gdalraster") + withr::defer(gdal_config_reset()) + + fn <- function() { + local_gdal_config(gdal_vsi_opts(AWS_REGION = "eu-west-1", vsi_path = "/vsis3/gdalvector-scoped/")) + length(gdal_config_active()$vsi) + } + expect_identical(fn(), 1L) + expect_length(gdal_config_active()$vsi, 0L) +}) + +# sitrep ---------------------------------------------------------------------------------------------------------- + +test_that("gdal_config_sitrep attributes provenance and redacts secrets in print", { + skip_if_not_installed("gdalraster") + withr::defer(gdal_config_reset()) + withr::local_envvar(GDAL_HTTP_CONNECTTIMEOUT = "111") + + gdal_config_set(GDAL_NUM_THREADS = "5", AWS_SECRET_ACCESS_KEY = "supersecretvalue") + sitrep <- gdal_config_sitrep() + expect_true(is_gdal_config_sitrep(sitrep)) + + tbl <- tibble::as_tibble(sitrep) + expect_identical(unique(tbl$source[tbl$key == "GDAL_NUM_THREADS"]), "gdalvector") + expect_identical(unique(tbl$source[tbl$key == "GDAL_HTTP_CONNECTTIMEOUT"]), "envvar") + + withr::local_options(cli.num_colors = 1, cli.width = 200) + out <- paste(format(sitrep), collapse = "\n") + expect_false(grepl("supersecretvalue", out, fixed = TRUE)) + expect_match(out, "", fixed = TRUE) +}) + +test_that("the sitrep bridges back into the options value system", { + skip_if_not_installed("gdalraster") + withr::defer(gdal_config_reset()) + withr::local_envvar(GDAL_HTTP_RETRY_CODES = "429,503") + + gdal_config_set(GDAL_NUM_THREADS = "4") + + session <- as.list(as_gdal_config_opts(gdal_config_sitrep())) + expect_identical(session[["GDAL_NUM_THREADS"]], "4") + expect_false("GDAL_HTTP_RETRY_CODES" %in% names(session)) + + effective <- as.list(as_gdal_config_opts(gdal_config_sitrep(), scope = "effective")) + expect_identical(effective[["GDAL_HTTP_RETRY_CODES"]], "429,503") +}) + +test_that("sitrep prints without error and an at-load baseline was stashed", { + skip_if_not_installed("gdalraster") + withr::local_options(cli.num_colors = 1, cli.width = 200) + expect_output(res <- withVisible(print(gdal_config_sitrep()))) + expect_false(res$visible) + expect_true(is_gdal_config_sitrep(.pkg_env$gdal$config$at_load)) +}) + +# load-time defaults ---------------------------------------------------------------------------------------------- + +test_that("fill-only session defaults pin the user agent and are reversible", { + skip_if_not_installed("gdalraster") + withr::defer(gdal_config_reset()) + + gdal_config_reset() + gdal_config_init_defaults() + expect_identical(unname(gdal_config_get("GDAL_HTTP_USERAGENT")), pkg_user_agent()) + active <- as.list(gdal_config_active()$opts) + expect_identical(active[["GDAL_HTTP_USERAGENT"]], pkg_user_agent()) + + # fill-only: an existing value is never overridden + gdal_config_reset() + gdalraster::set_config_option("GDAL_HTTP_USERAGENT", "custom-agent") + withr::defer(gdalraster::set_config_option("GDAL_HTTP_USERAGENT", "")) + gdal_config_init_defaults() + expect_identical(gdalraster::get_config_option("GDAL_HTTP_USERAGENT"), "custom-agent") +}) + +test_that("options(gdalvector.config_defaults = FALSE) opts out of session defaults", { + skip_if_not_installed("gdalraster") + withr::defer(gdal_config_reset()) + withr::local_options(gdalvector.config_defaults = FALSE) + + gdal_config_reset() + gdal_config_init_defaults() + expect_identical(gdalraster::get_config_option("GDAL_HTTP_USERAGENT"), "") +}) + +# config file ----------------------------------------------------------------------------------------------------- + +test_that("gdal_config_file_read parses configoptions, directives, and path-bound credentials", { + rc <- withr::local_tempfile(fileext = ".gdalrc") + writeLines( + c( + "# a comment", + "[directives]", + "ignore-env-vars = yes", + "[configoptions]", + "GDAL_NUM_THREADS = ALL_CPUS", + "CPL_TMPDIR=C:/tmp=x", + "[credentials]", + "[.private_bucket]", + "path=/vsis3/my_private_bucket", + "AWS_SECRET_ACCESS_KEY=topsecret", + "AWS_ACCESS_KEY_ID=keyid", + "[.sentinel]", + "path=/vsis3/sentinel-s2-l1c", + "AWS_REQUEST_PAYER=requester" + ), + rc + ) + + f <- gdal_config_file_read(rc) + expect_true(is_gdal_config_file(f)) + expect_identical(f$directives[["ignore-env-vars"]], "yes") + expect_identical(as.list(f$configoptions)[["GDAL_NUM_THREADS"]], "ALL_CPUS") + # '=' inside values survives + expect_identical(as.list(f$configoptions)[["CPL_TMPDIR"]], "C:/tmp=x") + + expect_setequal(names(f$credentials), c("/vsis3/my_private_bucket", "/vsis3/sentinel-s2-l1c")) + bucket <- f$credentials[["/vsis3/my_private_bucket"]] + expect_true(is_gdal_vsi_opts(bucket)) + expect_identical(attr(bucket, "vsi_path"), "/vsis3/my_private_bucket") + expect_identical(as.list(bucket)[["AWS_ACCESS_KEY_ID"]], "keyid") + + # secrets are redacted in print, present in the value + withr::local_options(cli.num_colors = 1, cli.width = 200) + out <- paste(format(f), collapse = "\n") + expect_false(grepl("topsecret", out, fixed = TRUE)) +}) + +test_that("a gdal_config value round-trips through write + read", { + cfg <- gdal_config( + GDAL_NUM_THREADS = "ALL_CPUS", + OGR_SQLITE_SYNCHRONOUS = "OFF", + gdal_vsi_opts( + AWS_S3_ENDPOINT = "t3.storage.dev", + AWS_ACCESS_KEY_ID = "keyid", + vsi_path = "/vsis3/noclocks-spatial/" + ) + ) + rc <- withr::local_tempfile(fileext = ".gdalrc") + gdal_config_file_write(cfg, rc) + + back <- as_gdal_config(gdal_config_file_read(rc)) + expect_identical( + as.list(back$opts)[order(names(as.list(back$opts)))], + as.list(cfg$opts)[order(names(as.list(cfg$opts)))] + ) + expect_named(back$vsi, "/vsis3/noclocks-spatial/") + expect_identical( + as.list(back$vsi[[1]])[order(names(as.list(back$vsi[[1]])))], + as.list(cfg$vsi[[1]])[order(names(as.list(cfg$vsi[[1]])))] + ) +}) + +test_that("gdal_config_file discovery honors the GDAL_CONFIG_FILE envvar on refresh", { + rc <- withr::local_tempfile(fileext = ".gdalrc") + writeLines(c("[configoptions]", "GDAL_NUM_THREADS=ALL_CPUS"), rc) + # registered before local_envvar so it runs *after* the envvar is restored (LIFO) + withr::defer(gdal_config_file(refresh = TRUE)) + withr::local_envvar(GDAL_CONFIG_FILE = rc) + f <- gdal_config_file(refresh = TRUE) + expect_identical(f$path, rc) + expect_identical(as.list(f$configoptions)[["GDAL_NUM_THREADS"]], "ALL_CPUS") +}) + +test_that("gdal_config_file returns an empty classed object when nothing is discovered", { + f <- gdal_config_file(refresh = TRUE) + expect_true(is_gdal_config_file(f)) + expect_named(f, c("path", "configoptions", "directives", "credentials")) +})