-
Notifications
You must be signed in to change notification settings - Fork 0
Add opt-in BV-BRC Data API download #46
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
eboyer221
wants to merge
8
commits into
main
Choose a base branch
from
bvbrc-api-integration
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
4ab57b5
Add opt-in BV-BRC Data API download
eboyer221 4910a77
Resolve genome IDs via BV-BRC API for method='api'
eboyer221 5678c8e
Fix zero-row handling in .bvbrc_prefix_fill (empty query result)
eboyer221 09ac812
Match Docker empty-string convention in API extractors (parity)
eboyer221 0d26842
Add docker-vs-api parity check scripts
eboyer221 85b7045
Merge remote-tracking branch 'origin/main' into bvbrc-api-integration
eboyer221 8c62f75
Adding API download to retrieve/prepareGenomes
epbrenner 2a83847
minor consistency edits
jananiravi File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,3 +9,5 @@ | |
| ^\.DS_Store$ | ||
| ^data_raw$ | ||
| ^doc$ | ||
| ^dev$ | ||
| ^docs$ | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,3 +8,5 @@ | |
| inst/doc | ||
| /inst/extdata/interpro | ||
| /data/tmp | ||
| dev/out/ | ||
| docs/*.html | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,223 @@ | ||
| # BV-BRC Data API path (additive; opt-in via retrieveMetadata(metadata_method = "api")) | ||
| # ----------------------------------------------------------------------------- | ||
| # Replaces ONLY the Docker/p3-* download of AMR + genome metadata. It returns the | ||
| # same two tibbles the Docker path produces -- columns prefixed `genome_drug.*` | ||
| # and `genome.*` -- so all downstream QC/join/DuckDB logic is reused unchanged. | ||
| # | ||
| # Why: the p3-* CLI path is stochastic (issue #30) -- it merges stderr into the | ||
| # data stream and never retries, so a transient BV-BRC 503 corrupts a batch. The | ||
| # API path uses typed JSON + retry-with-backoff, so a 503 is retried, never | ||
| # parsed as data. | ||
| # | ||
| # @keywords internal | ||
|
|
||
| .BVBRC_API_BASE <- "https://www.bv-brc.org/api" | ||
| .BVBRC_PAGE_MAX <- 25000L | ||
|
|
||
| # One HTTP request with retry on transient failures. | ||
| .bvbrcApiReq <- function(collection, rql, accept = "application/json") { | ||
| url <- sprintf("%s/%s/?%s", .BVBRC_API_BASE, collection, rql) | ||
| httr2::request(url) |> | ||
| httr2::req_headers(Accept = accept) |> | ||
| httr2::req_timeout(120) |> | ||
| httr2::req_retry( | ||
| max_tries = 5L, | ||
| is_transient = function(resp) httr2::resp_status(resp) %in% | ||
| c(429, 500, 502, 503, 504) | ||
| ) | ||
| } | ||
|
|
||
| .bvbrcEnc <- function(x) utils::URLencode(as.character(x), reserved = TRUE) | ||
|
|
||
| # Keyset-paginate a filtered query past the 25k cap. `key` is the unique | ||
| # sort/seek field ("id" for genome_amr, "genome_id" for genome). | ||
| .bvbrcApiFetch <- function(collection, rql_filter, select, key = "id") { | ||
| out <- list() | ||
| last <- "" | ||
| i <- 0L | ||
| repeat { | ||
| seek <- if (nzchar(last)) { | ||
| sprintf("and(%s,gt(%s,%s))", rql_filter, key, .bvbrcEnc(last)) | ||
| } else { | ||
| rql_filter | ||
| } | ||
| rql <- sprintf( | ||
| "%s&select(%s,%s)&sort(+%s)&limit(%d)", | ||
| seek, key, select, key, .BVBRC_PAGE_MAX | ||
| ) | ||
| resp <- httr2::req_perform(.bvbrcApiReq(collection, rql)) | ||
| pg <- jsonlite::fromJSON(httr2::resp_body_string(resp), | ||
| simplifyDataFrame = TRUE | ||
| ) | ||
| raw_n <- if (is.data.frame(pg)) nrow(pg) else 0L | ||
| if (raw_n == 0L) break | ||
| if (nzchar(last)) pg <- pg[pg[[key]] != last, , drop = FALSE] | ||
| if (nrow(pg) > 0L) { | ||
| i <- i + 1L | ||
| out[[i]] <- pg | ||
| last <- pg[[key]][nrow(pg)] | ||
| } | ||
| if (raw_n < .BVBRC_PAGE_MAX) break | ||
| } | ||
| if (i == 0L) { | ||
| return(tibble::tibble()) | ||
| } | ||
| tibble::as_tibble(data.table::rbindlist(out, fill = TRUE, use.names = TRUE)) | ||
| } | ||
|
|
||
| # Split a vector into chunks of size n (keeps in(...) URLs within length limits). | ||
| .bvbrcChunk <- function(x, n) { | ||
| if (length(x) == 0L) { | ||
| return(list()) | ||
| } | ||
| split(x, ceiling(seq_along(x) / n)) | ||
| } | ||
|
|
||
| # Ensure every expected field is present (fill missing with NA), coerce to | ||
| # character, order columns, and apply the `prefix.` naming convention that the | ||
| # Docker/p3 parser produces. | ||
| .bvbrcPrefixFill <- function(df, expected, prefix) { | ||
| df <- as.data.frame(df, stringsAsFactors = FALSE) | ||
| n <- nrow(df) # rep() keeps length right when the query returned 0 rows | ||
| for (f in expected) { | ||
| if (!f %in% names(df)) df[[f]] <- rep(NA_character_, n) | ||
| } | ||
| df <- df[, expected, drop = FALSE] | ||
| # coerce to character and use "" for missing, matching the Docker/TSV parser | ||
| # (.parse_bvbrc_tsv yields "" for blank fields, not NA) so the two paths agree. | ||
| df[] <- lapply(df, function(x) { | ||
| x <- as.character(x) | ||
| x[is.na(x)] <- "" | ||
| x | ||
| }) | ||
| names(df) <- paste0(prefix, ".", expected) | ||
| tibble::as_tibble(df) | ||
| } | ||
|
|
||
| # --- AMR phenotype (genome_amr) -> genome_drug.* ------------------------------ | ||
| .extractAMRtableApi <- function(genome_ids, abx = "All", | ||
| chunk_size = 500L, verbose = TRUE) { | ||
| # Full genome_amr field set; computational_method and measurement_unit are | ||
| # populated on live BV-BRC (confirmed against the real API), so both are | ||
| # fetched -- only `source` is consistently absent and gets filled "". | ||
| expected <- c( | ||
| "genome_id", "antibiotic", "computational_method", "evidence", | ||
| "genome_name", "id", "laboratory_typing_method", | ||
| "laboratory_typing_platform", "measurement", "measurement_sign", | ||
| "measurement_unit", "measurement_value", "pmid", "resistant_phenotype", | ||
| "source", "taxon_id", "testing_standard" | ||
| ) | ||
| have <- setdiff(expected, "source") | ||
| # keyset key for genome_amr is "id"; keep genome_id as a data column. | ||
| sel <- paste(setdiff(have, "id"), collapse = ",") | ||
|
|
||
| chunks <- .bvbrcChunk(genome_ids, chunk_size) | ||
| if (isTRUE(verbose)) { | ||
| message(" [api] AMR: ", length(genome_ids), " genomes in ", | ||
| length(chunks), " chunk(s)") | ||
| } | ||
| parts <- furrr::future_map( | ||
| chunks, | ||
| function(ids) { | ||
| ab <- if (identical(abx, "All")) { | ||
| "" | ||
| } else { | ||
| sprintf(",in(antibiotic,(%s))", | ||
| paste(vapply(abx, .bvbrcEnc, ""), collapse = ",")) | ||
| } | ||
| filt <- sprintf("and(in(genome_id,(%s))%s)", | ||
| paste(vapply(ids, .bvbrcEnc, ""), collapse = ","), ab) | ||
| .bvbrcApiFetch("genome_amr", filt, sel, key = "id") | ||
| }, | ||
| .options = furrr::furrr_options(seed = TRUE) | ||
| ) | ||
| df <- data.table::rbindlist(parts, fill = TRUE, use.names = TRUE) | ||
| .bvbrcPrefixFill(df, expected, "genome_drug") | ||
| } | ||
|
|
||
| # --- genome-ID resolution (genome) -------------------------------------------- | ||
| # API replacement for .retrieveQueryIDs(): resolve species names and/or taxon IDs | ||
| # to Good-quality WGS/Complete genome IDs, and write the `bac_data` table (used by | ||
| # retrieveMetadata()'s summary). Uses the Data API instead of the Docker-built | ||
| # cache, so retrieveMetadata(metadata_method = "api") needs no Docker. | ||
| .resolveGenomeIDsApi <- function(base_dir = ".", user_bacs, | ||
| overwrite = FALSE, verbose = TRUE) { | ||
| sel <- "genome_name,taxon_id,species,strain" | ||
| parts <- furrr::future_map( | ||
| user_bacs, | ||
| function(ub) { | ||
| ub <- trimws(as.character(ub)) | ||
| key_filter <- if (grepl("^[0-9]+$", ub)) { | ||
| sprintf("eq(taxon_lineage_ids,%s)", ub) # taxon ID (any rank) | ||
| } else { | ||
| sprintf("eq(species,%s)", .bvbrcEnc(ub)) # species name | ||
| } | ||
| filt <- sprintf( | ||
| "and(%s,eq(genome_quality,Good),in(genome_status,(WGS,Complete)))", | ||
| key_filter | ||
| ) | ||
| if (isTRUE(verbose)) message(" [api] resolving genome IDs for '", ub, "'") | ||
| res <- .bvbrcApiFetch("genome", filt, sel, key = "genome_id") | ||
| if (nrow(res) == 0L) { | ||
| warning( | ||
| "BV-BRC API resolved 0 genomes for user_bacs entry '", ub, "'. ", | ||
| "The API path matches species names exactly and taxon IDs against ", | ||
| "the full lineage -- if this input relied on substring matching or ", | ||
| "exact-rank taxon matching under the CLI path, results will differ.", | ||
| call. = FALSE | ||
| ) | ||
| } | ||
| res | ||
| }, | ||
| .options = furrr::furrr_options(seed = TRUE) | ||
| ) | ||
| df <- as.data.frame( | ||
| data.table::rbindlist(parts, fill = TRUE, use.names = TRUE), | ||
| stringsAsFactors = FALSE | ||
| ) | ||
| if (nrow(df) == 0L) { | ||
| return(character(0)) | ||
| } | ||
| df <- df[grepl("^[0-9]+[.][0-9]+$", df$genome_id), , drop = FALSE] | ||
| df <- df[!duplicated(df$genome_id), , drop = FALSE] | ||
|
|
||
| # write bac_data (genome.* columns), mirroring .retrieveQueryIDs() | ||
| paths <- .buildDBpath(base_dir = base_dir, user_bacs = user_bacs, overwrite = overwrite) | ||
| con <- DBI::dbConnect(duckdb::duckdb(), dbdir = paths$db_path) | ||
| on.exit(try(DBI::dbDisconnect(con, shutdown = TRUE), silent = TRUE), add = TRUE) | ||
| bac <- .bvbrcPrefixFill( | ||
| df, | ||
| c("genome_id", "genome_name", "taxon_id", "species", "strain"), | ||
| "genome" | ||
| ) | ||
| DBI::dbWriteTable(con, "bac_data", as.data.frame(bac), overwrite = TRUE) | ||
| if (isTRUE(verbose)) { | ||
| message(" [api] resolved ", nrow(df), " genome IDs; wrote bac_data") | ||
| } | ||
| unique(df$genome_id) | ||
| } | ||
|
|
||
| # --- genome metadata (genome) -> genome.* ------------------------------------- | ||
| .extractGenomeDataApi <- function(genome_ids, fields, | ||
| chunk_size = 500L, verbose = TRUE) { | ||
| expected <- strsplit(fields, ",", fixed = TRUE)[[1]] | ||
| expected <- unique(c("genome_id", expected)) | ||
| sel <- paste(setdiff(expected, "genome_id"), collapse = ",") | ||
|
|
||
| chunks <- .bvbrcChunk(genome_ids, chunk_size) | ||
| if (isTRUE(verbose)) { | ||
| message(" [api] genome metadata: ", length(genome_ids), " genomes in ", | ||
| length(chunks), " chunk(s)") | ||
| } | ||
| parts <- furrr::future_map( | ||
| chunks, | ||
| function(ids) { | ||
| filt <- sprintf("in(genome_id,(%s))", | ||
| paste(vapply(ids, .bvbrcEnc, ""), collapse = ",")) | ||
| .bvbrcApiFetch("genome", filt, sel, key = "genome_id") | ||
| }, | ||
| .options = furrr::furrr_options(seed = TRUE) | ||
| ) | ||
| df <- data.table::rbindlist(parts, fill = TRUE, use.names = TRUE) | ||
| .bvbrcPrefixFill(df, expected, "genome") | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Check semantics against the CLI path here: species names now match exactly (eq(species, ub)) vs. the CLI's case-insensitive substring match, and numeric taxon IDs match taxon_lineage_ids — any rank in the lineage — vs. the CLI's exact match on the genome's own taxon_id. metadata_method="api" and "cli" can silently return different genome sets for the same input. The zero-match case now warns (see below), but can we align the two paths, or is this an intentional change we should document in ?retrieveMetadata?