Files
uscogdata/R/mirror.R
jared 377eed1240 feat: cog_gov_search + cog_mirror + scope-aware verb behavior
Three pieces:

1. cog_gov_search: name/state/type search over canonical_fips_xwalk
   for resolving human-readable place names into canonical_govids.
   Accepts USPS abbrev ('FL') or FIPS int (12) for state; integer
   0-3 or name ('state','county','city','township') for type. Types
   4/5 emit an explanatory cli message and return an empty tibble
   (v0.1 corpus excludes them). USPS<->FIPS table hardcoded with
   50 states + DC + territories; FIPS 66 = GU (not GA).

2. cog_mirror: downloads manifest-listed files to a local directory
   with SHA-256 idempotency (files with matching hash return status
   'cached'). Supports HTTP and local-path fixture URLs. Round-trip
   test: mirror + re-open against the mirror + query Broward 2020
   returns identical results.

3. Scope-aware verbs: .check_govids_in_scope() helper in session.R
   queries canonical_fips_xwalk for the requested govids, emits a
   cli_inform listing any missing ones, and records the found/missing
   sets under provenance$scope. Wired into cog_spending (and
   transitively into cog_revenue, cog_geographic_rollup,
   cog_peer_compare via their cog_spending calls).

Also: dropped dbplyr from Imports (unused).

Tests: +29 (22 search + 12 mirror - 5 refactored) / 159 total pass.
devtools::check() now clean: 0E / 0W / 0N.
2026-04-24 11:38:32 -04:00

141 lines
4.6 KiB
R

# R/mirror.R
#' Mirror the published corpus to a local directory
#'
#' Downloads (or copies, for local-path fixture URLs) every file listed in
#' the session's `manifest.json` into `dest`, preserving the relative path
#' structure. Files already present with a matching SHA-256 hash are
#' skipped unless `overwrite = TRUE`. The resulting directory can be
#' pointed at via `options(uscogdata.url = paste0(dest, "/"))` for offline
#' analysis.
#'
#' @param dest Destination directory. Created if missing.
#' @param include Subset of `c("long", "metadata", "docs")` controlling
#' which manifest sections to mirror.
#' @param overwrite If `TRUE`, re-copy even when the local SHA matches.
#' @param progress If `TRUE`, show a cli progress bar.
#' @return Invisibly, a tibble of `(path, sha256, size_bytes, status)`
#' rows where `status` is `"downloaded"` or `"cached"`.
#' @export
cog_mirror <- function(dest,
include = c("long", "metadata", "docs"),
overwrite = FALSE,
progress = interactive()) {
if (!is.character(dest) || length(dest) != 1L) {
cli::cli_abort("`dest` must be a length-1 character path.")
}
include <- match.arg(include, several.ok = TRUE)
if (!dir.exists(dest)) dir.create(dest, recursive = TRUE)
.ensure_session()
url <- .uscogdata_env$url
manifest <- .uscogdata_env$manifest
is_local <- .is_local_path(url)
.write_manifest_local(manifest, file.path(dest, "manifest.json"))
entries <- .collect_mirror_entries(manifest, include)
results <- vector("list", length(entries))
pb <- if (isTRUE(progress)) {
cli::cli_progress_bar("Mirroring", total = length(entries))
} else NULL
for (i in seq_along(entries)) {
e <- entries[[i]]
dest_path <- file.path(dest, e$path)
.ensure_parent_dir(dest_path)
status <- .mirror_one_file(
src_url = paste0(url, e$path),
dest_path = dest_path,
expected_sha = e$sha256,
is_local = is_local,
overwrite = overwrite
)
results[[i]] <- tibble::tibble(
path = e$path,
sha256 = e$sha256 %||% NA_character_,
size_bytes = as.integer(file.info(dest_path)$size),
status = status
)
if (!is.null(pb)) cli::cli_progress_update()
}
if ("docs" %in% include) {
.mirror_docs(url, dest, is_local, overwrite)
}
if (!is.null(pb)) cli::cli_progress_done()
invisible(dplyr::bind_rows(results))
}
#' @noRd
.write_manifest_local <- function(manifest, path) {
writeLines(
jsonlite::toJSON(manifest, auto_unbox = TRUE, pretty = TRUE),
path
)
}
#' @noRd
.collect_mirror_entries <- function(manifest, include) {
entries <- list()
if ("long" %in% include && length(manifest$files$long_partitions) > 0L) {
entries <- c(entries, manifest$files$long_partitions)
}
if ("metadata" %in% include && length(manifest$files$metadata) > 0L) {
entries <- c(entries, manifest$files$metadata)
}
entries
}
#' @noRd
.ensure_parent_dir <- function(path) {
d <- dirname(path)
if (!dir.exists(d)) dir.create(d, recursive = TRUE)
}
#' @noRd
.mirror_one_file <- function(src_url, dest_path, expected_sha,
is_local, overwrite) {
if (!isTRUE(overwrite) && file.exists(dest_path) &&
!is.null(expected_sha) &&
digest::digest(dest_path, algo = "sha256", file = TRUE) == expected_sha) {
return("cached")
}
if (isTRUE(is_local)) {
# src_url was built as paste0(url, e$path); url ends in "/"
src <- src_url
if (!file.exists(src)) {
cli::cli_abort("Source file missing: {src}")
}
ok <- file.copy(src, dest_path, overwrite = TRUE)
if (!isTRUE(ok)) cli::cli_abort("file.copy failed: {src} -> {dest_path}")
} else {
resp <- httr2::request(src_url) |>
httr2::req_error(is_error = function(r) httr2::resp_status(r) >= 400) |>
httr2::req_perform()
writeBin(httr2::resp_body_raw(resp), dest_path)
}
"downloaded"
}
#' @noRd
.mirror_docs <- function(url, dest, is_local, overwrite) {
docs <- c("README.md", "data_dictionary.md",
"series_breaks.md", "reader-specification.md")
for (doc in docs) {
src <- paste0(url, "docs/", doc)
dst <- file.path(dest, "docs", doc)
.ensure_parent_dir(dst)
if (isTRUE(is_local)) {
if (file.exists(src)) file.copy(src, dst, overwrite = TRUE)
# silently skip missing optional docs in local fixtures
next
}
try({
resp <- httr2::request(src) |>
httr2::req_error(is_error = function(r) httr2::resp_status(r) >= 400) |>
httr2::req_perform()
writeBin(httr2::resp_body_raw(resp), dst)
}, silent = TRUE)
}
}