From 611849df2c2ca869b6999bb87b92760d654f4739 Mon Sep 17 00:00:00 2001 From: Dimi Brosens Date: Fri, 20 Jun 2025 15:52:02 +0200 Subject: [PATCH 01/94] Update node_metadata.csv we have our production url now --- data-raw/node_metadata.csv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data-raw/node_metadata.csv b/data-raw/node_metadata.csv index ae386881..67aceaeb 100644 --- a/data-raw/node_metadata.csv +++ b/data-raw/node_metadata.csv @@ -2,7 +2,7 @@ region,institution,acronym,url,supported Australia,Atlas of Living Australia,ALA,https://www.ala.org.au,TRUE Austria,Biodiversitäts-Atlas Österreich,BAO,https://biodiversityatlas.at,TRUE Brazil,Sistemas de Informações sobre a Biodiversidade Brasileira,SiBBr,https://sibbr.gov.br,TRUE -Flanders,Vlaams Biodiversiteitsportaal,VBP,https://natuurdata.dev.inbo.be,FALSE +Flanders,Vlaams Biodiversiteitsportaal,VBP,https://natuurdata.inbo.be,FALSE France,Portail français d'accès aux données d'observation sur les espèces,OpenObs,https://openobs.mnhn.fr,TRUE Global,Global Biodiversity Information Facility,GBIF,https://gbif.org,TRUE Guatemala,Sistema Nacional de Información sobre Diversidad Biológica de Guatemala,SNIBgt,https://snib.conap.gob.gt,TRUE From de0bcc8caa8ef35e6adc21afa5309d8d2791eb5e Mon Sep 17 00:00:00 2001 From: Dimi Brosens Date: Fri, 20 Jun 2025 15:57:56 +0200 Subject: [PATCH 02/94] Update node_config.csv update url's to production environment --- data-raw/node_config.csv | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/data-raw/node_config.csv b/data-raw/node_config.csv index f5d7f3fa..826c9987 100644 --- a/data-raw/node_config.csv +++ b/data-raw/node_config.csv @@ -73,25 +73,25 @@ Estonia,metadata/fields-unnest,https://elurikkus.ee/biocache-service/occurrence/ Estonia,metadata/providers,https://elurikkus.ee/collectory/ws/dataProvider,TRUE Estonia,metadata/taxa-single,https://elurikkus.ee/bie-index/search?q={name}&pageSize=5,TRUE Estonia,metadata/taxa-unnest,https://elurikkus.ee/bie-index/childConcepts/{id},TRUE -Flanders,data/metadata/fields-unnest,https://natuurdata.dev.inbo.be/biocache-service/occurrence/facets,TRUE -Flanders,data/occurrences,https://natuurdata.dev.inbo.be/biocache-service/occurrences/offline/download,TRUE -Flanders,data/occurrences-count,https://natuurdata.dev.inbo.be/biocache-service/occurrences/search,TRUE -Flanders,data/occurrences-count-groupby,https://natuurdata.dev.inbo.be/biocache-service/occurrence/facets,TRUE -Flanders,data/species,https://natuurdata.dev.inbo.be/biocache-service/occurrences/facets/download,TRUE -Flanders,data/species-count-,https://natuurdata.dev.inbo.be/biocache-service/occurrence/facets,TRUE -Flanders,files/media,https://natuurdata.dev.inbo.be/image-service/ws/image/{id}/original,TRUE -Flanders,metadata/assertions,https://natuurdata.dev.inbo.be/biocache-service/assertions/codes,TRUE -Flanders,metadata/collections,https://natuurdata.dev.inbo.be/collectory/ws/collection,TRUE -Flanders,metadata/datasets,https://natuurdata.dev.inbo.be/collectory/ws/dataResource,TRUE -Flanders,metadata/fields,https://natuurdata.dev.inbo.be/biocache-service/index/fields,TRUE -Flanders,metadata/licences,https://natuurdata.dev.inbo.be/image-service/ws/licence,TRUE -Flanders,metadata/lists,https://natuurdata.dev.inbo.be/species-list/ws/speciesList/,TRUE -Flanders,metadata/lists-unnest,https://natuurdata.dev.inbo.be/species-list/ws/speciesListItems/{list_id},TRUE -Flanders,metadata/media,https://natuurdata.dev.inbo.be/image-service/ws/getImageInfoForIdList,TRUE -Flanders,metadata/profiles,https://natuurdata.dev.inbo.be/dqf-service/api/v1/data-profiles/,TRUE -Flanders,metadata/profiles-unnest,https://natuurdata.dev.inbo.be/dqf-service/api/v1/quality/activeProfile?profileName={profile},TRUE -Flanders,metadata/providers,https://natuurdata.dev.inbo.be/collectory/ws/dataProvider,TRUE -Flanders,metadata/reasons,https://natuurdata.dev.inbo.be/logger/service/logger/reasons,TRUE +Flanders,data/metadata/fields-unnest,https://natuurdata.inbo.be/biocache-service/occurrence/facets,TRUE +Flanders,data/occurrences,https://natuurdata.inbo.be/biocache-service/occurrences/offline/download,TRUE +Flanders,data/occurrences-count,https://natuurdata.inbo.be/biocache-service/occurrences/search,TRUE +Flanders,data/occurrences-count-groupby,https://natuurdata.inbo.be/biocache-service/occurrence/facets,TRUE +Flanders,data/species,https://natuurdata.inbo.be/biocache-service/occurrences/facets/download,TRUE +Flanders,data/species-count-,https://natuurdata.inbo.be/biocache-service/occurrence/facets,TRUE +Flanders,files/media,https://natuurdata.inbo.be/image-service/ws/image/{id}/original,TRUE +Flanders,metadata/assertions,https://natuurdata.inbo.be/biocache-service/assertions/codes,TRUE +Flanders,metadata/collections,https://natuurdata.inbo.be/collectory/ws/collection,TRUE +Flanders,metadata/datasets,https://natuurdata.inbo.be/collectory/ws/dataResource,TRUE +Flanders,metadata/fields,https://natuurdata.inbo.be/biocache-service/index/fields,TRUE +Flanders,metadata/licences,https://natuurdata.inbo.be/image-service/ws/licence,TRUE +Flanders,metadata/lists,https://natuurdata.inbo.be/species-list/ws/speciesList/,TRUE +Flanders,metadata/lists-unnest,https://natuurdata.inbo.be/species-list/ws/speciesListItems/{list_id},TRUE +Flanders,metadata/media,https://natuurdata.inbo.be/image-service/ws/getImageInfoForIdList,TRUE +Flanders,metadata/profiles,https://natuurdata.inbo.be/dqf-service/api/v1/data-profiles/,TRUE +Flanders,metadata/profiles-unnest,https://natuurdata.inbo.be/dqf-service/api/v1/quality/activeProfile?profileName={profile},TRUE +Flanders,metadata/providers,https://natuurdata.inbo.be/collectory/ws/dataProvider,TRUE +Flanders,metadata/reasons,https://natuurdata.inbo.be/logger/service/logger/reasons,TRUE Flanders,metadata/taxa-single,https://api.gbif.org/v1/species/match?verbose=FALSE&name={name},TRUE Flanders,metadata/taxa-unnest,https://api.gbif.org/v1/species/{id}/children,TRUE France,data/occurrences,https://openobs.mnhn.fr/biocache-service/occurrences/offline/download,TRUE @@ -213,4 +213,4 @@ United Kingdom,metadata/media,https://images.nbnatlas.org/ws/getImageInfoForIdLi United Kingdom,metadata/providers,https://registry.nbnatlas.org/ws/dataProvider,TRUE United Kingdom,metadata/reasons,https://logger.nbnatlas.org/service/logger/reasons,TRUE United Kingdom,metadata/taxa-single,https://species-ws.nbnatlas.org/search?q={name}&pageSize=5,TRUE -United Kingdom,metadata/taxa-unnest,https://species-ws.nbnatlas.org/childConcepts/{id},TRUE \ No newline at end of file +United Kingdom,metadata/taxa-unnest,https://species-ws.nbnatlas.org/childConcepts/{id},TRUE From 3d691208d1cf19599b6c1d3b3ee21547d950fe8c Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Thu, 26 Jun 2025 16:08:26 +1000 Subject: [PATCH 03/94] Attempt to fix broken test coverage on CI Now uses updated script from https://github.com/r-lib/actions/blob/v2/examples/test-coverage.yaml --- .github/workflows/test-coverage.yaml | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test-coverage.yaml b/.github/workflows/test-coverage.yaml index 27d45283..f9cb681d 100644 --- a/.github/workflows/test-coverage.yaml +++ b/.github/workflows/test-coverage.yaml @@ -4,9 +4,10 @@ on: push: branches: [main, master] pull_request: - branches: [main, master] -name: test-coverage +name: test-coverage.yaml + +permissions: read-all jobs: test-coverage: @@ -15,7 +16,7 @@ jobs: GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: r-lib/actions/setup-r@v2 with: @@ -23,28 +24,39 @@ jobs: - uses: r-lib/actions/setup-r-dependencies@v2 with: - extra-packages: any::covr + extra-packages: any::covr, any::xml2 needs: coverage - name: Test coverage run: | - covr::codecov( + cov <- covr::package_coverage( quiet = FALSE, clean = FALSE, install_path = file.path(normalizePath(Sys.getenv("RUNNER_TEMP"), winslash = "/"), "package") ) + print(cov) + covr::to_cobertura(cov) shell: Rscript {0} + - uses: codecov/codecov-action@v5 + with: + # Fail if error if not on PR, or if on PR and token is given + fail_ci_if_error: ${{ github.event_name != 'pull_request' || secrets.CODECOV_TOKEN }} + files: ./cobertura.xml + plugins: noop + disable_search: true + token: ${{ secrets.CODECOV_TOKEN }} + - name: Show testthat output if: always() run: | ## -------------------------------------------------------------------- - find ${{ runner.temp }}/package -name 'testthat.Rout*' -exec cat '{}' \; || true + find '${{ runner.temp }}/package' -name 'testthat.Rout*' -exec cat '{}' \; || true shell: bash - name: Upload test results if: failure() - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: coverage-test-failures - path: ${{ runner.temp }}/package + path: ${{ runner.temp }}/package \ No newline at end of file From 265cd3739d0b8b1fb2eb488ca484729e72de358d Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Fri, 4 Jul 2025 16:00:07 +1000 Subject: [PATCH 04/94] First pass at supporting Kew Data Portal APIs (#271) --- R/atlas_media.R | 6 +- R/check.R | 9 +- R/collect_metadata.R | 6 +- R/collect_unnest.R | 20 +- R/sysdata.rda | Bin 17487 -> 17778 bytes R/utilities_internal.R | 10 +- data-raw/node_config.csv | 25 ++- data-raw/node_metadata.csv | 5 +- tests/testthat/test-international-Kew.R | 243 ++++++++++++++++++++++++ 9 files changed, 305 insertions(+), 19 deletions(-) create mode 100644 tests/testthat/test-international-Kew.R diff --git a/R/atlas_media.R b/R/atlas_media.R index 5bcc39d0..c5556fba 100644 --- a/R/atlas_media.R +++ b/R/atlas_media.R @@ -25,8 +25,9 @@ atlas_media <- function(request = NULL, "Austria", # not currently working "Brazil", "Guatemala", - "Sweden", - "Spain", + "Kew", + "Sweden", + "Spain", "United Kingdom") if(!(atlas %in% supported_atlases)){ abort(glue("`atlas_media` is not supported for atlas = {atlas}")) @@ -127,6 +128,7 @@ parse_regional_media_filters <- function(present_fields){ "Australia" = glue("({present_fields}:*)"), "Brazil" = "(all_image_url:*)", "Guatemala" = "(all_image_url:*)", + "Kew" = "(all_image_url:*)", "Portugal" = "(all_image_url:*)", "Spain" = {filter_fields <- present_fields |> str_remove("s$") |> diff --git a/R/check.R b/R/check.R index 5af19d91..57a5f113 100644 --- a/R/check.R +++ b/R/check.R @@ -173,7 +173,14 @@ check_fields <- function(.query) { #' @noRd #' @keywords Internal check_field_identities <- function(df, .query){ - if(!is.null(.query$fields) & pour("package", "run_checks")){ + if(!is.null(.query$fields) & + pour("package", "run_checks", .pkg = "galah") & + pour("atlas", "region", .pkg = "galah") %in% c("Australia", "Spain", "Sweden") + # NOTE: last line included because the remaining atlases use different + # architecture which tends to mean queries are sent with non-DwC terms, + # but return DwC terms. This triggers warnings that are technically + # correct, but practically misleading. + ){ # get basic info n_fields <- length(.query$fields) field_names <- colnames(df) diff --git a/R/collect_metadata.R b/R/collect_metadata.R index c873fb81..6648697d 100644 --- a/R/collect_metadata.R +++ b/R/collect_metadata.R @@ -243,8 +243,10 @@ collect_lists <- function(.query){ pluck("lists") |> bind_rows() } - result <- result |> - rename("species_list_uid" = "dataResourceUid") + if(any(colnames(result) == "dataResourceUid")){ + result <- result |> + rename("species_list_uid" = "dataResourceUid") + } attr(result, "call") <- "lists" attr(result, "region") <- pour("atlas", "region") result diff --git a/R/collect_unnest.R b/R/collect_unnest.R index a57cf288..b9374384 100644 --- a/R/collect_unnest.R +++ b/R/collect_unnest.R @@ -42,16 +42,20 @@ collect_fields_unnest <- function(.query, error_call = caller_env()){ bind_rows() # extract unformatted facet values - result <- result |> - mutate( - field_value = stringr::str_extract( - result$i18nCode, - "(?<=\\.).*" # everything after . + if(nrow(result) > 0){ + result <- result |> + mutate( + field_value = stringr::str_extract( + result$i18nCode, + "(?<=\\.).*" # everything after . ) ) - - colnames(result)[which(colnames(result) == "field_value")[1]] <- facet - select(result, {{facet}}) + + colnames(result)[which(colnames(result) == "field_value")[1]] <- facet + select(result, {{facet}}) + }else{ # i.e. catch empty results + result + } } } diff --git a/R/sysdata.rda b/R/sysdata.rda index a50a7612384d719eb57dd418bfc631c463d90dbd..576532e2f5f7bbe1e6da0a03b7e77b53aa9f67ef 100644 GIT binary patch literal 17778 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 diff --git a/R/utilities_internal.R b/R/utilities_internal.R index 212e7d04..0c5ec978 100644 --- a/R/utilities_internal.R +++ b/R/utilities_internal.R @@ -196,6 +196,14 @@ default_columns <- function() { "occurrence_date", "occurrence_status", "data_resource_uid"), + "Kew" = c("id", + "taxon_name", + "taxon_concept_lsid", + "latitude", + "longitude", + "occurrence_date", + "occurrence_status", + "data_resource_uid"), "Portugal" = c("id", "taxon_name", "taxon_concept_lsid", @@ -240,11 +248,11 @@ image_fields <- function() { "Australia" = c("multimedia", "images", "sounds", "videos"), "Brazil" = "all_image_url", "Guatemala" = "all_image_url", + "Kew" = "all_image_url", "Portugal" = "all_image_url", "Spain" = c("multimedia", "images", "sounds", "videos"), "Sweden" = c("multimedia", "images", "videos", "sounds"), "United Kingdom" = "all_image_url" - # Guatemala ? ) } diff --git a/data-raw/node_config.csv b/data-raw/node_config.csv index 826c9987..c30e0b3f 100644 --- a/data-raw/node_config.csv +++ b/data-raw/node_config.csv @@ -73,17 +73,17 @@ Estonia,metadata/fields-unnest,https://elurikkus.ee/biocache-service/occurrence/ Estonia,metadata/providers,https://elurikkus.ee/collectory/ws/dataProvider,TRUE Estonia,metadata/taxa-single,https://elurikkus.ee/bie-index/search?q={name}&pageSize=5,TRUE Estonia,metadata/taxa-unnest,https://elurikkus.ee/bie-index/childConcepts/{id},TRUE -Flanders,data/metadata/fields-unnest,https://natuurdata.inbo.be/biocache-service/occurrence/facets,TRUE Flanders,data/occurrences,https://natuurdata.inbo.be/biocache-service/occurrences/offline/download,TRUE Flanders,data/occurrences-count,https://natuurdata.inbo.be/biocache-service/occurrences/search,TRUE Flanders,data/occurrences-count-groupby,https://natuurdata.inbo.be/biocache-service/occurrence/facets,TRUE Flanders,data/species,https://natuurdata.inbo.be/biocache-service/occurrences/facets/download,TRUE -Flanders,data/species-count-,https://natuurdata.inbo.be/biocache-service/occurrence/facets,TRUE +Flanders,data/species-count,https://natuurdata.inbo.be/biocache-service/occurrence/facets,TRUE Flanders,files/media,https://natuurdata.inbo.be/image-service/ws/image/{id}/original,TRUE Flanders,metadata/assertions,https://natuurdata.inbo.be/biocache-service/assertions/codes,TRUE Flanders,metadata/collections,https://natuurdata.inbo.be/collectory/ws/collection,TRUE Flanders,metadata/datasets,https://natuurdata.inbo.be/collectory/ws/dataResource,TRUE Flanders,metadata/fields,https://natuurdata.inbo.be/biocache-service/index/fields,TRUE +Flanders,metadata/fields-unnest,https://natuurdata.inbo.be/biocache-service/occurrence/facets,TRUE Flanders,metadata/licences,https://natuurdata.inbo.be/image-service/ws/licence,TRUE Flanders,metadata/lists,https://natuurdata.inbo.be/species-list/ws/speciesList/,TRUE Flanders,metadata/lists-unnest,https://natuurdata.inbo.be/species-list/ws/speciesListItems/{list_id},TRUE @@ -134,6 +134,25 @@ Guatemala,metadata/providers,https://snib.conap.gob.gt/colecciones/ws/dataProvid Guatemala,metadata/reasons,https://snib.conap.gob.gt/logger/service/logger/reasons,TRUE Guatemala,metadata/taxa-single,https://snib.conap.gob.gt/especies-ws/search?q={name}&pageSize=5,TRUE Guatemala,metadata/taxa-unnest,https://snib.conap.gob.gt/especies-ws/childConcepts/{id},TRUE +Kew,data/occurrences,https://records-ws.data.kew.org/occurrences/offline/download,TRUE +Kew,data/occurrences-count,https://records-ws.data.kew.org/occurrences/search,TRUE +Kew,data/occurrences-count-groupby,https://records-ws.data.kew.org/occurrences/facets,TRUE +Kew,data/species,https://records-ws.data.kew.org/occurrences/facets/download,TRUE +Kew,data/species-count,https://records-ws.data.kew.org/occurrences/facets,TRUE +Kew,files/media,https://images.data.kew.org/ws/image/{id}/original,TRUE +Kew,metadata/assertions,https://records-ws.data.kew.org/assertions/codes,TRUE +Kew,metadata/collections,https://collections.data.kew.org/ws/collection,TRUE +Kew,metadata/datasets,https://collections.data.kew.org/ws/dataResource,TRUE +Kew,metadata/fields,https://records-ws.data.kew.org/index/fields,TRUE +Kew,metadata/fields-unnest,https://records-ws.data.kew.org/occurrences/facets,TRUE +Kew,metadata/licences,https://images.data.kew.org/ws/licence,TRUE +Kew,metadata/lists,https://lists.data.kew.org/ws/speciesList,TRUE +Kew,metadata/lists-unnest,https://lists.data/kew.org/ws/speciesListItems/{list_id},TRUE +Kew,metadata/media,https://images.data.kew.org/ws/getImageInfoForIdList,TRUE +Kew,metadata/providers,https://collections.data.kew.org/ws/dataProvider,TRUE +Kew,metadata/reasons,https://logger.data.kew.org/service/logger/reasons,TRUE +Kew,metadata/taxa-single,https://species-ws.data.kew.org/search?q={name}&pageSize=5,TRUE +Kew,metadata/taxa-unnest,https://species-ws.data.kew.org/childConcepts/{id},TRUE Portugal,data/occurrences,https://registos-ws.gbif.pt/occurrences/offline/download,FALSE Portugal,data/occurrences-count,https://registos-ws.gbif.pt/occurrences/search,TRUE Portugal,data/occurrences-count-groupby,https://registos-ws.gbif.pt/occurrence/facets,TRUE @@ -213,4 +232,4 @@ United Kingdom,metadata/media,https://images.nbnatlas.org/ws/getImageInfoForIdLi United Kingdom,metadata/providers,https://registry.nbnatlas.org/ws/dataProvider,TRUE United Kingdom,metadata/reasons,https://logger.nbnatlas.org/service/logger/reasons,TRUE United Kingdom,metadata/taxa-single,https://species-ws.nbnatlas.org/search?q={name}&pageSize=5,TRUE -United Kingdom,metadata/taxa-unnest,https://species-ws.nbnatlas.org/childConcepts/{id},TRUE +United Kingdom,metadata/taxa-unnest,https://species-ws.nbnatlas.org/childConcepts/{id},TRUE \ No newline at end of file diff --git a/data-raw/node_metadata.csv b/data-raw/node_metadata.csv index 67aceaeb..583498cb 100644 --- a/data-raw/node_metadata.csv +++ b/data-raw/node_metadata.csv @@ -2,10 +2,11 @@ region,institution,acronym,url,supported Australia,Atlas of Living Australia,ALA,https://www.ala.org.au,TRUE Austria,Biodiversitäts-Atlas Österreich,BAO,https://biodiversityatlas.at,TRUE Brazil,Sistemas de Informações sobre a Biodiversidade Brasileira,SiBBr,https://sibbr.gov.br,TRUE -Flanders,Vlaams Biodiversiteitsportaal,VBP,https://natuurdata.inbo.be,FALSE +Flanders,Vlaams Biodiversiteitsportaal,VBP,https://natuurdata.inbo.be,TRUE France,Portail français d'accès aux données d'observation sur les espèces,OpenObs,https://openobs.mnhn.fr,TRUE Global,Global Biodiversity Information Facility,GBIF,https://gbif.org,TRUE Guatemala,Sistema Nacional de Información sobre Diversidad Biológica de Guatemala,SNIBgt,https://snib.conap.gob.gt,TRUE +Kew,Kew Data Portal,KDP,https://data.kew.org,TRUE Portugal,GBIF Portugal,GBIF.pt,https://www.gbif.pt,TRUE Spain,GBIF Spain,GBIF.es,https://gbif.es,TRUE Sweden,Swedish Biodiversity Data Infrastructure,SBDI,https://biodiversitydata.se,TRUE @@ -21,4 +22,4 @@ Norway,Living Norway,LN,https://data.livingnorway.no/,FALSE Pacific,Pacific Biodiversity Information Facility,PBIF,https://pbif.sprep.org/,FALSE South Africa,South African National Biodiversity Institute,SANBI-GBIF,https://www.sanbi-gbif.org/,FALSE United States and Territories,GBIF US,GBIF.us,https://gbif.us/,FALSE -Vermont,Vermont Atlas of Life,VAL,https://val.vtecostudies.org,FALSE +Vermont,Vermont Atlas of Life,VAL,https://val.vtecostudies.org,FALSE \ No newline at end of file diff --git a/tests/testthat/test-international-Kew.R b/tests/testthat/test-international-Kew.R new file mode 100644 index 00000000..20869c6e --- /dev/null +++ b/tests/testthat/test-international-Kew.R @@ -0,0 +1,243 @@ +# set verbose to off +galah_config(verbose = FALSE, run_checks = FALSE) + +test_that("swapping to atlas = Kew works", { + expect_message(galah_config(atlas = "Kew", + email = "ala4r@ala.org.au", + password = "galah-Kew-test-login")) +}) + +test_that("show_all(fields) works for Kew", { + skip_if_offline(); skip_on_ci() + x <- show_all(fields) |> + try(silent = TRUE) + skip_if(inherits(x, "try-error"), message = "API not available") + expect_gt(nrow(x), 1) + expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) + # also that fields match those returned by default_columns() + y <- default_columns() # internal function called by `galah_select()` + expect_true(all(y %in% x$id)) +}) + +test_that("show_all(licences) works for Kew", { + # NOTE: When tested on 2025-07-04 this API works, but contains no data + skip_if_offline(); skip_on_ci() + x <- show_all(licences) |> + try(silent = TRUE) + skip_if(inherits(x, "try-error"), message = "API not available") + expect_gte(nrow(x), 0) + expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) +}) + +test_that("show_all(collections) works for Kew", { + skip_if_offline(); skip_on_ci() + x <- show_all(collections, limit = 10) |> + try(silent = TRUE) + skip_if(inherits(x, "try-error"), message = "API not available") + expect_lte(nrow(x), 10) + expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) +}) + +test_that("show_all(datasets) works for Kew", { + skip_if_offline(); skip_on_ci() + x <- show_all(datasets, limit = 10) |> + try(silent = TRUE) + skip_if(inherits(x, "try-error"), message = "API not available") + expect_lte(nrow(x), 10) + expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) +}) + +test_that("show_all(providers) works for Kew", { + skip_if_offline(); skip_on_ci() + x <- show_all(providers, limit = 10) |> + try(silent = TRUE) + skip_if(inherits(x, "try-error"), message = "API not available") + expect_lte(nrow(x), 10) + expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) +}) + +test_that("show_all(reasons) works for Kew", { + skip_if_offline(); skip_on_ci() + x <- show_all(reasons) |> + try(silent = TRUE) + skip_if(inherits(x, "try-error"), message = "API not available") + expect_gt(nrow(x), 1) + expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) +}) + +test_that("show_all(assertions) works for Kew", { + skip_if_offline(); skip_on_ci() + x <- show_all(assertions) |> + try(silent = TRUE) + skip_if(inherits(x, "try-error"), message = "API not available") + expect_gt(nrow(x), 1) + expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) +}) + +test_that("show_all(profiles) unavailable for Kew", { + show_all(profiles) |> + expect_error(label = "No API is available for type `metadata/profiles`") +}) + +test_that("show_all(lists) works for Kew", { + # NOTE: When tested on 2025-07-04 this API works, but contains no data + skip_if_offline(); skip_on_ci() + x <- show_all(lists, limit = 10) |> + try(silent = TRUE) + skip_if(inherits(x, "try-error"), message = "API not available") + expect_lte(nrow(x), 10) + expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) +}) + +test_that("search_all(fields) works for Kew", { + skip_if_offline(); skip_on_ci() + x <- search_all(fields, "year") |> + try(silent = TRUE) + skip_if(inherits(x, "try-error"), message = "API not available") + expect_gte(nrow(x), 1) + expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) +}) + +test_that("search_all(taxa) works for Kew", { + skip_if_offline(); skip_on_ci() + x <- search_all(taxa, "Acer") |> + try(silent = TRUE) + skip_if(inherits(x, "try-error"), message = "API not available") + expect_gte(nrow(x), 1) + expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) +}) + +test_that("search_all(identifiers) unavailable for Kew", { + x <- search_all(identifiers, "359") |> + expect_error(label = "No API is available for type `metadata/identifiers`") +}) + +## Currently failing; API exists but doesn't return data +## Server-side problem? +# test_that("show_values works for fields for Kew", { +# skip_if_offline(); skip_on_ci() +# x <- search_all(fields, "basis_of_record") |> +# show_values() |> +# try(silent = TRUE) +# skip_if(inherits(x, "try-error"), message = "API not available") +# expect_gte(nrow(x), 1) +# expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) +# }) + +test_that("atlas_counts works for Kew", { + skip_if_offline(); skip_on_ci() + x <- atlas_counts() |> + pull(count) |> + try(silent = TRUE) + skip_if(inherits(x, "try-error"), message = "API not available") + expect_gt(x, 0) +}) + +## Currently failing: see `show_values()` comment above +## which calls the same API +# test_that("atlas_counts works with type = 'species' for Kew", { +# skip_if_offline(); skip_on_ci() +# x <- atlas_counts(type = "species") |> +# pull(count) |> +# try(silent = TRUE) +# skip_if(inherits(x, "try-error"), message = "API not available") +# expect_gt(x, 0) +# }) + +test_that("`atlas_counts()` works with `identify()` for Kew", { + skip_if_offline(); skip_on_ci() + result <- galah_call() |> + identify("Acer") |> + count() |> + collect() |> + try(silent = TRUE) + skip_if(inherits(result, "try-error"), message = "API not available") + expect_gt(result$count, 1) + result2 <- galah_call() |> + filter(genus == "Acer") |> + count() |> + collect() |> + try(silent = TRUE) + skip_if(inherits(result2, "try-error"), message = "API not available") + expect_lt( + sqrt((result2$count - result$count)^2) / result$count, + 0.1) # i.e. <1% margin of error +}) + +## Again, facetting not functional yet +# test_that("`atlas_counts()` works with `group_by()` for Kew", { +# skip_if_offline(); skip_on_ci() +# result <- galah_call() |> +# filter(year >= 2000) |> +# group_by(basis_of_record) |> +# count() |> +# collect() |> +# try(silent = TRUE) +# skip_if(inherits(result, "try-error"), message = "API not available") +# expect_gt(nrow(result), 1) +# expect_equal(names(result), c("basis_of_record", "count")) +# }) + +test_that("`atlas_species()` works for Kew", { + skip_if_offline(); skip_on_ci() + galah_config( + atlas = "Kew", + email = "ala4r@ala.org.au", + send_email = FALSE) + spp <- galah_call() |> + identify("Pinus") |> + atlas_species() |> + try(silent = TRUE) + skip_if(inherits(spp, "try-error"), message = "API not available") + expect_gt(nrow(spp), 20) + expect_equal(ncol(spp), 10) # Note: some atlasses have 11 cols + expect_s3_class(spp, c("tbl_df", "tbl", "data.frame")) +}) + +test_that("`atlas_occurrences()` works for Kew", { + skip_if_offline(); skip_on_ci() + galah_config( + atlas = "Kew", + email = "ala4r@ala.org.au", + download_reason_id = 10, + send_email = FALSE) + occ <- galah_call() |> + identify("Acer") |> + filter(year >= 1999) |> + select(species, year) |> + atlas_occurrences() |> + try(silent = TRUE) + skip_if(inherits(occ, "try-error"), message = "API not available") + expect_gt(nrow(occ), 0) + expect_equal(ncol(occ), 2) + expect_false(any(occ$year < 1999)) + expect_true(inherits(occ, c("tbl_df", "tbl", "data.frame"))) +}) + +test_that("`atlas_media()` works for Kew", { + skip_if_offline(); skip_on_ci() + galah_config( + atlas = "Kew", + email = "test@ala.org.au", + download_reason_id = 10, + directory = "temp", + send_email = FALSE) + x <- request_data() |> + identify("Acer") |> + filter(year >= 2020) |> + atlas_media() |> + try(silent = TRUE) + skip_if(inherits(x, "try-error"), message = "API not available") + expect_s3_class(x, c("tbl_df", "tbl", "data.frame")) + expect_gte(nrow(x), 1) + expect_equal(colnames(x)[1:2], + c("media_id", "recordID")) + # download a subset + n_downloads <- 5 + collect_media(x[seq_len(n_downloads), ]) + expect_equal(length(list.files("temp", pattern = ".jpg$")), + n_downloads) + unlink("temp", recursive = TRUE) +}) + +galah_config(atlas = "Australia") From c6edf7c7ae4f6910b340bea1d10ac31eccc3e2ee Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Mon, 7 Jul 2025 16:24:02 +1000 Subject: [PATCH 05/94] Support updated taxonomic queries for GBIF (#272) `search_identifiers()` added; `search_taxa()` now accepts (and uses) multiple fields --- R/collapse_metadata.R | 27 +++++++++------ R/collapse_taxa.R | 42 ++++++++++++++++++++++- R/collect_taxa.R | 12 ++++++- R/search_all.R | 2 +- R/sysdata.rda | Bin 17778 -> 17800 bytes data-raw/node_config.csv | 5 ++- tests/testthat/test-international-GBIF.R | 20 +++++++++++ 7 files changed, 94 insertions(+), 14 deletions(-) diff --git a/R/collapse_metadata.R b/R/collapse_metadata.R index adca405a..579b25eb 100644 --- a/R/collapse_metadata.R +++ b/R/collapse_metadata.R @@ -126,19 +126,26 @@ collapse_identifiers <- function(.query){ url_list <- url_lookup("metadata/identifiers") names(url_list) <- "no-name-supplied" }else{ - base_url <- url_lookup("metadata/identifiers") |> - url_parse() search_terms <- .query$filter$value query <- as.list(search_terms) # create query urls - urls <- lapply(query, - function(a, base_url){ - names(a) <- "taxonID" - base_url$query <- as.list(a) - url_build(base_url) - }, - base_url = base_url) |> - unlist() + if(is_gbif()){ + base_url <- url_lookup("metadata/identifiers") |> + utils::URLdecode() + urls <- glue::glue(base_url, id = query) |> + unlist() + }else{ + base_url <- url_lookup("metadata/identifiers") |> + url_parse() + urls <- lapply(query, + function(a, base_url){ + names(a) <- "taxonID" + base_url$query <- as.list(a) + url_build(base_url) + }, + base_url = base_url) |> + unlist() + } } # build object and return result <- list(type = "metadata/identifiers", diff --git a/R/collapse_taxa.R b/R/collapse_taxa.R index 0388ff55..a72e05fe 100644 --- a/R/collapse_taxa.R +++ b/R/collapse_taxa.R @@ -37,7 +37,15 @@ collapse_taxa_single <- function(.query){ #' @noRd #' @keywords Internal collapse_taxa_multiple <- function(.query){ - split_list <- split(.query$identify, seq_len(nrow(.query$identify))) + # get a data.frame, enforce use of accepted taxon levels + identify_df <- .query$identify + colnames(identify_df) <- tolower(colnames(identify_df)) + identify_df <- identify_df |> + dplyr::select(dplyr::any_of(accepted_ranks())) + + # split into one query per row + split_list <- split(.query$identify, + seq_len(nrow(.query$identify))) base_url <- url_lookup("metadata/taxa-multiple") |> url_parse() urls <- lapply(split_list, @@ -50,6 +58,7 @@ collapse_taxa_multiple <- function(.query){ search_terms <- lapply(split_list, function(a){paste(a, collapse = "_")}) |> unlist() + # build object and return result <- list(type = "metadata/taxa-multiple", url = tibble(url = urls, @@ -58,3 +67,34 @@ collapse_taxa_multiple <- function(.query){ class(result) <- "query" return(result) } + +#' Internal function to accept only specific taxon ranks for searching +#' @noRd +#' @keywords Internal +accepted_ranks <- function(){ + if(is_gbif()){ + # https://techdocs.gbif.org/en/openapi/v1/species#/Searching%20names/matchNames + c("kingdom", + "phylum", + "class", + "order", + "superfamily", + "family", + "subfamily", + "tribe", + "subtribe", + "genus", + "species") + }else{ + # https://docs.ala.org.au/openapi/index.html?urls.primaryName=namematching#/Taxonomy%20search/match_2 + c("kingdom", + "phylum", + "class", + "order", + "superfamily", + "family", + "genus", + "specificEpithet", + "infraspecificEpithet") + } +} diff --git a/R/collect_taxa.R b/R/collect_taxa.R index ad9f75b4..95634138 100644 --- a/R/collect_taxa.R +++ b/R/collect_taxa.R @@ -169,10 +169,19 @@ collect_identifiers <- function(.query){ result <- query_API(.query) |> flat_lists_only() |> bind_rows() + if(any(colnames(result) == "taxonConceptID")){ result <- result |> filter(!duplicated(result$taxonConceptID)) } + + if(is_gbif()){ + result$success <- TRUE + result <- result |> + relocate(success, .before = 1) |> + rename("taxonConceptID" = "key") + } + result <- result |> mutate("search_term" = search_terms, .before = "success") @@ -182,7 +191,8 @@ collect_identifiers <- function(.query){ } names(result) <- rename_columns(names(result), type = "taxa") # old code - result |> select(any_of(wanted_columns("taxa"))) + result <- result |> + select(any_of(wanted_columns("taxa"))) attr(result, "call") <- "identifiers" attr(result, "region") <- pour("atlas", "region") result diff --git a/R/search_all.R b/R/search_all.R index 1cc5c203..f47e4470 100644 --- a/R/search_all.R +++ b/R/search_all.R @@ -124,7 +124,7 @@ search_all <- function(type, query){ if(type == "taxa"){ check_if_in_pipe(query) request_metadata(type = "taxa") |> - identify(query[[1]]) |> + identify(query) |> collect() }else if(type == "identifiers"){ request_metadata() |> diff --git a/R/sysdata.rda b/R/sysdata.rda index 576532e2f5f7bbe1e6da0a03b7e77b53aa9f67ef..4741a2781124e72711fbc63e2482571642b75f6c 100644 GIT binary patch delta 17729 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 MkxmpO6Z)WDfXD`CL;wH) delta 17713 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 diff --git a/data-raw/node_config.csv b/data-raw/node_config.csv index c30e0b3f..7fc1c1df 100644 --- a/data-raw/node_config.csv +++ b/data-raw/node_config.csv @@ -109,12 +109,15 @@ France,metadata/providers,https://openobs.mnhn.fr/api/datasets/providers,TRUE France,metadata/taxa-single,https://taxref.mnhn.fr/api/taxa/search?scientificNames={name}&page=1&size=5,TRUE France,metadata/taxa-unnest,https://taxref.mnhn.fr/api/taxa/{id}/children,TRUE Global,data/occurrences,https://api.gbif.org/v1/occurrence/download/request,TRUE -Global,data/occurrences-count,https://api.gbif.org/v1/occurrence/search,TRUE +Global,data/occurrences-count,https://api.gbif.org/v1/occurrence/search/predicate,TRUE +Global,data/occurrences-count-groupby,https://api.gbif.org/v1/occurrence/search/predicate,TRUE Global,metadata/collections,https://api.gbif.org/v1/grscicoll/collection,TRUE Global,metadata/datasets,https://api.gbif.org/v1/dataset,TRUE Global,metadata/fields-unnest,https://api.gbif.org/v1/occurrence/search,TRUE +Global,metadata/identifiers,https://api.gbif.org/v1/species/{id},TRUE Global,metadata/providers,https://api.gbif.org/v1/organization,TRUE Global,metadata/taxa-single,https://api.gbif.org/v1/species/match?verbose=FALSE&name={name},TRUE +Global,metadata/taxa-multiple,https://api.gbif.org/v1/species/match,TRUE Global,metadata/taxa-unnest,https://api.gbif.org/v1/species/{id}/children,TRUE Global,,https://api.gbif.org/v1/grscicoll/search,TRUE Global,,https://api.gbif.org/v1/dataset/search,TRUE diff --git a/tests/testthat/test-international-GBIF.R b/tests/testthat/test-international-GBIF.R index 43351357..d100e09e 100644 --- a/tests/testthat/test-international-GBIF.R +++ b/tests/testthat/test-international-GBIF.R @@ -84,6 +84,26 @@ test_that("search_all(taxa) works for GBIF", { expect_true(x$class == "Mammalia") }) +test_that("search_all(taxa) works using data.frames for GBIF", { + skip_if_offline(); skip_on_ci() + x <- search_all(taxa, + data.frame(kingdom = "Animalia", + phylum = "Chordata")) |> + try(silent = TRUE) + skip_if(inherits(x, "try-error"), message = "API not available") + expect_gte(nrow(x), 1) + expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) +}) + +test_that("search_all(identifiers) works for GBIF", { + skip_if_offline(); skip_on_ci() + x <- search_all(identifiers, "359") |> + try(silent = TRUE) + skip_if(inherits(x, "try-error"), message = "API not available") + expect_gte(nrow(x), 1) + expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) +}) + galah_config(verbose = TRUE) test_that("search_all(datasets) works for GBIF", { From dcf891f44b5b77f3b50dddede880914452a28b39 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Tue, 8 Jul 2025 13:53:26 +1000 Subject: [PATCH 06/94] light restructure ahead of GBIF predicates revision --- R/{quosure_handling.R => handle_quosures.R} | 183 ++++++++---------- ...ata_request.R => handle_request_objects.R} | 34 ++++ 2 files changed, 113 insertions(+), 104 deletions(-) rename R/{quosure_handling.R => handle_quosures.R} (92%) rename R/{update_data_request.R => handle_request_objects.R} (73%) diff --git a/R/quosure_handling.R b/R/handle_quosures.R similarity index 92% rename from R/quosure_handling.R rename to R/handle_quosures.R index 6e10178c..3dcdf397 100644 --- a/R/quosure_handling.R +++ b/R/handle_quosures.R @@ -2,39 +2,8 @@ # related functions. Note that the approach used below is taken from advanced R: # https://adv-r.hadley.nz/expressions.html -#' This should parse out a request object and return quosures thereafter -#' @importFrom rlang eval_tidy -#' @importFrom rlang get_expr -#' @importFrom rlang quo_get_expr -#' @importFrom stringr str_detect -#' @noRd -#' @keywords Internal -detect_request_object <- function(dots){ - if (length(dots) > 0) { - call_string <- get_expr(dots)[[1]] |> - quo_get_expr() |> - deparse() |> - paste(collapse = " ") # captures multi-lines - # note: no leading "^" below, - # because pipes can parse to e.g. `galah_identify(galah_call(...` - types <- c( - "galah_call\\(", - "request_data\\(", - "request_metadata\\(", - "request_files\\(", - "^~.$", - "^.$") |> - paste(collapse = "|") - if (str_detect(call_string, types)) { # note: "~." or "." indicate presence of the magrittr pipe (%>%) - eval_request <- eval_tidy(dots[[1]]) - c(list(eval_request), dots[-1]) - }else{ - dots - } - }else{ - NULL - } -} +## -- Top-level parsers -- ## +# These are called 'first' by `galah_` functions #' parse quosures for objects of class `data_request` #' @importFrom dplyr bind_rows @@ -61,6 +30,80 @@ parse_quosures_data <- function(dots){ result } +#' parse quosures, but for `select` and related functions +#' +#' Major difference here is there is no need for parsing; simply return +#' stuff that is a named object +#' @importFrom rlang abort +#' @importFrom rlang eval_tidy +#' @importFrom rlang quo_get_expr +#' @noRd +#' @keywords internal +parse_quosures_basic <- function(dots){ + if(length(dots) > 0){ + parsed_dots <- lapply(dots, function(a){ + switch(expr_type(a), + "symbol" = {parse_symbol(a)}, + "call" = {eval_tidy(a)}, + "literal" = {quo_get_expr(a)}, + abort("Quosure type not recognised.")) + }) + unlist(parsed_dots) + }else{ + NULL + } +} + +#' parse quosures, but for `filter.files_request()` where we expect large amounts +#' of data to be supplied +#' @importFrom rlang as_label +#' @importFrom rlang as_string +#' @importFrom rlang is_quosure +#' @importFrom rlang f_lhs +#' @importFrom rlang f_rhs +#' @importFrom rlang quo_get_env +#' @importFrom rlang quo_get_expr +#' @importFrom tibble tibble +#' @noRd +#' @keywords internal +parse_quosures_files <- function(dots){ + if(length(dots) > 0){ + check_named_input(dots) + dot_expr <- quo_get_expr(dots[[1]]) # i.e. only first entry is available + # get formula lhs + lhs <- f_lhs(dot_expr) + if(is_quosure(lhs)){ + lhs <- quo_get_expr(lhs) + } + lhs <- as_string(lhs) |> dequote() + # get rhs + x <- new_quosure(f_rhs(dot_expr), env = quo_get_env(dots[[1]])) + rhs <- switch(expr_type(x), + "call" = {eval_tidy(x)}, + "symbol" = {if(exists(quo_get_expr(x), + where = quo_get_env(x))){ + eval_tidy(x) + }else{ + as_label(x) + }}, + "literal" = {quo_get_expr(x)}, + abort("Quosure type not recognised.")) + if(inherits(rhs, "data.frame")){ + list(variable = dequote(lhs), data = rhs) + }else{ + tibble( + variable = dequote(lhs), + logical = "==", + value = rhs) + } + }else{ + NULL + } +} + +## -- `data.frame` cleaning -- ## +# This is for cleaning the 'default' output from parsers + #' Function to ensure assertions are placed first in a query #' This is important so that they are parsed correctly #' Note this only gets triggered for AND statements - OR is handled earlier @@ -122,77 +165,6 @@ clean_logical_statements <- function(df){ df } -#' parse quosures, but for `select` and related functions -#' -#' Major difference here is there is no need for parsing; simply return -#' stuff that is a named object -#' @importFrom rlang abort -#' @importFrom rlang eval_tidy -#' @importFrom rlang quo_get_expr -#' @noRd -#' @keywords internal -parse_quosures_basic <- function(dots){ - if(length(dots) > 0){ - parsed_dots <- lapply(dots, function(a){ - switch(expr_type(a), - "symbol" = {parse_symbol(a)}, - "call" = {eval_tidy(a)}, - "literal" = {quo_get_expr(a)}, - abort("Quosure type not recognised.")) - }) - unlist(parsed_dots) - }else{ - NULL - } -} - -#' parse quosures, but for `filter.files_request()` where we expect large amounts -#' of data to be supplied -#' @importFrom rlang as_label -#' @importFrom rlang as_string -#' @importFrom rlang is_quosure -#' @importFrom rlang f_lhs -#' @importFrom rlang f_rhs -#' @importFrom rlang quo_get_env -#' @importFrom rlang quo_get_expr -#' @importFrom tibble tibble -#' @noRd -#' @keywords internal -parse_quosures_files <- function(dots){ - if(length(dots) > 0){ - check_named_input(dots) - dot_expr <- quo_get_expr(dots[[1]]) # i.e. only first entry is available - # get formula lhs - lhs <- f_lhs(dot_expr) - if(is_quosure(lhs)){ - lhs <- quo_get_expr(lhs) - } - lhs <- as_string(lhs) |> dequote() - # get rhs - x <- new_quosure(f_rhs(dot_expr), env = quo_get_env(dots[[1]])) - rhs <- switch(expr_type(x), - "call" = {eval_tidy(x)}, - "symbol" = {if(exists(quo_get_expr(x), - where = quo_get_env(x))){ - eval_tidy(x) - }else{ - as_label(x) - }}, - "literal" = {quo_get_expr(x)}, - abort("Quosure type not recognised.")) - if(inherits(rhs, "data.frame")){ - list(variable = dequote(lhs), data = rhs) - }else{ - tibble( - variable = dequote(lhs), - logical = "==", - value = rhs) - } - }else{ - NULL - } -} - #' Internal function to remove quoting of variable names #' Used as quotes are added when lhs is set using {{}} #' @noRd @@ -201,6 +173,9 @@ dequote <- function(x){ gsub("^\"|\"$", "", x) } + +## -- Quosure parsing -- ## + #' Switch functions for quosures #' @param x A (single) quosure #' @importFrom rlang abort diff --git a/R/update_data_request.R b/R/handle_request_objects.R similarity index 73% rename from R/update_data_request.R rename to R/handle_request_objects.R index 864652ff..3de50ae5 100644 --- a/R/update_data_request.R +++ b/R/handle_request_objects.R @@ -1,3 +1,37 @@ +#' This should parse out a request object and return quosures thereafter +#' @importFrom rlang eval_tidy +#' @importFrom rlang get_expr +#' @importFrom rlang quo_get_expr +#' @importFrom stringr str_detect +#' @noRd +#' @keywords Internal +detect_request_object <- function(dots){ + if (length(dots) > 0) { + call_string <- get_expr(dots)[[1]] |> + quo_get_expr() |> + deparse() |> + paste(collapse = " ") # captures multi-lines + # note: no leading "^" below, + # because pipes can parse to e.g. `galah_identify(galah_call(...` + types <- c( + "galah_call\\(", + "request_data\\(", + "request_metadata\\(", + "request_files\\(", + "^~.$", + "^.$") |> + paste(collapse = "|") + if (str_detect(call_string, types)) { # note: "~." or "." indicate presence of the magrittr pipe (%>%) + eval_request <- eval_tidy(dots[[1]]) + c(list(eval_request), dots[-1]) + }else{ + dots + } + }else{ + NULL + } +} + #' Internal function to update a `data_request` #' @noRd #' @keywords Internal From aef3a8db47a1c29992c130c682c6ba591d45affa Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Thu, 10 Jul 2025 11:58:18 +1000 Subject: [PATCH 07/94] First pass at getting Flanders APIs running (#256) --- R/build.R | 12 +- R/collapse_metadata.R | 9 +- R/collect_taxa.R | 3 +- R/sysdata.rda | Bin 17800 -> 17808 bytes R/utilities_internal.R | 155 +++--- data-raw/node_config.csv | 12 +- tests/testthat/test-international-Flanders.R | 540 +++++++++---------- 7 files changed, 351 insertions(+), 380 deletions(-) diff --git a/R/build.R b/R/build.R index d819390c..41ddda02 100644 --- a/R/build.R +++ b/R/build.R @@ -67,11 +67,13 @@ build_query <- function(identify = NULL, query$wkt <- location } } - # add profiles information (ALA only) - if(!is.null(data_profile)) { - query$qualityProfile <- data_profile - } else { - query$disableAllQualityFilters <- "true" + # add profiles information (ALA only) + if(profiles_supported()){ + if(!is.null(data_profile)) { + query$qualityProfile <- data_profile + } else { + query$disableAllQualityFilters <- "true" + } } build_single_fq(query) } diff --git a/R/collapse_metadata.R b/R/collapse_metadata.R index 579b25eb..4c371d49 100644 --- a/R/collapse_metadata.R +++ b/R/collapse_metadata.R @@ -128,15 +128,14 @@ collapse_identifiers <- function(.query){ }else{ search_terms <- .query$filter$value query <- as.list(search_terms) + base_url <- url_lookup("metadata/identifiers") # create query urls - if(is_gbif()){ - base_url <- url_lookup("metadata/identifiers") |> - utils::URLdecode() + if(grepl("api.gbif.org", base_url)){ + base_url <- utils::URLdecode(base_url) urls <- glue::glue(base_url, id = query) |> unlist() }else{ - base_url <- url_lookup("metadata/identifiers") |> - url_parse() + base_url <- url_parse(base_url) urls <- lapply(query, function(a, base_url){ names(a) <- "taxonID" diff --git a/R/collect_taxa.R b/R/collect_taxa.R index 95634138..c0b0f30d 100644 --- a/R/collect_taxa.R +++ b/R/collect_taxa.R @@ -175,7 +175,8 @@ collect_identifiers <- function(.query){ filter(!duplicated(result$taxonConceptID)) } - if(is_gbif()){ + if(!any(colnames(result) == "success")){ # GBIF doesn't indicate success + # we avoid `is_gbif()` here because other atlases use GBIF APIs result$success <- TRUE result <- result |> relocate(success, .before = 1) |> diff --git a/R/sysdata.rda b/R/sysdata.rda index 4741a2781124e72711fbc63e2482571642b75f6c..4140fd423a9b0ad1b7f86b8d9b2136b25ee4e114 100644 GIT binary patch literal 17808 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 literal 17800 zcmV)HK)t_0T4*^jL0KkKSx@SLdH`4DfB*mg|NsC0|NsC0|NsC0|NibmK>$SIUI-Wf z01`j}Ovm6?|9yS+xSrb2yFI`F0nh*g*uwkF3Z7%W}8H%wP+*}Oad)@1INo=2AAT)A5`>8{Tn0*etx3t<7HpXp6-Oi5ReB;&cwh~A{fB*;$ zG-)tKCIC$`XaEW5DY9q;!VgsSH>jA8Q`1u?r12094Gl2{ngj$0CW0D_O-z`WRP_%j zw4bUyL8gbK0QCR>00TgNqI!Yq4FCWd0j44$DX80206j*YqttCDrlIP3fMhfPXaE2J z00E!?000005@{kKG$TbZQ`t>CDLm0fk{(FfZBuFuJtwK^9;Sc*003w-4FCWD000pr zlO}*Ev_m7wDt?(yRDYt+3|e)jIhPg6h7s_T8Pv6}pt@71pD%Af|@QX+%K z5fF@3#wvL16d%D5rXS79S6JG525!k<>(E2&)x^0A^%$SD51a(qBN2Iyc7Qn;4*>{> zW?+dUCsfy>y&XBKIw=t_KZXbgA^b9H%{v2m8GqORRC)Y}T>qa5^~O`8=TK*}Flc5S z@7Z*%NA3PKbeY`_4O^;+a-u;6P>~R1L_i||0x>`%1XPegBv1exeg?chi*KKk1wnGb zQSyp-bF9oY=ZEs){zC z&MUklb0L|FZ@jnR__8Jv8k4>Px4rN@_-t8(Ekz`bbEL-jW}459dGsC{S}}}cSc!2AO;j_dX5j>Tl|;U-u{ONk}xvw!P$fZH71eGYZ2 zP``fsl0y5MSz#1c-P4$LuxQiSFwq!F0s zN!3~74t5KZ`kx=E^v?R|949Om1xYIwRxBcbNg)D|fcc*>=jMGSeLjyrI!CH}?P!RD zXo^vql$%jtb@N3Gb> z7#RcO_t6l3kAH8i_9zh4MX)dvXw+OnGhDZR@YZWLQxrF5JioiipVKtPEisg@u#OMw z@53i8RC~S^L_h%fNJI(n>wpsLu#T^#mq8QD+8R3?d18jV=dFe)&()oVogTK@_^*V& z>@jAt(~tpQKnjYp$9FZLm{B>3>=i}#`l}GOHU__9dQCg|^2%Pf^t-%TJAB%?Iks6X4}z|H*?3gtvcDl^moUo8M}PLi zUr+IA@j$QVyJJ)UAEtbcyACU>i?yf#)d3`iTB6L+xd#grKb+l#y&$peh9>?8*?k3b z_P*fW;C5Wmo#?hx#Hn(KHFsxNaQJv~caX8;M>D-ap8=V+Oxm$wy2P*NbEH01tN>rK zt}ZNiXJBf8F=ggv%CjO@c99SodVJ5onh!r;$8fL4i$lhkR*Ttb%uyT=&Qwc)j!=Z` z0rz)z#M7yv=0>~7wR2)2*LB{3g=<@PjNEB&sM$WoRWQ8Z_cv{ROubPQ5yWnvrd;pt+%Q#^TzMXA2A-7f@ppx12Q(p?bCAlyp0uL)8A~xY-BSm`9 zW8~PlwY&#DcfR$w)&bYZ;9BdqFNVH}`RAUqpmyXPnM(`}tVjH_+&bPKSMc1Dg(1@ZTH{8Sd+yd$Z$!%=Mi+ zFFseVx6{QfikDh1Z8GD~4z_Vpvd|)m(cg7MlI9&*`{{1+PK4q7cJZxw$klybUt#v4 z&t6&5IUgb8QBGWMrMRsiOwIe6S<3fyeV?%!j+V)kPMjy*&8+45v(MJPRx=^6j)gPR{l^UpKYsx1^zn4(z+`Z-N zXwFp@5!=K5EB6j5?|xP9cpin_>0ld=V>@0a$JIgSU+L>glOSEB6v;r&o!z+NEVid* z5wN`ivb)YRYo>a5VdG{K~oEf^s!s=WQ=S=mqjsD@K%71Zh(am)_2Hp;pG>xW# zs?)Ki?!F^jj8H~Rm8*>?15`PW zAiG_#xM0@{1Vzo+X3;SbEE!6QB;K<3D>SRHVtIOBPq&&36RlfaG_%}zm}uAaafk{9 z@}rSQ3cqtl;`pBe{;k@z`B?_+ppbU63y=rAQ*t)Si^7YLN@!8EJ5UOchIzn1Js6;Z znGit*iXeb@5eP9+7KD+51QqJW38#8eQF1rk62kpe*(0RcbD z5Idlvt91;5GLR5XzLR70JpD))3dyiA1$IoqF;vZTZ6jciju@DU?{TdhgInq&zr0y0 z^Z?vJ9uGrGH4ok-{OQClhl034vry&4tA(i_1P ze4}s3%`bkI126}FMLYdt4wgTB!W4^!;^3m{RhQ;u>w!`^U-I_rj#o`7pr`7+LPtY1 z9^fdHfkyAAQ1Du|kIkC-2Wb|!qus0$3GvV{xZp*^0tmwI#b@!-d)7%Z2e1&Hj!VR6 z7Zlc#o{f5hU^)o6$IsrRts7Bo0PpeNPX2X4rNwmRRXa)KxZ&8rRU}DKaopRCd0aRo zamv7o>;Q}vY-KQ{T@VA+aUhYhstqhI7bEe}#DcWKCs5(z^uFKO1CM*hY-`YSB6vD- z5ILTn^*uwj*W1r=*VN;ejCpD!p1z4@Yq!S!KWGBI)a2PI*kd@@HlmI3 z_O&!wG+5e1m^Z>zdIoaSJRDqoDK8v@eEgAdPk%#HMX(YUP!&qo5b- zWuT^P<<%Z>+qsqsj!ttlN_m>or(*+k)7U)ytxoI;wNU7$%*7FeivTJR*=TT4*A5d7Gs9<>$f zC6Yqh8p5D)^O+izOc?_D+?$SgJIN)8c64;5qtcZ?=>UuU1bF?$R^^xTZ?P8`Hj?Z2|8b} zs=W$fLp{<6EIgYLFv_er62b!@RB%WHk4TUNC5N#@+0-Fn(^Lu)(6EdYMnd%x zlUha8z$?wLMFoga(IcHiELzOUN?{j4szXl9xNwNs__Oi*rXF5fIM%cy=q2){>&cgo zs*5+`RC!~=?s&|4`z1K%lGso?tFJ>SfuBdqk*#3rD26UCi{jW-3Md4{61IBS$-Af) z1Szn9prwk?NK~aUM31%$U@WQ?P@vd0eNILFxm1#Z!A7@irKKbjPNt)5_MK%^13Qd{ z+M_{U28M(R0CojAXHf%nT~v&)BE|9sN+_ju&|aP8v1mq79*z!1D?>m-5qzBq(a|Xr zqK8nU;84kK@%F7$g%FY(!UZu?eTj^v1TdvA~DP!-^~`u%hc`B8_Udbf8< zOlZ=ff{h=r>Wb6|xuLjgM$oPvdO%bvyC?TQv4vw^+KuqYc1 zyQZN4COqJttcB-wApPGs3GZj zXOKXFMf_E>h!Jq{oB<~IfNCbmiwckmi3=)*NM83vXs4IDZW@-oQ*t#ZE4pmVg6;x_ zsRGV+G&=Qr8yBf+#5=2+;h=fN9Nw-zd&3&jsbz@uO@gyi;I1$nHEBrm zx;v^l)&A@;Na@H^;5+7 z3^Y~X2jLLezfhCl{Yx`c+j`U9}{e zl-+Dea>r*O8jBT06j5mn8V`y5Fk~*~?y#DJaCO#6Ct%qy2}S|h-|%A7Bz!G=DPr4M zvE#tjMS!S^3IhX0Gg1SCM(e4-FqyX`41_f{%WRv(IGpPZFs=~fKoU*B;oGp;1UEMl zF@!qrj(DzHGb|d>4IZPQK_4OsvVx!3>BU0($3PAmrarNpg_dhGK>mJvwW~ zY9>;-9mS42afbLd=0h7*%o2)D8^F*4#f$>#`7Y4x06_p@ks}9loz*s^Q<|h*3a2zE zJk*OvK5l-_H zWHw+l@u8K?EVfI?91>>%sUidlG}J(bt}K<(*2d5m8i6O^p8^wI$IrK)ZpO8(v4%+- z5%!IW1x&D%G9UbK)DF@U;Q%C7yKlWfNj8$I`!h4s*O-u1iYx=ir(Dp4hcduYOzr2c zUS0#16yVbwt-NNihL9srps*>$7A1cWAq%=Ao7OBLFD6Xgc-T zr$bHa6s0K5NsSoD!H8EJOi;>hnB=5x-+pl%W`qw%QC}o0UagCf^=}GX;;%DNL6| z!OSVy*NqkOS``2!a-m2lfa*7etzz65EoeBJ|dC7v6kxoIRY*c~(`*FHhqHVCA z=8d>%o(sza)P)2lDi0KuORdc8xU}Xs-G+ zRwltl2+)KOPz#De!7ZY7Gcz*MO{AP8r6P8ME`iO3%Q@At4u=9on63z8MN&xwvYcRa z@lb+#9eMHVeEj?0JHf_uoTWmEjkeVLa1T+e(}$V{V5o|!h_1|uq9Ri{y>i*zsL_fn z;JPci>!+taNyirsnsuNw$hy|ENUF_iSZE5Cs+QE>B7n3ldR_u{m>25A?zfDgL4C!@ z7#|fQk5G$Igdlq5C-j`+QNh5MF*r~hh1SVq>g*ARgv#>t*6?n&SJ}Z1ztuWKN-{P6 zt@yV7&JPvo%TQF_89mECl1oScNc}AH=A-IB#i5H3&+nANm2=E1><>x zOgjg-o)}@_Zh-xmlMJ0ya{513W@XR&z3=;lR_Skd(=+i-{70TyIG#v-rJBMgB6)b% zHV7r49=wc+fH90?fg%qAdd^eb7<(>0!)epPM|sw8Ba_*Hp>v?8dog<>Tn;aZR_q9g^8V%tKcL}DWlZjrJf;Ks{h z5;mA4;roEiFw+Vtg3+W@J7hzqsrL4V##lC>7$1|6XV3KWaUABr144qVG6x}z_Am8| zOC#snDwXh?t#uNu+Z4^TJhkh}xvI!}=2 zKDapwZZ|`4Yz_`Ki4KKe7Q-METo1Y~u-TAr`-C?ahnU#KGnZl96J6iOq{CV;?c898 zwtIkY!0EBdP?nh|N}NG~FrYBu7_$z(GFi<)tmJ3rcRTlSpc)&MrZEE>g9DDJ;lm7S zf_JB|Z}nm7sndSj>QCg0Y7W99dnLm%znD=|A!0Mp8PrT9{;u^{%}~oHsth5RT~vyk zM!C7Y*!iQ2JE;7|Y=I%ltwGLpN3IbKyQ5Jhq2AoQY}}Yx0_Jf~Ily(yb&s%D9gcDi z2wm9mx+)!`M*d9-_>4tEG;(;h3j+7fCO}F?i9m?&8wRp})r!OU`ZuxfL#nG9 zeIG~R@M)oT=?lkg-RcBdpb$CdQ+1FIsO$r@M~-uZL^4b!OlfmvO(HruSr=oHL}a0= zj9?qDi;Sr7dA@Wl)z6d-n>}M_a6Bl79u@EJRsR3&KM3q%dr@j4vL+2;OB|OUlQRN_ zePNi0?bOk!dh2TJ+W|_KS%wdVu;`Tyi!-?{n@59>@Gz!m0aJX=rg;ZJ;VnG!!bn#AM3@{;~ z;5}{66EoH@6oK9l4HG|zqf^@=nwsU=z*dDA;LPQ07+l#|&Pxvk?{6cgA#a^I&6%x{ zEDjPfr*9(UE2P>iiZ0S6W+x2CpdOw>#OV*4=l$Qh2KQiczvy+Q>D<-304s=N`*Z2>*HxfexEObg3!!jINUvkm<$JSQW4Kk^Y=k*K-fVPgkX;z8d>&yl;6jw z;iMDi_9zU<43mpr-XX(ng2^?mMaUt?hzb2NRFHLo_$lHil-P-RkwN{I5 zf;=}QdybC#pR;es^jAk~kk8P6WrYu149fX&0wDJj%?Los4Ti~mDs19vR{C8o*F;sx zOk#C!5lGf45t5Y&0A?6)NWzqC?~2F%_Pcf_qD_Su_G9InPYH9KIv^XU+z4TkIB31$ zv9(&2YT5HT{duzF)2T?xm!pHnaz4Mmu~Kff{v!~x+1Q1Zq98eVN`sN?r3-3Ex)&V6 z!hO;OnR1E)*!+cU!=Snk)q#@~{H4ud_54M*lWLs#7i?aDNG1|F28jepPJqb+(An>x zdX$qg5ddHYWETt@2hZ8id>y9#2t~UrgmK9ke_BA6lMi@$jm9Gg(94}kI>%qK)JNBt zjg4*2ekQUppm|_|EIDUp=ZwP#Wl9u}WY;6|c1+}S!c>QyF)~LT%pvq-(f5eYBl!1S zX$N}HF+@Z`I?{v~5FrS*3Ti~c0r&=J^b`lB0ETYyKKNyUx}Nrp%)vQ-Gid{Ph-gvl zcl?e1*KXbR{eDYEP}&0~4&ZxUz+58mGL3~-x%VMD5QHJha-DqxissJWYqw0Gs5);| z0;atgC`Ch4t!G&y4igZxMWfXoJR*}!w)usi%+3#z`Lu2tWLpB92j}jt=AKuR+UP^r z^6W`!fQMn~VB&>lI)ef4ZLd41yO@-{&gSV(^CX(h=Ur^3dpW%9=DN<-N4??2HX2wi z5|g__9$V(f_?y?}oxmj$h#7r3vtjWfF_UZa*v|y*h1eO5V{UP+wB3$`b5BS|_p!7Y zOiz+$3NksuEr|N%jbOktQFs`M-8X3{!3f-7*f%v{_C(dK32`zKKZES1(z0;^h?x^o zB$kMjOT>9C=6p^svj@hqJ-zqvonx1eBOsoiw(1QGX9)B4Wbwm8g0t*5Ih{eo2JW-j znC}#viw&kCyNVYp#2Ms7-fz2*Wf_b)!!tNw&NEh~Ybf4;zdbyRkK@SUZYI?{szZ}P zl|4skY9Np`Vg@c91e6dC>Nq2skgb{4vzURaC=^rlDL6zYJt8}*c>z!)2-zo)K@iMH z4|v5wf3wi+?Bq@r$Hl2&o8Rc>`ew(^lJLt1bE}o1t=;u|weG&L=Jw8KB9`;KSb7xI?Zt4K7+x zNSqC0Ul5X=jUKUHDl&12@ zOj8FrT;z1!rUk{cDkDZj!wwu7W?uixYepG%tyaT_40o_#!NW+&pCuYT%IT78)y{6q zXt&DFZ&MtN8$)%{HMt$O!P8~t9DE!Hdw1OPTbrNT?*4B?|F6%@;@mvjo5!cI{5Spo zbMt(acr5Vx`f=<3N5`Yq|9hO;C-47>^m~5>RyN)*-pj5Y0@Eau9jHhRgS!NGocC3V zQaqI=U34|QBXA>=160+{4S08e?*oX~#mu{!NU_y`m$BuD5uS<$)d*cB2neGFVTj6O z$Ab(WhDdSa>5CgDr+1G8Q8|lGRW&-@f&|eH>h+QbmwWf08ex+Z{r07jk_{j%c>TVj zP{WL#E7Fc3E{zppo0xUZ`J3T9-zJ$giP><6%6DzZxHZpTPZCoS16^4hz9; zJodx__MD<(sjHvr`6_uI_nWI#+}qy_k;M_mNU6V|Xe0)f;$!iSVTKq!H@LU-^UNEj zIUu**oHHhsmcDLHZV;$pdlZ{U+ zI^MNt&c;c}k4Sk#mFR)2K$I!K_6)KF=n}voUGhp>7z551*lHbLnRT;}okPely`}bA zs@4g(;y$5um@VE8Lz~dRK&%>Jrxv4iaVZ2)0vKTGV06@F6GCC@@ALD|qdJ8Hsb^d; z+tmjP5DJvY3bLU@yD3U_;}M`s3IILo;&?GDmrIF7kJk+zeN2p1Q4*oR=!EarL-ali zWI*lH``lZ(%LK?Y7J@bw0dCE5MX?OPMEt&aYb&WM%u;NFq%it>i`ozyw@eL? z$bcFddCo&bG2JsD!%GCW-mqlywi{H4!fVTaJew{CpEwS6o16~xl&*{nUsp#U`A}eUx_(ouRa5l;%!|GH+zgtI zgpf222}uAze*U3U4d^fgBOxI6r7ohQ~fi7S|7uT=%pv449=JK#8 zEd~PQ0X~2*Fazl7{u6DzZC;r%j+uxg^LC}Xuc zbsz!Rox@IB6h(18qc*alDow_2J9fOd7B zk>a$|fKv!Kh~`#$nN=c$@Y<8mzK2o-a2KN9VD=Rn3$omTW)P86S7XQZym~Z-?DBI6 z*mM!P;?|}?2#1YO$o++}eut&P(Q*a(Jt7eUU4_e@T|WK+!J(@~vMpdC-< z34wzHe#n5JB7#B+pu~X;_WQ&Sbq6}(f+(y(h=>V?TZ<&n!WJN=tqPbC84!^qh%o~P z0iv=ZNq`2sg30I@25>~k%l}nr1p@|q0a_WT;A zM!?scbU`kLL~-K=<&n9rg&zp(RuJiKNI@hUimq@HZyXx{W{P?j-;ytTfUrl62!K~o z0VSeQ+q2n0*B(wROlti=ZcM)_i3tK0UNttRO;$P zD}%vpm2MNDOQvP~lPG{db`5KDt=2@7!xD-8G zc#lPO7!&GRq3-;F@Sg{zqA<1;6=NH@a0L&1K^ImS#Q8Tp4!77^9Cl~H*@852Fez7O zoXGS<)6-v2;PQa^!*c=*Nd$=?L4X_j{B*(|+v7M${t;;ePcpBa%!qe%D=Z3K8!5%d zVj9ty^XY8XkT5QXBB`~zP-xkjW`)(D+JfA%Lu|Iu634mJt%vGjU?|TRmnjR^(k)@oT+W_Ia{0K&iGXP$@|&*Q-1<2@4D% zl@(dVOc4TV&qs?p1`#Z)Q4AiqS9?q~9!k^~preOcA|o@JLabV$gB(_@Cd}@*uv>MB zfj4|zRS`--08|o{w8V+9a=(0GS#&h0T+E9H1Wk&YiSoPbnrLQL(yy*&R?};y_Sr6a zWu}Bh6i`qQ=)&}2h>IaeH%ltEkPFiF)K>3MP*9C1*{fS3A&{GXpyEcXabsZNOOy;y zp+HCsw*w;^M2I6w-L$U8?ipc`0YE}bRoToMm5^fv7d~y~+{Y;JzQ({uQQ-}cHWgTv z)$szNMuQ5JswyM{QAJD?0OCT>=5h>`@*QTwpt=%tBc~bS!^Q^6ZIy*YH2CL^%6Rw8 zGj<0WvGOu`Dh_Ld=If9+&`ruJ<2hX_8lzMw&92dYCm0hlronxQXh zNM;NwqJioTMs$tt@@h^*%)n4_!KLbR*{O`ISeOVDg{_psI3IxA$&gLi#BBMu4oRNm z8P-)mss`9tE@Ucbju4|fD$7Q}U#R6|bo?paBPh2W;-rVp?GTY9X1E#D55%7L(LMR_ zVCV$NASb!C4ZzqAu3jdI_j)o8{KmQU%~f+b6eXJgKFWBXd@3NuBC8}-PzS`CNqna| zhgb_>*cz0;G(b!up&&z!9BJu{M)yUr(G(6N9P@=`3=I`?Q%+`X?Cb{@piS9=AVwzj zaPE-1lP$Nln0sphoU%k$hnp6NoxApNoMiP0*M14$PNA$PFz5nln7!~ZIHzNek>s2k z4VnU00ClF#-T2sf8a6&%vjuvWG;~IDz?0O;5$hoUgCW;%iw00EdiBO?wUP9&cCuU9TX8@#2aOKal3bHzV20bqDQ(5n=D93ylcotMNv~OJX6ez zH68*wM{pQ)Rc0bZf|>*;P-lX(T}{#mI(Td=)(aWT9(1J50BTmkSt9P~1J}Y}i1{GwC4@r)h1@7qqIrjSWzFLoKrc zyb@?9cy#JqfRdn84@g@Xi9P0HB*o}>2%c(yN*-WCG%Zf$0b~RYUQ+La7VGFb^!nT} zp^h!1evf<BvpPPyI_%=f${ zZGrEBO=Lu<9aa%wS=gW^G?x(08+O?O@~>n$1hiPB2w8UdWv&PX`GMm=kp>eI3JA$G zt+N)?XhUj=z%WxrrN(xbXH;|yYIK8+KH4U+_`saElc;Qh>Q{wvTYqqtnZ?61H`hJa z*a~gbu52SDBIg&{J1R*Ck1$1GK_B&n0Qs0JbZ=cN%p!9S_6vOzg-1J;`9226qKV*H z0(fz}d;teQPD8~ym>v=0qm>V5!J}9RDLO(0>IQ67F9-%ycP8o&&d9J3PgCWaauFtJ zM?Gu#D&eM79QG2n5k9K6U^Pnx>HD4Kxpo_$2djwz!K{jd*(4 ztwB=ciD(@gKO{1MTy@X2?CZ{}unxARNfCZtA_d?OiQ%2A3n4(ZVp9h0ueUqo0?J8_ zxoaWKPDdCmz4-9!-wUz0Q?NKZZah!T@JZt>`8)ghkCa2D@Q_2qCJrJNJG%jY(RHD4 zDIg3C&^U_3VM}z6i~|ru1=8$T5inrSNLI%Ez8Zw?Qi|SaZ9f+wzyl%2kF@ia29MKz zN7<-S(L@CiBt`mcX+ao~5LO+=RAU&#Sg>ITz=cv$gd+k49$vex)Y^EV@x!}w^WO3H zrVLbADuiIgkVvsb7_ed>s5A1?s*qJs5rkqWGDxIi8iJd`3=f5}A8H|KA>76lQH?cR zx1`B1jRAFTrZ*m^9v1xhu^jbinlu#PtCcw!W}BxH2HH{)tr;n#Vk(~m{JoX z_U3YakIT_)?9txu)9=IRPu6$DeLnZ!^--PjAD8-Pr&LqDn$-QTZVSto@cw%|E5CpA zd>S@=WVr_Stb5GL2Q^%`YhInL)Q|FhJbIr+IiY5MJJ+8*@NCwLGTv?5jMp+GoHyc( zsb*O}J|1U70?y9G&denI9QzSY$Mf;?YFI1Ly0oRs#^_@w*K{FyMJ!b6)m@wP=W67t z&5-3Rdin91UzUxsTr~(iQ=e9|GpywAdvyGME$!o|9~uUcBNOF+Ou_iufcKlGRsfI& zA0DInMUT<^3Jje@Y5;*O0?9(cF$8Qo95s@%pef<7Fw^`Jz&H*+Rsr`296>*4`)m*1 z2q%Ik--#ZO!wiL}Ou8|exnk@BHcy`5M?$DDZv$!+)B%th(x})|>5@UOn~I(N+a0Dl z{-_mBV^_L-2lc+2`*i-b)C{vGlT8?rNPG+#5aw`CKp?wY=c@E0_z#oP-xHz|AGC)P z(g_$DkgnLK&k^Kt86hH_h2Xc5ksv^lNJcV1gppscMNw0$%&AFn0gz-EPWcl8d+8^I zO170)iVR5^XhOk40u=`dVS@vJ8mc~4=?!1E48uW)Mj(C&`ryvVKxG&R&BQ6PnbE73 zufm04MrcVV#GX5nc}+ngBQl&&1K-FQ*JFz8bBW^+`Z>`32pWkJ*wXh1fw$ak@ZXUh zoxmB5wCF_Cq=?&@eh(MYvL6coHK2S3Pzqs;op^E|T%86M__K&V>+!1JMXQzF2JDF7!3 zn|o5f-gWG=wUgV|Wb#NzXUj)DlGRrLY=Z8SV2HDDJxO|Uf zi4h~JI}ayBAReC`b#g^ zNqo&1CnONzg3w>F&z|rt^^uDY2Def5l?Xql0C-J>Ozp?)2*HGbNDghcnCp6of~~9st6* z+9Q;qEQ|ttnkdKJYDvw70YKuEh}&RrN~KF_eaRp)0|5|#vI2?_77ADl;)hfXMa-a6 z^L>p^yX)XNyhTWN(0c-v>Q9R^oHQYH6PM8fK#_t#A(Y3*k+;4$eEWvKobaCwsDw`j zMRJlM1KEg1OBL_c2ac`@Kv8m_+@P>1sXOoBE2urCG0cPm7L4;P1T?xapE-Uzp>nu@WjA0jwDZ>~?AZ;0=dxC$eKw5co{Ra}eS^3N)lS zI?@L@1!FJ~TNNUZ6kt*YU}je9PZIzhv(+cw1C;4EFbM!W)u&uh@2c9{#8Z2cJVY!L z#YibZb;VC06@eZ>OnaW6fM32ln`eK|!!q#*Y+8tHnRaO}g&!V09Kc{@#BuSHoi2sQ zkfaIVBSGm>g?s|&cii%pYJoIC=2U&36jpFs?(YDxc4i-8es~Zv4Buk_6GigT*LAwL zEwxmEtD!4J26?CCpCj)6K8}ml-q3|FIXYNX&b#6`UuIw+Z+AFv>|M_0iJWmx_Tk#{ z95e+6`rb5KWxdjUxfL`}^nFi*=ldT&vUF+3alhUI zJz~Y{`#l_^^Wo@DfDb|4n0nz5u^&ttNS1-B_?e7I@7TKDZdEsf)CZ=g%?8O~Jn%c9 zK`17i7}6flmm zQ3~9cSpd{nAaRxqW)pTAzs=F9G|`I9r4p5Zi!9K#4x^I5W8^3TvZFs$U$bxR$9zr^ zUkeOjQc4g*qaiDTKV#t8+H*EBGDs2@2*!ut4v%QG*ooz5ZnptP2v~?=m4s9DM}gsPw;*Mq=uNAjIo|CP&)CK~NJo zO=X5AhHx}Yux&82l#t_*ATdZI4V)Q~CQVI41Y;nGL<8!cEk7tA$XOr;@XRIY0y{VP zrPzWQdwoo{^Kt=g$r=WU5=D|h5vYjBE5L#TRHO<0JnVU~j+66kY^WTlUF}Ia!5&>up^7CbRbIWD90ud#?%0|xwP)QSq&^bgs z!}x@b@@Anu=OM?G0bmWq`i-FdPh{8vXdI*@SjQ?4#Q-z1hTzj}Xn+wJNhFrQStajwG=qV+TQN*@lI9Xp1Iuoy&;gjFO5gl9qELo1!bv3-+kN~D1QbB!D7$FEm(R)f;Et02wit5a2}w85BVdgDfP78b!@D0N)5C_j#(*`tRk2*rz{uK#dy)`3EuQ86<=V+~yvf zYey#=#d$-QMkE46h{gjTi4p;$%n+n4It=s5EA#r1Nro{LVv?PoD4ySQqz#<%f*_=1 zMl3}|6^L}tzXRnDmvi9>x$O6Ex7XwbLVEJx=k_K;E>l{80$Hdxp63$n9!W)2L{(T&jAWca4akEm zH!@79)IA2<0Y<-A(#EEPu{%qB=7QSoYB9B1#Y-xdT}*+^r{q_V2jMfpk&=dI8x1q4 z1c^xreGjHby~AzvnX{)(`d&5CFZAed`?mRbb^JVEj9-7j zzakrr#%;4}eo*zA?(NU>KdP#+!J0Lx8XfPAKe4oFrq4~xS}~;`m1~w)zTc$M-06l- zOVyo$4rLw!2LTd~znlarC|n5;KB7etQUGa?-sls0WQF>A!wu! z;3D~^AmpkspgM3()<9GUlzo;8be?mz%jIl*7E^E!xOE*JG7;jSQ%j_xN>%`$JIfm4 zBUhvx>L4lknS9**QkpcHZ-x;2A>eyd85CI*1Q`e_AgD4)BNT)|kQDFs0>d7+Oqseg z9qHx3-#tJ#1gBj{HR0y!>CiD|Ip!-sbrvnoysvKr1d;(5LPUfaT_e%r-GQ+~D!`aY zg_E*Gu^7P7t0l$ol*(6-Y~N+q&CkC@8ynll-gC5s|bo=Otz z5VF#jB#a`^fWV>#yrM5ifLimJsV&NoFM zi3kxG$?WWItHX7UYm8Mwf`UQ&0L5B@rP#NUR-`Cy!2rk#O9;%dfQ=wgg^M5(Eg?I@ z(+M@nWQ4og!&)pcG>|KiH0fPdSilAmhdaf?I~}iMb~*&Cu&1pA(pa~`sm+MAl(?^9 zIA8%F_zunN`=>fb0t-%eQ-brg9i^q`MTt~IL_rLts#vqpSddK(sIgTN!B^^95o^8O zVyuWLL{YVwL=qLj9Tw;VK0L1(m=kkZp@D)MxrTGZCdLc*I`O{!av*VGGD9+RZ&!Lj z4z%5?rDf4!iK}j?)zrf3m|9FnR-7e9`Mhec8^tn|9K!)AN>Hkyb*CunX&5-mV)YZQ zuZLosyt13bQwFK7UUpxa(BR@z7%EHbQSD5Vd1v)?JL}eNak_H&b(gm5xfx5vmFdSA zXyBU5JL=5VD`PN03`hYBH4gU@Bt`0`^Py=LrrqEMGI7^h+?y&mY&B=ESDkTZa}^U- z)T1=Rp6RSE{Ij{5-h-y8ARuIv5-g9R!dl4HB5Y1mqf-&^r0pOlLFnvdNH#wZmu}dy z6qqP>I648dh_fRh3I@pug&?h6WFQ427^DIspfE&4Vg(rrC;~}VMs@)&nIK7LmoocQ z@$>DcgNcVh->TN#reZ+CS1|kB-hHWZ?;K zVAz~bHYj!Rq^>1m$~z5UXXKI`hSFrgQs#^C#Pijrs9If^?BG{ZGpmy8O4T=&ybM=T zi>PpYZkn_X{I@`mkll}(xh?VSP#01euBtjmF>wSpArUjU44YBDNJyzY-Zwe&isAvm z+$AB+eZB+@4?)%%oDP#_(=iQ)B^%)gxr2OzNSKuw6C^XYAW}%v0s@vYOV5{wgpu47 z191S)0^CQ80#+n2A<<&nLp_Kz1vmM6{~HOaBbN?THZJ&| z9P-^>5QV5ugH3zu?BEK3a`|nrxN&v{DuctzDDUp;IOifl1d)<475R!;00EIjfK@sa zfo6biq)1`#3M&|hf>Tlerb!Y{g##jcg`r|db8~R0Xv9#KpffO{Oz=p3a%4{C&U`YE zPF`HBLr;&Z9c;onIUEGgklt?ns{w2`wPYYl8>*Yuw6cZ5%#DLmmCg%!2qC|z+lN}m zUl&~Fy8QcnUbSq&-k72un}=U0Hk2^3Z>Et| zT%|zTikq<9X*-D06&>o-&i!*>Y668PJm|wff+#hGPZY2?;)nqu!^Fu5pd|bwHq(pd z(3dzFUc{8<1P2%#%!Z_au(*sANvceMa{~+mnQRD|CIrf){@f*n@ zBpSqs1>*;P4#Fg3va{lCE&|{&yt5^uN48m^>8b&lypX7erGz0#W^};Bowb4E3T|cG zQ0C`>laV4Ic5=}w901oK!H}ikh-9m$v`>3tlw%@56N_ont?HhSkPfZ}vC$@K=@VMU z3M@k{8)SkiBNd=ll(MmGD+yMr+gfQFS;H%&meRxuQChHQoMvT^wQ}SPtC<6W$w;cC zC4qp@$wNTUI)nqND?p>k0P6{yhJ_Ao3c(i78K0qyAXqu}u38&%H-v~KqCK4)$YWkP z=cpSpp?M%1fQPkOhG&ff zGa5iJ&o6Es5Hr>$vu0=h_yEmJz{aM$#kn#h8s^AQS^+lzVH0#z*k~Fiw*WH`ASITG z2_#!VkO)8_6p>&>Q-SaSA=N;3c?)^&9lK$f2X6N{@>UFfm)wFyjBb=6cdUW#2NTzf zgoGkucg8D4qCzfcb!wP(H~|lW2bygVMHo66=agCG3YtdJNn#5Uf46PNbVR`*-5CvNm zhGCduCnze@)J&5YCZDl$M5&AGbox8Knd%d*?0eSu-G`9ZcyBolXaLNfW12W(qmUmbA1h^D zM2RzVb+VdrA=0w6phS2^6A&zo5fBDp2wHg!{O~9{6Sr5jrbO8VG}uTts$A;` z_z@Zed_p>h42wpXs)wano@q#>Z}d&3lD7{Oaht{LDkM1{z%dM`BNZ=M6a=Gw!~md5 z7joX&q%&N16TXf{XB_C{FclPemrs_V&fN|fzB5r^nR(-f1j?7{78elUb9iQJr#s(` z*q(c;G9iLQNlzI`#fCMz0Y)mf1{*g0Bi*uxhoGPy-;D3T>%z zC?KMX6j5d5{igNEI>0vp+@k|{hf$6=!WRzl@hJVIWS&s%HKc?IB#;O&I1nxRO2j|= z-~UH}{QsI^Im6uXna53i(xu#MT)FSexB4i7t`t+47(V$0X%Ri&hva@`K6A)Vh48il fDG-4VN91M&S3E^RDcwaw|Ha&qP81{)`k-Ebmv(>B diff --git a/R/utilities_internal.R b/R/utilities_internal.R index 0c5ec978..8a8ebb67 100644 --- a/R/utilities_internal.R +++ b/R/utilities_internal.R @@ -163,104 +163,76 @@ preset_groups <- function(group_name) { #' @keywords Internal default_columns <- function() { atlas <- pour("atlas", "region") - switch (atlas, - "Austria" = c("id", - "taxon_name", - "taxon_concept_lsid", - "latitude", - "longitude", - "occurrence_date", - "occurrence_status", - "data_resource_uid"), - "Brazil" = c("id", - "taxon_name", - "taxon_concept_lsid", - "latitude", - "longitude", - "occurrence_date", - "occurrence_status", - "data_resource_uid"), - "France" = c("id", - "scientificName", - "taxonConceptID", - "decimalLatitude", - "decimalLongitude", - "eventDate", - "occurrenceStatus", - "dataResourceUid"), - "Guatemala" = c("id", - "taxon_name", - "taxon_concept_lsid", - "latitude", - "longitude", - "occurrence_date", - "occurrence_status", - "data_resource_uid"), - "Kew" = c("id", - "taxon_name", - "taxon_concept_lsid", - "latitude", - "longitude", - "occurrence_date", - "occurrence_status", - "data_resource_uid"), - "Portugal" = c("id", - "taxon_name", - "taxon_concept_lsid", - "latitude", - "longitude", - "occurrence_date", - "occurrence_status", - "data_resource_uid"), - "Spain" = c("recordID", - "scientificName", - "taxonConceptID", - "decimalLatitude", - "decimalLongitude", - "eventDate", - "occurrenceStatus", - "dataResourceUid"), - "United Kingdom" = c("id", - "taxon_name", - "taxon_concept_lsid", - "latitude", - "longitude", - "occurrence_date", - "occurrence_status", - "data_resource_uid"), - c("recordID", # note this requires that the ALA name (`id`) be corrected - "scientificName", - "taxonConceptID", - "decimalLatitude", - "decimalLongitude", - "eventDate", - "occurrenceStatus", - "dataResourceName") - ) + if(atlas %in% c("Austria", + "Brazil", + "Guatemala", + "Kew", + "Portugal", + "United Kingdom")){ + c("id", + "taxon_name", + "taxon_concept_lsid", + "latitude", + "longitude", + "occurrence_date", + "occurrence_status", + "data_resource_uid") + }else if(atlas %in% c("France")){ + c("id", # only difference from ALA + "scientificName", + "taxonConceptID", + "decimalLatitude", + "decimalLongitude", + "eventDate", + "occurrenceStatus", + "dataResourceName") + }else if(atlas %in% c("Australia", + "Flanders", + "Spain", + "Sweden")){ + c("recordID", # note this requires that the ALA name (`id`) be corrected + "scientificName", + "taxonConceptID", + "decimalLatitude", + "decimalLongitude", + "eventDate", + "occurrenceStatus", + "dataResourceName") + }else{ + rlang::abort("Unknown `atlas`") + } } #' @noRd #' @keywords Internal image_fields <- function() { atlas <- pour("atlas", "region") - switch (atlas, - "Austria" = "all_image_url", - "Australia" = c("multimedia", "images", "sounds", "videos"), - "Brazil" = "all_image_url", - "Guatemala" = "all_image_url", - "Kew" = "all_image_url", - "Portugal" = "all_image_url", - "Spain" = c("multimedia", "images", "sounds", "videos"), - "Sweden" = c("multimedia", "images", "videos", "sounds"), - "United Kingdom" = "all_image_url" - ) + if(atlas %in% c("Austria", + "Brazil", + "Guatemala", + "Kew", + "Portugal", + "United Kingdom")){ + "all_image_url" + }else if(atlas %in% c("Australia", + "Flanders", + "Spain", + "Sweden")){ + c("multimedia", "images", "sounds", "videos") + }else{ + rlang::abort("Unknown `atlas`") + } } #' @noRd #' @keywords Internal species_facets <- function(){ atlas <- pour("atlas", "region") - if(atlas %in% c("Australia", "France", "Spain", "Sweden")) { + if(atlas %in% c("Australia", + "Flanders", + "France", + "Spain", + "Sweden")) { "speciesID" }else{ "species_guid" @@ -274,4 +246,15 @@ source_type_id_lookup <- function(region){ "Austria" = 1, "United Kingdom" = 2001, "2004") # ALA default for galah +} + +#' @noRd +#' @keywords Internal +profiles_supported <- function(){ + atlas <- pour("atlas", "region") + if(atlas %in% c("Australia")) { + TRUE + }else{ + FALSE + } } \ No newline at end of file diff --git a/data-raw/node_config.csv b/data-raw/node_config.csv index 7fc1c1df..fac0873f 100644 --- a/data-raw/node_config.csv +++ b/data-raw/node_config.csv @@ -84,15 +84,17 @@ Flanders,metadata/collections,https://natuurdata.inbo.be/collectory/ws/collectio Flanders,metadata/datasets,https://natuurdata.inbo.be/collectory/ws/dataResource,TRUE Flanders,metadata/fields,https://natuurdata.inbo.be/biocache-service/index/fields,TRUE Flanders,metadata/fields-unnest,https://natuurdata.inbo.be/biocache-service/occurrence/facets,TRUE +Flanders,metadata/identifiers,https://api.gbif.org/v1/species/{id},TRUE Flanders,metadata/licences,https://natuurdata.inbo.be/image-service/ws/licence,TRUE Flanders,metadata/lists,https://natuurdata.inbo.be/species-list/ws/speciesList/,TRUE Flanders,metadata/lists-unnest,https://natuurdata.inbo.be/species-list/ws/speciesListItems/{list_id},TRUE Flanders,metadata/media,https://natuurdata.inbo.be/image-service/ws/getImageInfoForIdList,TRUE -Flanders,metadata/profiles,https://natuurdata.inbo.be/dqf-service/api/v1/data-profiles/,TRUE -Flanders,metadata/profiles-unnest,https://natuurdata.inbo.be/dqf-service/api/v1/quality/activeProfile?profileName={profile},TRUE +Flanders,metadata/profiles,https://natuurdata.inbo.be/data-quality-filter-service/api/v1/data-profiles,TRUE +Flanders,metadata/profiles-unnest,https://natuurdata.inbo.be/data-quality-filter-service/api/v1/data-profiles/{profile},TRUE Flanders,metadata/providers,https://natuurdata.inbo.be/collectory/ws/dataProvider,TRUE Flanders,metadata/reasons,https://natuurdata.inbo.be/logger/service/logger/reasons,TRUE Flanders,metadata/taxa-single,https://api.gbif.org/v1/species/match?verbose=FALSE&name={name},TRUE +Flanders,metadata/taxa-multiple,https://api.gbif.org/v1/species/match,TRUE Flanders,metadata/taxa-unnest,https://api.gbif.org/v1/species/{id}/children,TRUE France,data/occurrences,https://openobs.mnhn.fr/biocache-service/occurrences/offline/download,TRUE France,data/occurrences-count,https://openobs.mnhn.fr/biocache-service/occurrences/search,TRUE @@ -109,11 +111,13 @@ France,metadata/providers,https://openobs.mnhn.fr/api/datasets/providers,TRUE France,metadata/taxa-single,https://taxref.mnhn.fr/api/taxa/search?scientificNames={name}&page=1&size=5,TRUE France,metadata/taxa-unnest,https://taxref.mnhn.fr/api/taxa/{id}/children,TRUE Global,data/occurrences,https://api.gbif.org/v1/occurrence/download/request,TRUE +Global,data/occurrences-doi,https://api.gbif.org/v1/occurrence/download/{doi_string},TRUE Global,data/occurrences-count,https://api.gbif.org/v1/occurrence/search/predicate,TRUE Global,data/occurrences-count-groupby,https://api.gbif.org/v1/occurrence/search/predicate,TRUE +Global,data/species-count,https://api.gbif.org/v1/occurrence/search/predicate,TRUE Global,metadata/collections,https://api.gbif.org/v1/grscicoll/collection,TRUE Global,metadata/datasets,https://api.gbif.org/v1/dataset,TRUE -Global,metadata/fields-unnest,https://api.gbif.org/v1/occurrence/search,TRUE +Global,metadata/fields-unnest,https://api.gbif.org/v1/occurrence/search/predicate,TRUE Global,metadata/identifiers,https://api.gbif.org/v1/species/{id},TRUE Global,metadata/providers,https://api.gbif.org/v1/organization,TRUE Global,metadata/taxa-single,https://api.gbif.org/v1/species/match?verbose=FALSE&name={name},TRUE @@ -166,11 +170,13 @@ Portugal,metadata/collections,https://metadados.gbif.pt/ws/collection,TRUE Portugal,metadata/datasets,https://metadados.gbif.pt/ws/dataResource,TRUE Portugal,metadata/fields,https://registos-ws.gbif.pt/index/fields,TRUE Portugal,metadata/fields-unnest,https://registos-ws.gbif.pt/occurrence/facets,TRUE +Portugal,metadata/fields-unnest,https://api.gbif.org/v1/occurrence/search/predicate,TRUE Portugal,metadata/licences,https://imagens.gbif.pt/ws/licence,TRUE Portugal,metadata/media,https://imagens.gbif.pt/ws/imageInfoForList,TRUE Portugal,metadata/providers,https://metadados.gbif.pt/ws/dataProvider,TRUE Portugal,metadata/reasons,https://logger.gbif.pt/service/logger/reasons,TRUE Portugal,metadata/taxa-single,https://api.gbif.org/v1/species/match?verbose=FALSE&name={name},TRUE +Portugal,metadata/taxa-multiple,https://api.gbif.org/v1/species/match,TRUE Portugal,metadata/taxa-unnest,https://api.gbif.org/v1/species/{id}/children,TRUE Spain,data/occurrences,https://registros-ws.gbif.es/occurrences/offline/download,TRUE Spain,data/occurrences-count,https://registros-ws.gbif.es/occurrences/search,TRUE diff --git a/tests/testthat/test-international-Flanders.R b/tests/testthat/test-international-Flanders.R index 53f4deb1..feeb3ae7 100644 --- a/tests/testthat/test-international-Flanders.R +++ b/tests/testthat/test-international-Flanders.R @@ -1,78 +1,79 @@ # Tests commented out until Flanders atlas is operational -# # set verbose to off -# galah_config(verbose = FALSE, run_checks = FALSE) -# -# test_that("swapping to atlas = Flanders works", { -# expect_message(galah_config(atlas = "Flanders")) -# }) -# -# test_that("show_all(fields) works for Flanders", { -# skip_if_offline(); skip_on_ci() -# x <- show_all(fields) |> -# try(silent = TRUE) -# skip_if(inherits(x, "try-error"), message = "API not available") -# expect_gt(nrow(x), 1) -# expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) -# # also that fields match those returned by default_columns() -# y <- default_columns() # internal function called by `galah_select()` -# expect_true(all(y %in% x$id)) -# }) -# -# test_that("show_all(licences) works for Flanders", { -# skip_if_offline(); skip_on_ci() -# x <- show_all(licences) |> -# try(silent = TRUE) -# skip_if(inherits(x, "try-error"), message = "API not available") -# expect_gt(nrow(x), 1) -# expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) -# }) -# -# test_that("show_all(collections) works for Flanders", { -# skip_if_offline(); skip_on_ci() -# x <- show_all(collections, limit = 10) |> -# try(silent = TRUE) -# skip_if(inherits(x, "try-error"), message = "API not available") -# expect_lte(nrow(x), 10) -# expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) -# }) -# -# test_that("show_all(datasets) works for Flanders", { -# skip_if_offline(); skip_on_ci() -# x <- show_all(datasets, limit = 10) |> -# try(silent = TRUE) -# skip_if(inherits(x, "try-error"), message = "API not available") -# expect_lte(nrow(x), 10) -# expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) -# }) -# -# test_that("show_all(providers) works for Flanders", { -# skip_if_offline(); skip_on_ci() -# x <- show_all(providers, limit = 10) |> -# try(silent = TRUE) -# skip_if(inherits(x, "try-error"), message = "API not available") -# expect_lte(nrow(x), 10) -# expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) -# }) -# -# test_that("show_all(reasons) works for Flanders", { -# skip_if_offline(); skip_on_ci() -# x <- show_all(reasons) |> -# try(silent = TRUE) -# skip_if(inherits(x, "try-error"), message = "API not available") -# expect_gt(nrow(x), 1) -# expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) -# }) -# -# test_that("show_all(assertions) works for Flanders", { -# skip_if_offline(); skip_on_ci() -# x <- show_all(assertions) |> -# try(silent = TRUE) -# skip_if(inherits(x, "try-error"), message = "API not available") -# expect_gt(nrow(x), 1) -# expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) -# }) -# +# set verbose to off +galah_config(verbose = FALSE, run_checks = FALSE) + +test_that("swapping to atlas = Flanders works", { + expect_message(galah_config(atlas = "Flanders")) +}) + +test_that("show_all(fields) works for Flanders", { + skip_if_offline(); skip_on_ci() + x <- show_all(fields) |> + try(silent = TRUE) + skip_if(inherits(x, "try-error"), message = "API not available") + expect_gt(nrow(x), 1) + expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) + # also that fields match those returned by default_columns() + y <- default_columns() # internal function called by `galah_select()` + expect_true(all(y %in% x$id)) +}) + +test_that("show_all(licences) works for Flanders", { + skip_if_offline(); skip_on_ci() + x <- show_all(licences) |> + try(silent = TRUE) + skip_if(inherits(x, "try-error"), message = "API not available") + expect_gt(nrow(x), 1) + expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) +}) + +test_that("show_all(providers) works for Flanders", { + skip_if_offline(); skip_on_ci() + x <- show_all(providers, limit = 10) |> + try(silent = TRUE) + skip_if(inherits(x, "try-error"), message = "API not available") + expect_lte(nrow(x), 10) + expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) +}) + +test_that("show_all(collections) works for Flanders", { + skip_if_offline(); skip_on_ci() + x <- show_all(collections, limit = 10) |> + try(silent = TRUE) + skip_if(inherits(x, "try-error"), message = "API not available") + expect_lte(nrow(x), 10) + expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) +}) + +test_that("show_all(datasets) works for Flanders", { + skip_if_offline(); skip_on_ci() + x <- show_all(datasets, limit = 10) |> + try(silent = TRUE) + skip_if(inherits(x, "try-error"), message = "API not available") + expect_lte(nrow(x), 10) + expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) +}) + +test_that("show_all(reasons) works for Flanders", { + skip_if_offline(); skip_on_ci() + x <- show_all(reasons) |> + try(silent = TRUE) + skip_if(inherits(x, "try-error"), message = "API not available") + expect_gt(nrow(x), 1) + expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) +}) + +test_that("show_all(assertions) works for Flanders", { + skip_if_offline(); skip_on_ci() + x <- show_all(assertions) |> + try(silent = TRUE) + skip_if(inherits(x, "try-error"), message = "API not available") + expect_gt(nrow(x), 1) + expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) +}) + +## profiles integration planned, but not currently working # test_that("show_all(profiles) works for Flanders", { # skip_if_offline(); skip_on_ci() # x <- show_all(profiles) |> @@ -81,139 +82,113 @@ # expect_gt(nrow(x), 1) # expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) # }) -# -# test_that("show_all(lists) works for Flanders", { -# skip_if_offline(); skip_on_ci() -# x <- show_all(lists, limit = 10) |> -# try(silent = TRUE) -# skip_if(inherits(x, "try-error"), message = "API not available") -# expect_lte(nrow(x), 10) -# expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) -# }) -# -# test_that("search_all(fields) works for Flanders", { -# skip_if_offline(); skip_on_ci() -# x <- search_all(fields, "year") |> -# try(silent = TRUE) -# skip_if(inherits(x, "try-error"), message = "API not available") -# expect_gte(nrow(x), 1) -# expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) -# }) -# -# test_that("search_all(taxa) works for Flanders", { -# skip_if_offline(); skip_on_ci() -# x <- search_all(taxa, "Mammalia") |> -# try(silent = TRUE) -# skip_if(inherits(x, "try-error"), message = "API not available") -# expect_gte(nrow(x), 1) -# expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) -# }) -# -# test_that("search_all(taxa) works using data.frames for Flanders", { -# skip_if_offline(); skip_on_ci() -# x <- search_all(taxa, -# data.frame(kingdom = "Animalia", -# phylum = "Chordata")) |> -# try(silent = TRUE) -# skip_if(inherits(x, "try-error"), message = "API not available") -# expect_gte(nrow(x), 1) -# expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) -# }) -# -# test_that("search_all(identifiers) works for Flanders", { -# skip_if_offline(); skip_on_ci() -# x <- search_all(identifiers, "359") |> -# try(silent = TRUE) -# skip_if(inherits(x, "try-error"), message = "API not available") -# expect_gte(nrow(x), 1) -# expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) -# }) -# -# test_that("show_values works for fields for Flanders", { -# skip_if_offline(); skip_on_ci() -# x <- search_all(fields, "basisOfRecord") |> -# show_values() |> -# try(silent = TRUE) -# skip_if(inherits(x, "try-error"), message = "API not available") -# expect_gte(nrow(x), 1) -# expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) -# }) -# + +test_that("show_all(lists) works for Flanders", { + skip_if_offline(); skip_on_ci() + x <- show_all(lists, limit = 10) |> + try(silent = TRUE) + skip_if(inherits(x, "try-error"), message = "API not available") + expect_lte(nrow(x), 10) + expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) +}) + +test_that("`unnest()` works for lists in Flanders", { + skip_if_offline(); skip_on_ci() + x <- request_metadata() |> + filter(list == "dr565") |> + unnest() |> + collect() |> + try(silent = TRUE) + skip_if(inherits(x, "try-error"), message = "API not available") + expect_gte(nrow(x), 10) + expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) +}) + +test_that("search_all(fields) works for Flanders", { + skip_if_offline(); skip_on_ci() + x <- search_all(fields, "year") |> + try(silent = TRUE) + skip_if(inherits(x, "try-error"), message = "API not available") + expect_gte(nrow(x), 1) + expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) +}) + +test_that("search_all(taxa) works for Flanders", { + skip_if_offline(); skip_on_ci() + x <- search_all(taxa, "Mammalia") |> + try(silent = TRUE) + skip_if(inherits(x, "try-error"), message = "API not available") + expect_gte(nrow(x), 1) + expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) +}) + +test_that("search_all(identifiers) works for Flanders", { + skip_if_offline(); skip_on_ci() + x <- search_all(identifiers, "359") |> + try(silent = TRUE) + skip_if(inherits(x, "try-error"), message = "API not available") + expect_gte(nrow(x), 1) + expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) +}) + +test_that("show_values works for fields for Flanders", { + skip_if_offline(); skip_on_ci() + x <- search_all(fields, "basisOfRecord") |> + show_values() |> + try(silent = TRUE) + skip_if(inherits(x, "try-error"), message = "API not available") + expect_gte(nrow(x), 1) + expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) +}) + # test_that("show_values works for profiles for Flanders", { # skip_if_offline(); skip_on_ci() -# x <- search_all(profiles, "LA") |> +# x <- search_all(profiles, "LA") |> # show_values() |> # try(silent = TRUE) # skip_if(inherits(x, "try-error"), message = "API not available") # expect_gte(nrow(x), 1) # expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) # }) -# -# test_that("atlas_counts works for Flanders", { -# skip_if_offline(); skip_on_ci() -# x <- atlas_counts() |> -# pull(count) |> -# try(silent = TRUE) -# skip_if(inherits(x, "try-error"), message = "API not available") -# expect_gt(x, 0) -# }) -# -# test_that("atlas_counts works with type = 'species' for Flanders", { -# skip_if_offline(); skip_on_ci() -# x <- atlas_counts(type = "species") |> -# pull(count) |> -# try(silent = TRUE) -# skip_if(inherits(x, "try-error"), message = "API not available") -# expect_gt(x, 0) -# }) -# -# test_that("atlas_counts works with galah_identify for Flanders", { -# skip_if_offline(); skip_on_ci() -# result <- galah_call() |> -# identify("Mammalia") |> -# count() |> -# collect() |> -# try(silent = TRUE) -# skip_if(inherits(result, "try-error"), message = "API not available") -# expect_gt(result$count, 1) -# result2 <- galah_call() |> -# filter(class == "Mammalia") |> -# count() |> -# collect() |> -# try(silent = TRUE) -# skip_if(inherits(result2, "try-error"), message = "API not available") -# expect_lt( -# sqrt((result2$count - result$count)^2) / result$count, -# 0.1) # i.e. <1% margin of error -# }) -# -# test_that("atlas_counts works with apply_profile for Flanders", { -# skip_if_offline(); skip_on_ci() -# without_profile <- galah_call() |> -# count() |> -# collect() -# with_profile <- galah_call() |> -# apply_profile(LA) |> -# count() |> -# collect() -# expect_gt(with_profile$count, 0) -# expect_equal(class(without_profile), class(with_profile)) -# expect_lt(with_profile$count, without_profile$count) -# }) -# -# test_that("atlas_counts works with group_by for Flanders", { -# skip_if_offline(); skip_on_ci() -# result <- galah_call() |> -# filter(year >= 2000) |> -# group_by(basis_of_record) |> -# count() |> -# collect() |> -# try(silent = TRUE) -# skip_if(inherits(result, "try-error"), message = "API not available") -# expect_gt(nrow(result), 1) -# expect_equal(names(result), c("basis_of_record", "count")) -# }) -# + +test_that("atlas_counts works for Flanders", { + skip_if_offline(); skip_on_ci() + x <- atlas_counts() |> + pull(count) |> + try(silent = TRUE) + skip_if(inherits(x, "try-error"), message = "API not available") + expect_gt(x, 0) +}) + +test_that("atlas_counts works with type = 'species' for Flanders", { + skip_if_offline(); skip_on_ci() + x <- atlas_counts(type = "species") |> + pull(count) |> + try(silent = TRUE) + skip_if(inherits(x, "try-error"), message = "API not available") + expect_gt(x, 0) +}) + +test_that("atlas_counts works with galah_identify for Flanders", { + skip_if_offline(); skip_on_ci() + result <- galah_call() |> + identify("Mammalia") |> + count() |> + collect() |> + try(silent = TRUE) + skip_if(inherits(result, "try-error"), message = "API not available") + expect_gt(result$count, 1) + result2 <- galah_call() |> + filter(class == "Mammalia") |> + count() |> + collect() |> + try(silent = TRUE) + skip_if(inherits(result2, "try-error"), message = "API not available") + expect_lt( + sqrt((result2$count - result$count)^2) / result$count, + 0.1) # i.e. <1% margin of error +}) + # test_that("atlas_counts works with apply_profile for Flanders", { # skip_if_offline(); skip_on_ci() # without_profile <- galah_call() |> @@ -227,85 +202,90 @@ # expect_equal(class(without_profile), class(with_profile)) # expect_lt(with_profile$count, without_profile$count) # }) -# -# test_that("atlas_species works for Flanders", { -# skip_if_offline(); skip_on_ci() -# galah_config( -# atlas = "Flanders", -# email = "test@ala.org.au", -# send_email = FALSE) -# spp <- galah_call() |> -# galah_identify("Carnivora") |> -# atlas_species() |> -# try(silent = TRUE) -# skip_if(inherits(spp, "try-error"), message = "API not available") -# expect_gt(nrow(spp), 20) -# expect_equal(ncol(spp), 11) -# expect_s3_class(spp, c("tbl_df", "tbl", "data.frame")) -# }) -# -# test_that("galah_select works for Flanders", { -# skip_if_offline(); skip_on_ci() -# x <- galah_select() -# y <- galah_select(basisOfRecord) -# expect_equal(length(x), 2) -# expect_equal(x$summary, "group = basic") -# expect_equal(x$group, "basic") -# expect_true(inherits(x, c("list"))) -# expect_equal(length(y), 3) -# expect_equal(y$summary, "basisOfRecord") -# expect_equal(y$group, character(0)) -# expect_true(inherits(y, c("list"))) -# expect_true(inherits(y[[1]], c("quosure", "formula"))) -# }) -# -# test_that("atlas_occurrences works for Flanders", { -# skip_if_offline(); skip_on_ci() -# galah_config( -# atlas = "Flanders", -# email = "test@ala.org.au", -# download_reason_id = 10, -# send_email = FALSE) -# occ <- galah_call() |> -# identify("Mammalia") |> -# filter(year <= 1800) |> -# select(species, year) |> -# atlas_occurrences() |> -# try(silent = TRUE) -# skip_if(inherits(occ, "try-error"), message = "API not available") -# expect_gt(nrow(occ), 0) -# expect_equal(ncol(occ), 2) -# expect_true(inherits(occ, c("tbl_df", "tbl", "data.frame"))) -# }) -# -# test_that("atlas_media() works for Flanders", { -# skip_if_offline(); skip_on_ci() -# galah_config( -# atlas = "Flanders", -# email = "test@ala.org.au", -# download_reason_id = 10, -# directory = "temp", -# send_email = FALSE) -# x <- request_data() |> -# identify("Mammalia") |> -# filter(year >= 2023 -# # imageIDsCount > 0 -# ) |> -# # count() |> -# # collect() -# atlas_media() |> -# try(silent = TRUE) -# skip_if(inherits(x, "try-error"), message = "API not available") -# expect_s3_class(x, c("tbl_df", "tbl", "data.frame")) -# expect_gte(nrow(x), 1) -# expect_equal(colnames(x)[1:2], -# c("media_id", "recordID")) -# # download a subset -# n_downloads <- 5 -# collect_media(x[seq_len(n_downloads), ]) -# expect_equal(length(list.files("temp", pattern = ".jpg$")), -# n_downloads) -# unlink("temp", recursive = TRUE) -# }) -# -# galah_config(atlas = "Australia") + +test_that("atlas_counts works with group_by for Flanders", { + skip_if_offline(); skip_on_ci() + result <- galah_call() |> + filter(year >= 2000) |> + group_by(basisOfRecord) |> + count() |> + collect() |> + try(silent = TRUE) + skip_if(inherits(result, "try-error"), message = "API not available") + expect_gt(nrow(result), 1) + expect_equal(names(result), c("basis_of_record", "count")) + }) + +test_that("atlas_species works for Flanders", { + skip_if_offline(); skip_on_ci() + galah_config( + atlas = "Flanders", + email = "galah@natuurdata@inbo.be", + send_email = FALSE) + spp <- galah_call() |> + identify("Canis") |> + atlas_species() |> + try(silent = TRUE) + skip_if(inherits(spp, "try-error"), message = "API not available") + expect_gt(nrow(spp), 1) + expect_equal(ncol(spp), 11) + expect_s3_class(spp, c("tbl_df", "tbl", "data.frame")) +}) + +test_that("atlas_occurrences works for Flanders", { + skip_if_offline(); skip_on_ci() + galah_config( + atlas = "Flanders", + email = "galah@natuurdata@inbo.be", + download_reason_id = 10, + send_email = FALSE) + query <- galah_call() |> + identify("Canis") |> + filter(year == 2020) |> + select(species, year) |> + collapse() + + response <- compute(query) |> + try(silent = TRUE) + skip_if(inherits(occ, "try-error"), message = "API not available") + + occ <- collect(response) |> + try(silent = TRUE) + skip_if(inherits(occ, "try-error"), message = "API not available") + + expect_gt(nrow(occ), 0) + expect_equal(ncol(occ), 2) + expect_true(inherits(occ, c("tbl_df", "tbl", "data.frame"))) +}) + +test_that("atlas_media() works for Flanders", { + skip_if_offline(); skip_on_ci() + galah_config( + atlas = "Flanders", + email = "test@ala.org.au", + download_reason_id = 10, + directory = "temp", + send_email = FALSE) + x <- request_data() |> + identify("Mammalia") |> + filter(year >= 2023 + # imageIDsCount > 0 + ) |> + # count() |> + # collect() + atlas_media() |> + try(silent = TRUE) + skip_if(inherits(x, "try-error"), message = "API not available") + expect_s3_class(x, c("tbl_df", "tbl", "data.frame")) + expect_gte(nrow(x), 1) + expect_equal(colnames(x)[1:2], + c("media_id", "recordID")) + # download a subset + n_downloads <- 5 + collect_media(x[seq_len(n_downloads), ]) + expect_equal(length(list.files("temp", pattern = ".jpg$")), + n_downloads) + unlink("temp", recursive = TRUE) +}) + +galah_config(atlas = "Australia") From 441725546f58221b0f4b66651f50c036076003cb Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Tue, 15 Jul 2025 17:45:20 +1000 Subject: [PATCH 08/94] First pass at converting `dplyr::filter()` statements directly into predicates (#272) works when `is_gbif()` returns `TRUE`. So far the filter is parsed correctly but isn't passed to the relevant functions yet; more work to follow --- R/build_predicates.R | 6 +- R/galah_filter.R | 22 +- R/handle_quosures.R | 3 + R/handle_quosures_GBIF.R | 294 ++++++++++++++++++++++++ R/print.R | 22 +- R/sysdata.rda | Bin 17808 -> 17845 bytes data-raw/node_config.csv | 12 +- tests/testthat/test-galah_filter-GBIF.R | 81 +++++++ 8 files changed, 415 insertions(+), 25 deletions(-) create mode 100644 R/handle_quosures_GBIF.R create mode 100644 tests/testthat/test-galah_filter-GBIF.R diff --git a/R/build_predicates.R b/R/build_predicates.R index 24b0344f..b1711213 100644 --- a/R/build_predicates.R +++ b/R/build_predicates.R @@ -94,8 +94,4 @@ parse_predicates <- function(df){ # test object: # df <- galah_filter(year == 1850) -# df <- galah_filter(catalogNumber == 217880) - -gbif_upper_case <- function(string){ - gsub("(?=[[:upper:]])", "_", string, perl = TRUE) |> toupper() -} \ No newline at end of file +# df <- galah_filter(catalogNumber == 217880) \ No newline at end of file diff --git a/R/galah_filter.R b/R/galah_filter.R index 27a522ae..54f5f0fd 100644 --- a/R/galah_filter.R +++ b/R/galah_filter.R @@ -116,8 +116,12 @@ filter.data_request <- function(.data, ...){ dots <- enquos(..., .ignore_empty = "all") check_named_input(dots) - update_data_request(.data, - filter = parse_quosures_data(dots)) # see `quosure_handling.R` + if(is_gbif()){ + filters <- parse_quosures_data_gbif(dots) # `handle_quosures_GBIF.R` + }else{ + filters <- parse_quosures_data(dots) # `handle_quosures.R` + } + update_data_request(.data, filter = filters) } # usually filters as previously for ALA, but some exceptions: # doi == "x" in `atlas_occurrences()` @@ -187,8 +191,12 @@ galah_filter <- function(..., profile = NULL){ check_named_input(dots) switch(class(dots[[1]])[1], "data_request" = { - update_data_request(dots[[1]], - filter = parse_quosures_data(dots[-1])) + if(is_gbif()){ + filters <- parse_quosures_data_gbif(dots[-1]) # `handle_quosures_GBIF.R` + }else{ + filters <- parse_quosures_data(dots[-1]) # `handle_quosures.R` + } + update_data_request(dots[[1]], filter = filters) }, "metadata_request" = { parse_quosures_metadata(dots[[1]], dots[-1]) @@ -200,6 +208,10 @@ galah_filter <- function(..., profile = NULL){ input$type <- parsed_dots$variable input }, - parse_quosures_data(dots) + if(is_gbif()){ + parse_quosures_data_gbif(dots) + }else{ + parse_quosures_data(dots) + } ) } \ No newline at end of file diff --git a/R/handle_quosures.R b/R/handle_quosures.R index 3dcdf397..a75f4e79 100644 --- a/R/handle_quosures.R +++ b/R/handle_quosures.R @@ -529,6 +529,9 @@ parse_c <- function(x, excl){ parse_logical(enquo(in_as_or_statements), quo_get_env(x)) # pass this to parse_logical } + +## -- SOLR conversion -- ## + #' Convert information in a `tibble` to a `solr` query #' Previously `galah_filter.R/parse_logical`, but altered to support multi-row tibbles #' @noRd diff --git a/R/handle_quosures_GBIF.R b/R/handle_quosures_GBIF.R new file mode 100644 index 00000000..6f5df1df --- /dev/null +++ b/R/handle_quosures_GBIF.R @@ -0,0 +1,294 @@ +# Duplicate of `handle_quosures.R` with modifications to `parse_quosures_data()` +# to return predicates in place of a data.frame + +# Note that advice on constructing predicates is available here: +# https://techdocs.gbif.org/en/data-use/api-downloads + +## -- Top-level parsers -- ## +# These are called 'first' by `galah_` functions + +#' parse quosures for objects of class `data_request` +#' @noRd +#' @keywords internal +parse_quosures_data_gbif <- function(dots){ + if(length(dots) > 0){ + result <- purrr::map(dots, + switch_expr_type_pred) + names(result) <- rep("predicate", length(result)) + + if(length(result) > 1L){ + result <- list(type = jsonlite::unbox("and"), + predicates = result) + } + class(result) <- c("galah_filter_predicate", "list") + result + }else{ + NULL + } +} + +## -- Quosure parsing -- ## + +#' Switch functions for quosures +#' @param x A (single) quosure +#' @noRd +#' @keywords internal +switch_expr_type_pred <- function(x, ...){ + switch(expr_type(x), + "symbol" = {parse_symbol(x)}, # identical to `switch_expr_type()` + "call" = {parse_call_pred(x, ...)}, # only 'new' line + "literal" = {rlang::quo_get_expr(x)}, + cli::cli_abort("Quosure type not recognised.") + ) +} + +#' Internal, recursive function to parse a call +#' +#' Note that most entries passed to `filter` will be of type `call`, making this +#' quite an important function. For example, `filter(y == 1)` is a call, but so +#' are functions, e.g. `filter(list(x = 1))`. +#' +#' Importantly, when using NSE, values are checked to see if they are present +#' in the working environment, but names are not, which is different to previous +#' galah behavior. So `x <- 1; y <- 10; filter(y == x)` will parse to +#' `list(y = 1)` not `list(10 = 1)`. Advanced R suggests using `:=` for these +#' cases, which could be added to `switch` below +#' @noRd +#' @keywords internal +parse_call_pred <- function(x){ + y <- rlang::quo_get_expr(x) + env_tr <- rlang::quo_get_env(x) + switch_lookup <- y[[1]] |> + base::deparse() |> + rlang::as_string() |> + function_type() # galah function + switch(switch_lookup, # i.e. switch depending on what function is called + "relational_operator" = parse_relational_pred(x), + "logical_operator" = parse_logical_pred(x), + "bracket" = parse_brackets_pred(x), + "exclamation" = parse_exclamation_pred(x), + "is.na" = parse_is_na_pred(x), + "between" = parse_between_pred(x), + "%in%" = parse_in_pred(x), + rlang::eval_tidy(x) # if unknown, parse + # {filter_error()} # if unknown, error + ) +} + +#' Take standard filter-style queries and parse to `galah_filter()`-style `tibble` +#' Called by `parse_call` +#' @noRd +#' @keywords internal +parse_relational_pred <- function(x){ + + # get expression + expr <- rlang::quo_get_expr(x) + if(length(expr) != 3L){filter_error()} + + # parse out separate parts + operator <- as.character(expr[[1]]) + + lhs <- rlang::f_lhs(expr) |> + rlang::as_label() |> + dequote() |> # galah function + gbif_upper_case() # galah function + + rhs <- rlang::as_quosure(rlang::f_rhs(expr), + env = rlang::quo_get_env(x)) |> + switch_expr_type_pred() |> + as.character() + + # handle cases where someone passes a vector, i.e. year == c(2001, 2002) + # these can be parse as 'in' + if(length(rhs) > 1){ + list( + type = jsonlite::unbox("in"), + key = jsonlite::unbox(lhs), + values = rhs) + + # otherwise we assume they are length-1 and continue + }else{ + + # 'does not equal' is handled hierarchically + if(operator == "!="){ + result <- list(type = "equals", + key = lhs, + value = rhs) + list( + type = "not", + predicate = purrr::map(result, jsonlite::unbox)) + # everything else is flat + }else{ + operator_text <- switch(operator, + "==" = "equals", + "<" = "lessThan", + "<=" = "lessThanOrEquals", + ">" = "greaterThan", + ">=" = "greaterThanOrEquals") + purrr::map( + list(type = operator_text, + key = lhs, + value = rhs), + jsonlite::unbox) + } + } +} + +#' Internal function to handle variable naming +#' @noRd +#' @keywords internal +gbif_upper_case <- function(string){ + gsub("(?=[[:upper:]])", "_", string, perl = TRUE) |> + toupper() +} + +#' Handle & and | statements +#' @noRd +#' @keywords internal +parse_logical_pred <- function(x){ + # get provided info + provided_string <- rlang::quo_get_expr(x)[[1]] |> + rlang::as_string() + + # convert statements to strings + if(grepl("\\|{1,2}", provided_string)){ + logical_string <- "or" + }else{ + logical_string <- "and" + } + + # wrap later predicates in supplied boolean + subpredicates <- purrr::map(rlang::quo_get_expr(x)[-1], + \(a){ + rlang::as_quosure(a, + env = rlang::quo_get_env(x)) |> + switch_expr_type_pred() + }) + names(subpredicates) <- rep("predicate", length(subpredicates)) + list(type = jsonlite::unbox(logical_string), + predicates = subpredicates) +} + +#' Parse `call`s that contain brackets +#' Where this happens, they are always length-2, with "(" as the first entry. +#' @noRd +#' @keywords internal +parse_brackets_pred <- function(x){ + if(length(quo_get_expr(x)) != 2L){ + filter_error() + } + try_next_quosure_pred(x) +} + +#' Parse `call`s that contain exclamations +#' Where this happens, they are always length-2, with "(" as the first entry. +#' @noRd +#' @keywords internal +parse_exclamation_pred <- function(x){ + next_section <- try_next_quosure_pred(x) + next_length <- length(next_section) + if(next_length == 2 & !is.null(next_section$type)){ + if(next_section$type == "isNull"){ + next_section$type <- "isNotNull" + next_section + }else{ + list( + type = "not", + predicate = {next_section}) + } + }else{ + list( + type = "not", + predicate = {next_section}) + } +} + +#' A common pattern is to parse the first thing, then remove it and +#' keep going. This function is shorthand for that operation. +#' @noRd +#' @keywords internal +try_next_quosure_pred <- function(x){ + + ## This version originally used in exclamation and brackets code + rlang::as_quosure(rlang::quo_get_expr(x)[[-1]], + env = rlang::quo_get_env(x)) |> + switch_expr_type_pred() +} + +#' Parse `call`s that contain `is.na()` +#' Where this happens, they are always length-2, with "(" as the first entry. +#' @noRd +#' @keywords internal +parse_is_na_pred <- function(x){ + if(length(rlang::quo_get_expr(x)) != 2L){ + filter_error() + } + + variable <- rlang::as_label(rlang::quo_get_expr(x)[[2]]) |> + dequote() |> # galah function + gbif_upper_case() # galah function + + list(type = "isNull", + parameter = variable) +} + +#' Parse `call`s that contain `dplyr::between()` +#' Where this happens, they are always length-4, with "between" as the first entry. +#' @noRd +#' @keywords internal +parse_between_pred <- function(x){ + x_expr <- rlang::quo_get_expr(x) + if(length(x_expr) < 4L){ + filter_error() + } + + lhs <- x_expr[[2]] |> + rlang::as_label() |> + dequote() |> # galah function + gbif_upper_case() # galah function + + lower_bound <- list(type = "greaterThanOrEquals", + key = lhs, + value = rlang::as_label(x_expr[[3]])) |> + purrr::map(.f = jsonlite::unbox) + + upper_bound <- list(type = "lessThanOrEquals", + key = lhs, + value = rlang::as_label(x_expr[[4]])) |> + purrr::map(.f = jsonlite::unbox) + + list(type = jsonlite::unbox("and"), + predicates = list( + predicate = lower_bound, + predicate = upper_bound)) +} + +#' Parse `call`s that contain `%in%` +#' +#' Where this happens, they are always length-3, with "%in%" as the first entry. +#' @noRd +#' @keywords internal +parse_in_pred <- function(x){ + + # extract relevant information + lhs <- rlang::as_label(rlang::quo_get_expr(x)[[2]]) |> + dequote() |> # galah function + gbif_upper_case() # galah function + + rhs <- rlang::as_quosure(rlang::quo_get_expr(x)[[3]], + env = quo_get_env(x)) |> + switch_expr_type() + ## NOTE: Not clear that this is correct. + ## Should parse to: `"values": ["cat1", "cat2", "cat3"]` + + # handle apostrophes (') + if(any(stringr::str_detect(rhs, "\\'"))) { + rhs <- gsub("'", "\\\\'", rhs) + } + + # format as list + list( + type = jsonlite::unbox("in"), + key = jsonlite::unbox(lhs), + values = rhs) +} \ No newline at end of file diff --git a/R/print.R b/R/print.R index fcb3a0d8..ae05b51e 100644 --- a/R/print.R +++ b/R/print.R @@ -98,17 +98,21 @@ switch_slot_text <- function(x, a){ } }, "filter" = { - if(ncol(x[[a]]) > 2){ - df <- x[[a]][, 1:3] + if(inherits(x[[a]], "galah_filter_predicate")){ + glue::glue_collapse(unlist(x[[a]]), sep = " ") # messy but functional }else{ - df <- x[[a]] + if(ncol(x[[a]]) > 2){ + df <- x[[a]][, 1:3] + }else{ + df <- x[[a]] + } + if(nrow(df) > 1){ + df <- df[1, ] + } + glue_collapse( + apply(df, 1, function(b){paste(b, collapse = " ")}), + sep = " | ") } - if(nrow(df) > 1){ - df <- df[1, ] - } - glue_collapse( - apply(df, 1, function(b){paste(b, collapse = " ")}), - sep = " | ") }, "select" = x[[a]]$summary, "group_by" = glue_collapse(x[[a]]$name, sep = " | "), diff --git a/R/sysdata.rda b/R/sysdata.rda index 4140fd423a9b0ad1b7f86b8d9b2136b25ee4e114..64ccec244d0844743d246246df7b7e54a0893f47 100644 GIT binary patch literal 17845 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 Yf~$;0LMhOShyRPYBAh5lKWA!i0E=eI9{>OV literal 17808 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 diff --git a/data-raw/node_config.csv b/data-raw/node_config.csv index fac0873f..e8caf499 100644 --- a/data-raw/node_config.csv +++ b/data-raw/node_config.csv @@ -114,14 +114,14 @@ Global,data/occurrences,https://api.gbif.org/v1/occurrence/download/request,TRUE Global,data/occurrences-doi,https://api.gbif.org/v1/occurrence/download/{doi_string},TRUE Global,data/occurrences-count,https://api.gbif.org/v1/occurrence/search/predicate,TRUE Global,data/occurrences-count-groupby,https://api.gbif.org/v1/occurrence/search/predicate,TRUE -Global,data/species-count,https://api.gbif.org/v1/occurrence/search/predicate,TRUE Global,metadata/collections,https://api.gbif.org/v1/grscicoll/collection,TRUE Global,metadata/datasets,https://api.gbif.org/v1/dataset,TRUE -Global,metadata/fields-unnest,https://api.gbif.org/v1/occurrence/search/predicate,TRUE +Global,metadata/fields,https://api.gbif.org/v1/occurrence/term,TRUE +Global,metadata/fields-unnest,https://api.gbif.org/v1/occurrence/download/request,TRUE Global,metadata/identifiers,https://api.gbif.org/v1/species/{id},TRUE Global,metadata/providers,https://api.gbif.org/v1/organization,TRUE -Global,metadata/taxa-single,https://api.gbif.org/v1/species/match?verbose=FALSE&name={name},TRUE -Global,metadata/taxa-multiple,https://api.gbif.org/v1/species/match,TRUE +Global,metadata/taxa-single,https://api.gbif.org/v2/species/match?verbose=FALSE&scientificName={name},TRUE +Global,metadata/taxa-multiple,https://api.gbif.org/v2/species/match,TRUE Global,metadata/taxa-unnest,https://api.gbif.org/v1/species/{id}/children,TRUE Global,,https://api.gbif.org/v1/grscicoll/search,TRUE Global,,https://api.gbif.org/v1/dataset/search,TRUE @@ -175,8 +175,8 @@ Portugal,metadata/licences,https://imagens.gbif.pt/ws/licence,TRUE Portugal,metadata/media,https://imagens.gbif.pt/ws/imageInfoForList,TRUE Portugal,metadata/providers,https://metadados.gbif.pt/ws/dataProvider,TRUE Portugal,metadata/reasons,https://logger.gbif.pt/service/logger/reasons,TRUE -Portugal,metadata/taxa-single,https://api.gbif.org/v1/species/match?verbose=FALSE&name={name},TRUE -Portugal,metadata/taxa-multiple,https://api.gbif.org/v1/species/match,TRUE +Portugal,metadata/taxa-single,https://api.gbif.org/v2/species/match?verbose=FALSE&scientificName={name},TRUE +Portugal,metadata/taxa-multiple,https://api.gbif.org/v2/species/match,TRUE Portugal,metadata/taxa-unnest,https://api.gbif.org/v1/species/{id}/children,TRUE Spain,data/occurrences,https://registros-ws.gbif.es/occurrences/offline/download,TRUE Spain,data/occurrences-count,https://registros-ws.gbif.es/occurrences/search,TRUE diff --git a/tests/testthat/test-galah_filter-GBIF.R b/tests/testthat/test-galah_filter-GBIF.R new file mode 100644 index 00000000..1d7f9d59 --- /dev/null +++ b/tests/testthat/test-galah_filter-GBIF.R @@ -0,0 +1,81 @@ +galah_config(atlas = "GBIF") + +test_that("galah_filter() returns predicates for GBIF", { + x <- galah_filter(year == 2024) + inherits(x, "galah_filter_predicate") |> + expect_true() + expect_equal(names(x[[1]]), + c("type", "key", "value")) + values <- unlist(x[[1]]) + names(values) <- NULL + expect_equal(values, c("equals", "YEAR", "2024")) +}) + +# only the above test contains information rn + +test_that("filter() handles multiple queries including != for GBIF", { + result <- galah_call() |> + filter(year == 2024, countryCode != "AU") + + str(result) +}) + +test_that("filter() handles AND for GBIF", { + result <- galah_call() |> + filter(year == 2024 & countryCode != "AU") + + str(result) +}) + +# assertions? + +test_that("filter() handles `between()` for GBIF", { + galah_call() |> + filter(dplyr::between(year, 2010, 2020)) |> + str() +}) + + +test_that("filter() handles %in% for GBIF", { + galah_call() |> + filter(year %in% c(2010, 2020)) |> + str() +}) + +test_that("filter() handles !() for GBIF", { + galah_call() |> + filter(!(year %in% c(2010, 2020))) |> + str() +}) + +test_that("filter() handles is.na() for GBIF", { + galah_call() |> + filter(is.na(country)) |> + str() +}) + +test_that("filter() handles !is.na() for GBIF", { + galah_call() |> + filter(!is.na(country)) |> + str() +}) + +test_that("filter() handles c() for GBIF", { + # check when supplied directly + galah_call() |> + filter(country == c("AU", "UK", "AZ")) |> + str() + + # and as a vector + country_vector <- c("AU", "UK", "AZ") + galah_call() |> + filter(country == country_vector) |> + str() + # effectively parses this as 'in' as per GBIF instructions +}) + +# missing `within` (galah_geolocate()) + +# missing `geoDistance` (galah_radius()) + +galah_config(atlas = "ALA") \ No newline at end of file From 8653cb465d93613001b1580af46260674f9add5b Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Wed, 30 Jul 2025 18:27:42 +1000 Subject: [PATCH 09/94] First pass at adding `coalesce()` and `as_query()` #278 --- NAMESPACE | 27 +-- R/as_query-distributions.R | 39 ++++ R/{collapse_media.R => as_query-media.R} | 56 +++-- ...ollapse_metadata.R => as_query-metadata.R} | 116 +++------- ...e_occurrences.R => as_query-occurrences.R} | 57 ++--- ...s_count.R => as_query-occurrences_count.R} | 85 ++++---- ...ences_doi.R => as_query-occurrences_doi.R} | 38 ++-- R/{collapse_species.R => as_query-species.R} | 35 ++- ...ecies_count.R => as_query-species_count.R} | 19 +- R/{collapse_taxa.R => as_query-taxa.R} | 79 +++++-- R/{collapse_unnest.R => as_query-unnest.R} | 26 ++- R/as_query.R | 85 ++++++++ R/build_checks.R | 2 +- R/{build.R => build_query.R} | 44 ---- R/build_query_set.R | 199 ------------------ R/coalesce.R | 179 ++++++++++++++++ R/collapse.R | 66 +++--- R/collapse_distributions.R | 27 --- R/collect_taxa.R | 64 ++++-- ...arse_metadata.R => parse_metadata_lists.R} | 0 ...ata_unnest.R => parse_metadata_profiles.R} | 0 man/as_query.data_request.Rd | 39 ++++ man/coalesce.Rd | 40 ++++ man/collapse.data_request.Rd | 15 +- 24 files changed, 732 insertions(+), 605 deletions(-) create mode 100644 R/as_query-distributions.R rename R/{collapse_media.R => as_query-media.R} (71%) rename R/{collapse_metadata.R => as_query-metadata.R} (63%) rename R/{collapse_occurrences.R => as_query-occurrences.R} (70%) rename R/{collapse_occurrences_count.R => as_query-occurrences_count.R} (59%) rename R/{collapse_occurrences_doi.R => as_query-occurrences_doi.R} (52%) rename R/{collapse_species.R => as_query-species.R} (70%) rename R/{collapse_species_count.R => as_query-species_count.R} (79%) rename R/{collapse_taxa.R => as_query-taxa.R} (50%) rename R/{collapse_unnest.R => as_query-unnest.R} (78%) create mode 100644 R/as_query.R rename R/{build.R => build_query.R} (83%) delete mode 100644 R/build_query_set.R create mode 100644 R/coalesce.R delete mode 100644 R/collapse_distributions.R rename R/{parse_metadata.R => parse_metadata_lists.R} (100%) rename R/{parse_metadata_unnest.R => parse_metadata_profiles.R} (100%) create mode 100644 man/as_query.data_request.Rd create mode 100644 man/coalesce.Rd diff --git a/NAMESPACE b/NAMESPACE index 2fcfc069..1f56beb7 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -2,9 +2,17 @@ S3method(arrange,data_request) S3method(arrange,metadata_request) +S3method(as_query,data_request) +S3method(as_query,files_request) +S3method(as_query,metadata_request) +S3method(coalesce,data_request) +S3method(coalesce,files_request) +S3method(coalesce,metadata_request) S3method(collapse,data_request) S3method(collapse,files_request) S3method(collapse,metadata_request) +S3method(collapse,query) +S3method(collapse,query_set) S3method(collect,computed_query) S3method(collect,data_request) S3method(collect,files_request) @@ -34,12 +42,14 @@ S3method(slice_head,metadata_request) S3method(st_crop,data_request) export(apply_profile) export(arrange) +export(as_query) export(atlas_citation) export(atlas_counts) export(atlas_media) export(atlas_occurrences) export(atlas_species) export(atlas_taxonomy) +export(coalesce) export(collapse) export(collect) export(collect_media) @@ -138,19 +148,8 @@ importFrom(glue,glue) importFrom(glue,glue_collapse) importFrom(glue,glue_data) importFrom(graphics,identify) -importFrom(httr2,req_body_raw) -importFrom(httr2,req_error) -importFrom(httr2,req_headers) -importFrom(httr2,req_perform) -importFrom(httr2,req_timeout) -importFrom(httr2,request) -importFrom(httr2,resp_body_json) -importFrom(httr2,resp_body_string) importFrom(httr2,url_build) importFrom(httr2,url_parse) -importFrom(jsonlite,fromJSON) -importFrom(jsonlite,toJSON) -importFrom(jsonlite,unbox) importFrom(lifecycle,badge) importFrom(lifecycle,deprecate_stop) importFrom(lifecycle,deprecate_warn) @@ -158,12 +157,8 @@ importFrom(potions,brew) importFrom(potions,pour) importFrom(purrr,list_flatten) importFrom(purrr,list_transpose) -importFrom(purrr,map) importFrom(purrr,pluck) importFrom(purrr,pluck_depth) -importFrom(purrr,rate_backoff) -importFrom(purrr,rate_delay) -importFrom(purrr,rate_sleep) importFrom(rlang,.data) importFrom(rlang,abort) importFrom(rlang,as_label) @@ -213,8 +208,6 @@ importFrom(stringr,str_detect) importFrom(stringr,str_extract) importFrom(stringr,str_remove) importFrom(stringr,str_replace) -importFrom(stringr,str_replace_all) -importFrom(stringr,str_split) importFrom(stringr,str_to_title) importFrom(stringr,str_trim) importFrom(tibble,as_tibble) diff --git a/R/as_query-distributions.R b/R/as_query-distributions.R new file mode 100644 index 00000000..94d80ae2 --- /dev/null +++ b/R/as_query-distributions.R @@ -0,0 +1,39 @@ +#' Internal function to run `as_query()` for type `data/distributions` +#' @noRd +#' @keywords Internal +as_query_distributions_data <- function(.query){ + identify_supplied <- !is.null(.query$identify) + filter_supplied <- !is.null(.query$filter) + if(identify_supplied & filter_supplied){ + cli::cli_abort("`collapse(type = 'distributions')` only accepts one of `filter()` or `identify()`, not both") + } + if(identify_supplied){ + url <- url_lookup("data/distributions-taxa", + lsid = "`TAXON_PLACEHOLDER`") + }else if(filter_supplied){ + values <- strsplit(.query$filter$value, "\\|")[[1]] + urls <- purrr::map(values, \(x){ + url_lookup("data/distributions-id", id = x)}) |> + unlist() + url <- tibble::tibble(url = urls) + }else{ # i.e. neither supplied, get all distributions via ID column + url <- url_lookup("data/distributions-id") + } + result <- list(type = "data/distributions", + url = url, + headers = build_headers()) + class(result) <- "query" + return(result) +} + +#' Internal function to create a distributions query +#' @noRd +#' @keywords Internal +as_query_distributions_metadata <- function(.query){ + url <- url_lookup("metadata/distributions") + result <- list(type = "metadata/distributions", + url = url, + headers = build_headers()) + class(result) <- "query" + return(result) +} \ No newline at end of file diff --git a/R/collapse_media.R b/R/as_query-media.R similarity index 71% rename from R/collapse_media.R rename to R/as_query-media.R index e9128a8b..7f27c167 100644 --- a/R/collapse_media.R +++ b/R/as_query-media.R @@ -1,11 +1,8 @@ -#' Internal version of `collapse()` for `request_metadata(type = "media")` +#' Internal version of `as_query()` for `request_metadata(type = "media")` #' @param .query An object of class `metadata_request` (from `request_metadata()`) -#' @importFrom jsonlite toJSON -#' @importFrom rlang abort -#' @importFrom tibble tibble #' @noRd #' @keywords Internal -collapse_media <- function(.query){ +as_query_media_metadata <- function(.query){ # NOTE: # this function currently assumes that the user has passed an occurrence # tibble verbatim to filter, i.e. @@ -23,36 +20,33 @@ collapse_media <- function(.query){ media_ids <- media_ids[!is.na(media_ids)] names(media_ids) <- NULL }else if(any(colnames(occ) == "all_image_url")){ # Austria, Sweden, UK - media_ids <- pull(occ, "all_image_url") + media_ids <- dplyr::pull(occ, "all_image_url") media_ids <- media_ids[!is.na(media_ids)] names(media_ids) <- NULL }else{ - abort("Media metadata not found in supplied tibble") + cli::cli_abort("Media metadata not found in supplied tibble") } result <- list( type = "metadata/media", url = url_lookup("metadata/media"), headers = build_headers(), - body = toJSON(list(imageIds = media_ids)), + body = jsonlite::toJSON(list(imageIds = media_ids)), filter = .query$filter) class(result) <- "query" return(result) } -#' Internal version of `collapse()` for `request_files(type = "media")` +#' Internal version of `as_query()` for `request_files(type = "media")` #' @param .query An object of class `files_request` (from `request_files()`) -#' @importFrom rlang abort -#' @importFrom tibble tibble -#' @importFrom stringr str_detect #' @noRd #' @keywords Internal -collapse_media_files <- function(.query, +as_query_media_files <- function(.query, thumbnail = FALSE ){ # handle filters if(is.null(.query$filter)){ - abort("`collapse()` requires a `filter()` argument to function.") + cli::cli_abort("`collapse()` requires a `filter()` argument to function.") } df <- .query$filter if(any(colnames(df) == "media_id")){ @@ -60,13 +54,14 @@ collapse_media_files <- function(.query, }else if(any(colnames(df) == "image_id")){ identifiers <- df$image_id }else{ - abort("No valid identifiers found in supplied data.") + cli::cli_abort("No valid identifiers found in supplied data.") } path <- build_file_path(ids = identifiers, types = df$mimetype) if(any(colnames(df) == "image_url")){ url <- df$image_url }else{ - url <- url_lookup("files/images", id = identifiers) + url <- url_lookup("files/images", + id = identifiers) } # handle thumbnails if(thumbnail){ @@ -76,7 +71,7 @@ collapse_media_files <- function(.query, # create result result <- list( type = "files/media", - url = tibble(url = url, path = path), + url = tibble::tibble(url = url, path = path), headers = build_headers()) class(result) <- "query" @@ -106,13 +101,12 @@ build_media_id <- function(df){ } #' build file paths that include 1. path, 2. file name, 3. correct extension -#' @importFrom glue glue #' @noRd #' @keywords Internal build_file_path <- function(ids, types){ - path <- pour("package", "directory", .pkg = "galah") + path <- potions::pour("package", "directory", .pkg = "galah") ext <- build_file_extension(types) - glue("{path}/{ids}.{ext}") |> as.character() + glue::glue("{path}/{ids}.{ext}") |> as.character() } #' get extensions for media files @@ -120,15 +114,15 @@ build_file_path <- function(ids, types){ #' @noRd #' @keywords Internal build_file_extension <- function(x){ - case_match(x, - "image/jpg" ~ "jpg", - "image/jpeg" ~ "jpg", - "image/png" ~ "png", - "audio/mpeg" ~ "mpg", - "audio/x-wav" ~ "wav", - "audio/mp4" ~ "mp4", - "image/gif" ~ "gif", - "video/3gpp" ~ "3gp", - "video/quicktime" ~ "mov", - "audio/vnd.wave" ~ "wav") + dplyr::case_match(x, + "image/jpg" ~ "jpg", + "image/jpeg" ~ "jpg", + "image/png" ~ "png", + "audio/mpeg" ~ "mpg", + "audio/x-wav" ~ "wav", + "audio/mp4" ~ "mp4", + "image/gif" ~ "gif", + "video/3gpp" ~ "3gp", + "video/quicktime" ~ "mov", + "audio/vnd.wave" ~ "wav") } diff --git a/R/collapse_metadata.R b/R/as_query-metadata.R similarity index 63% rename from R/collapse_metadata.R rename to R/as_query-metadata.R index 4c371d49..cca77f8f 100644 --- a/R/collapse_metadata.R +++ b/R/as_query-metadata.R @@ -1,18 +1,20 @@ -#' Internal function to `collapse()` apis +# These functions are called by `as_query.metadata_request()` + +#' Internal function get a tibble of APIs #' @noRd #' @keywords Internal -collapse_apis <- function(){ +as_query_apis <- function(){ result <- list(type = "metadata/apis", data = "galah:::node_config") class(result) <- "query" return(result) } -#' Internal function to `collapse()` assertions +#' Internal function to create an assertions query #' NOTE: API doesn't accept any arguments - could post-filter for search #' @noRd #' @keywords Internal -collapse_assertions <- function(){ +as_query_assertions <- function(){ if(is_gbif()){ result <- list(type = "metadata/assertions", data = "galah:::gbif_internal_archived$assertions") @@ -31,27 +33,26 @@ collapse_assertions <- function(){ return(result) } -#' Internal function to `collapse()` atlases +#' Internal function to create an atlases query #' @noRd #' @keywords Internal -collapse_atlases <- function(){ +as_query_atlases <- function(){ result <- list(type = "metadata/atlases", data = "galah:::node_metadata") class(result) <- "query" return(result) } -#' Internal function to `collapse()` collections -#' @importFrom httr2 url_parse +#' Internal function to create a collections query #' @noRd #' @keywords Internal -collapse_collections <- function(.query){ +as_query_collections <- function(.query){ url <- url_lookup("metadata/collections") if(is_gbif() & !missing(.query)){ if(!is.null(.query$filter)){ - url <- url_parse(url) + url <- httr2::url_parse(url) url$query <- list(q = .query$filter$value[1]) - url <- url_build(url) + url <- httr2::url_build(url) } } result <- list(type = "metadata/collections", @@ -63,16 +64,16 @@ collapse_collections <- function(.query){ # NOTE: LA collectory functions do not accept `max` or `offset` # Therefore they cannot be paginated. GBIF collectory funs can. -#' Internal function to `collapse()` datasets +#' Internal function to create a datasets query #' @noRd #' @keywords Internal -collapse_datasets <- function(.query){ +as_query_datasets <- function(.query){ url <- url_lookup("metadata/datasets") if(is_gbif() & !missing(.query)){ if(!is.null(.query$filter)){ - url <- url_parse(url) + url <- httr2::url_parse(url) url$query <- list(q = .query$filter$value[1]) - url <- url_build(url) + url <- httr2::url_build(url) } } result <- list(type = "metadata/datasets", @@ -82,24 +83,12 @@ collapse_datasets <- function(.query){ return(result) } -#' Internal function to `collapse()` distributions -#' @noRd -#' @keywords Internal -collapse_distributions_metadata <- function(.query){ - url <- url_lookup("metadata/distributions") - result <- list(type = "metadata/distributions", - url = url, - headers = build_headers()) - class(result) <- "query" - return(result) -} - -#' Internal function to `collapse()` fields +#' Internal function to create a fields query #' Note that this is inconsistent with `show_all_fields()` which returns data #' from multiple APIs #' @noRd #' @keywords Internal -collapse_fields <- function(){ +as_query_fields <- function(){ if(is_gbif()){ result <- list(type = "metadata/fields", data = "galah:::gbif_internal_archived$fields") @@ -118,47 +107,10 @@ collapse_fields <- function(){ return(result) } -#' Internal function to `collapse()` identifiers -#' @noRd -#' @keywords Internal -collapse_identifiers <- function(.query){ - if(is.null(.query$filter)){ - url_list <- url_lookup("metadata/identifiers") - names(url_list) <- "no-name-supplied" - }else{ - search_terms <- .query$filter$value - query <- as.list(search_terms) - base_url <- url_lookup("metadata/identifiers") - # create query urls - if(grepl("api.gbif.org", base_url)){ - base_url <- utils::URLdecode(base_url) - urls <- glue::glue(base_url, id = query) |> - unlist() - }else{ - base_url <- url_parse(base_url) - urls <- lapply(query, - function(a, base_url){ - names(a) <- "taxonID" - base_url$query <- as.list(a) - url_build(base_url) - }, - base_url = base_url) |> - unlist() - } - } - # build object and return - result <- list(type = "metadata/identifiers", - url = tibble(url = urls, - search_term = search_terms), - headers = build_headers()) - class(result) <- "query" - return(result) -} - -#' Internal function to `collapse()` licences +#' Internal function to create a licences query #' @noRd #' @keywords Internal -collapse_licences <- function(){ +as_query_licences <- function(){ result <- list(type = "metadata/licences", url = url_lookup("metadata/licences"), headers = build_headers()) @@ -166,12 +118,12 @@ collapse_licences <- function(){ return(result) } -#' Internal function to `collapse()` lists +#' Internal function to create a lists query #' @noRd #' @keywords Internal -collapse_lists <- function(.query){ +as_query_lists <- function(.query){ url <- url_lookup("metadata/lists") |> - url_parse() + httr2::url_parse() url$query <- list(max = 10000) if(!missing(.query)){ if(!is.null(.query$slice)){ @@ -179,17 +131,17 @@ collapse_lists <- function(.query){ } } result <- list(type = "metadata/lists", - url = url_build(url), + url = httr2::url_build(url), headers = build_headers(), slot_name = "lists") class(result) <- "query" return(result) } -#' Internal function to `collapse()` profiles +#' Internal function to create a profiles query #' @noRd #' @keywords Internal -collapse_profiles <- function(){ +as_query_profiles <- function(){ update_needed <- internal_cache_update_needed("profiles") if(update_needed){ result <- list(type = "metadata/profiles", @@ -203,16 +155,16 @@ collapse_profiles <- function(){ return(result) } -#' Internal function to `collapse()` providers +#' Internal function to create a providers query #' @noRd #' @keywords Internal -collapse_providers <- function(.query){ +as_query_providers <- function(.query){ url <- url_lookup("metadata/providers") if(is_gbif() & !missing(.query)){ if(!is.null(.query$filter)){ - url <- url_parse(url) + url <- httr2::url_parse(url) url$query <- list(q = .query$filter$value[1]) - url <- url_build(url) + url <- httr2::url_build(url) } } result <- list(type = "metadata/providers", @@ -222,10 +174,10 @@ collapse_providers <- function(.query){ return(result) } -#' Internal function to `collapse()` reasons +#' Internal function to create a reasons query #' @noRd #' @keywords Internal -collapse_reasons <- function(){ +as_query_reasons <- function(){ update_needed <- internal_cache_update_needed("reasons") if(update_needed){ result <- list(type = "metadata/reasons", @@ -239,10 +191,10 @@ collapse_reasons <- function(){ return(result) } -#' Internal function to `collapse()` ranks +#' Internal function to create a ranks query #' @noRd #' @keywords Internal -collapse_ranks <- function(){ +as_query_ranks <- function(){ if(is_gbif()){ result <- list(type = "metadata/ranks", data = "galah:::gbif_internal_archived$ranks") diff --git a/R/collapse_occurrences.R b/R/as_query-occurrences.R similarity index 70% rename from R/collapse_occurrences.R rename to R/as_query-occurrences.R index dfccb91c..511f261e 100644 --- a/R/collapse_occurrences.R +++ b/R/as_query-occurrences.R @@ -1,24 +1,23 @@ -#' Internal function to `collapse()` for `type = "occurrences"` -#' @importFrom rlang abort +#' Internal function to convert `data_request` with `type = "occurrences"` to a `query` #' @noRd #' @keywords Internal -collapse_occurrences <- function(.query){ +as_query_occurrences <- function(.query){ if(is.null(.query$filter) & is.null(.query$identify) & is.null(.query$geolocate)){ - abort("No filters supplied to `collapse()` with `type = \"occurrences\"`") + cli::cli_abort("No filters supplied to `collapse()` with `type = \"occurrences\"`") } - switch(pour("atlas", "region"), - "United Kingdom" = collapse_occurrences_uk(.query), - "Global" = collapse_occurrences_gbif(.query), - collapse_occurrences_la(.query)) + switch(potions::pour("atlas", "region"), + "United Kingdom" = as_query_occurrences_uk(.query), + "Global" = as_query_occurrences_gbif(.query), + as_query_occurrences_la(.query)) } #' calculate the query to be returned for the UK atlas #' @param .query An object of class `data_request()` #' @noRd #' @keywords Internal -collapse_occurrences_uk <- function(.query){ +as_query_occurrences_uk <- function(.query){ # set default columns if(is.null(.query$select)){ .query$select <- galah_select(group = "basic") @@ -26,7 +25,7 @@ collapse_occurrences_uk <- function(.query){ # build a url # NOTE: providing an email blocks this from executing (2023-08-30) url <- url_lookup("data/occurrences") |> - url_parse() + httr2::url_parse() url$query <- c(build_query(identify = .query$identify, filter = .query$filter, location = .query$geolocate, @@ -35,12 +34,12 @@ collapse_occurrences_uk <- function(.query){ qa = "`ASSERTIONS_PLACEHOLDER`", sourceTypeId = source_type_id_lookup("United Kingdom"), fileType = "csv", - reasonTypeId = pour("user", "download_reason_id"), + reasonTypeId = potions::pour("user", "download_reason_id"), dwcHeaders = "true") # build output result <- list( type = "data/occurrences", - url = url_build(url), + url = httr2::url_build(url), headers = build_headers(), filter = .query$filter, select = .query$select) @@ -49,23 +48,29 @@ collapse_occurrences_uk <- function(.query){ } #' calculate the query to be returned for GBIF -#' @importFrom glue glue #' @noRd #' @keywords Internal -collapse_occurrences_gbif <- function(.query, format = "SIMPLE_CSV"){ +as_query_occurrences_gbif <- function(.query, format = "SIMPLE_CSV"){ + + body <- build_predicates( + .query$filter, + .query$identify, + .query$geolocate, + format = "SIMPLE_CSV") + # deal with user-specified taxonomic names if(!is.null(identify)){ .query$filter <- rbind( .query$filter, - data.frame(variable = "taxonKey", - logical = "==", - value = "`TAXON_PLACEHOLDER`", - query = "")) + tibble::tibble(variable = "taxonKey", + logical = "==", + value = "`TAXON_PLACEHOLDER`", + query = "")) } # get user string - username <- pour("user", "username", .pkg = "galah") - password <- pour("user", "password", .pkg = "galah") - user_string <- glue("{username}:{password}") + username <- potions::pour("user", "username", .pkg = "galah") + password <- potions::pour("user", "password", .pkg = "galah") + user_string <- glue::glue("{username}:{password}") # build object result <- list( type = "data/occurrences", @@ -89,7 +94,7 @@ collapse_occurrences_gbif <- function(.query, format = "SIMPLE_CSV"){ #' @param .query An object of class `data_request()` #' @noRd #' @keywords Internal -collapse_occurrences_la <- function(.query){ +as_query_occurrences_la <- function(.query){ # set default columns if(is.null(.query$select)){ .query$select <- galah_select(group = "basic") @@ -105,8 +110,8 @@ collapse_occurrences_la <- function(.query){ emailNotify = email_notify(), sourceTypeId = {pour("atlas", "region") |> source_type_id_lookup()}, - reasonTypeId = pour("user", "download_reason_id"), - email = pour("user", "email"), + reasonTypeId = potions::pour("user", "download_reason_id"), + email = potions::pour("user", "email"), dwcHeaders = "true") # DOI conditional on this service being offered if (!is.null(.query$mint_doi) & @@ -115,12 +120,12 @@ collapse_occurrences_la <- function(.query){ } # build url url <- url_lookup("data/occurrences") |> - url_parse() + httr2::url_parse() url$query <- query # build output result <- list( type = "data/occurrences", - url = url_build(url), + url = httr2::url_build(url), headers = build_headers(), filter = .query$filter, select = .query$select) diff --git a/R/collapse_occurrences_count.R b/R/as_query-occurrences_count.R similarity index 59% rename from R/collapse_occurrences_count.R rename to R/as_query-occurrences_count.R index ee91eab7..0ddb05d4 100644 --- a/R/collapse_occurrences_count.R +++ b/R/as_query-occurrences_count.R @@ -2,13 +2,14 @@ #' @keywords Internal #' @param .query an object of class `data_request` #' @noRd -collapse_occurrences_count <- function(.query){ +as_query_occurrences_count <- function(.query){ + # NOTE: This is quite weird syntax; consider revising if(is_gbif()){ - function_name <- "collapse_occurrences_count_gbif" - arg_names <- names(formals(collapse_occurrences_count_gbif)) + function_name <- "as_query_occurrences_count_gbif" + arg_names <- names(formals(as_query_occurrences_count_gbif)) }else{ - function_name <- "collapse_occurrences_count_atlas" - arg_names <- names(formals(collapse_occurrences_count_atlas)) + function_name <- "as_query_occurrences_count_atlas" + arg_names <- names(formals(as_query_occurrences_count_atlas)) } custom_call <- .query[names(.query) %in% arg_names] class(custom_call) <- "data_request" @@ -16,11 +17,9 @@ collapse_occurrences_count <- function(.query){ } #' collapse for counts on LAs -#' @importFrom httr2 url_build -#' @importFrom httr2 url_parse #' @keywords Internal #' @noRd -collapse_occurrences_count_atlas <- function(identify = NULL, +as_query_occurrences_count_atlas <- function(identify = NULL, filter = NULL, geolocate = NULL, data_profile = NULL, @@ -35,33 +34,33 @@ collapse_occurrences_count_atlas <- function(identify = NULL, # set behaviour depending on `group_by()` if(is.null(group_by)){ url <- url_lookup("data/occurrences-count") |> - url_parse() + httr2::url_parse() url$query <- c(query, pageSize = 0) result <- list(type = "data/occurrences-count", - url = url_build(url), + url = httr2::url_build(url), headers = build_headers(), filter = filter, slot_name = "totalRecords", expand = FALSE) }else{ url <- url_lookup("data/occurrences-count-groupby") |> - url_parse() + httr2::url_parse() facets <- as.list(group_by$name) names(facets) <- rep("facets", length(facets)) if(is.null(slice)){ # limits to 10,000 rows # TODO: This should ultimately be set by `slice` or `atlas_counts(limit = )`, not internally. # Will need updating to avoid hidden limit setting here & in `compute_occurrences_count()` - slice <- tibble(slice_n = 1e4, slice_called = FALSE) + slice <- tibble::tibble(slice_n = 1e4, slice_called = FALSE) } if(is.null(arrange)){ - arrange <- tibble(variable = "count", direction = "descending") + arrange <- tibble::tibble(variable = "count", direction = "descending") } - slice_arrange <- bind_cols(slice, arrange) + slice_arrange <- dplyr::bind_cols(slice, arrange) arrange_list <- check_slice_arrange(slice_arrange) url$query <- c(query, facets, arrange_list) result <- list(type = "data/occurrences-count-groupby", - url = url_build(url), + url = httr2::url_build(url), headers = build_headers(), filter = filter, expand = ifelse(length(facets) > 1, TRUE, FALSE), @@ -76,40 +75,54 @@ collapse_occurrences_count_atlas <- function(identify = NULL, #' @importFrom httr2 url_parse #' @keywords Internal #' @noRd -collapse_occurrences_count_gbif <- function(identify = NULL, +as_query_occurrences_count_gbif <- function(identify = NULL, filter = NULL, geolocate = NULL, group_by = NULL, slice = NULL ){ + # compile supplied arguments into a list + # honestly this is a little messy, but hard to call build_predicates() + # at this stage, as taxonomic info hasn't yet been parsed + predicates_info <- list(identify = identify, + filter = filter, + geolocate = geolocate, + group_by = group_by, + slice = ifelse(is.null(slice), + tibble::tibble(slice_n = 30, slice_called = FALSE), + slice), + limit = 0) + # get relevant information if(is.null(group_by)){ - url <- url_lookup("data/occurrences-count") |> - url_parse() - url$query <- c( - build_query_gbif(identify, filter, geolocate), - limit = 0) result <- list( type = "data/occurrences-count", - url = url_build(url), + url = url_lookup("data/occurrences-count"), + predicates = predicates_info, slot_name = "count", expand = FALSE) # add facets }else{ - result <- list(type = "data/occurrences-count-groupby") - url <- url_lookup("data/occurrences-count") |> - url_parse() - facets <- as.list(group_by$name) - names(facets) <- rep("facet", length(facets)) - if(is.null(slice)){ - slice <- tibble(slice_n = 30, slice_called = FALSE) - } - url$query <- c(build_query_gbif(identify, filter, geolocate), - limit = 0, - facets, - facetLimit = slice$slice_n) - result$url <- url_build(url) - result$expand <- ifelse(length(facets) > 1, TRUE, FALSE) + result <- list( + type = "data/occurrences-count", + url = url_lookup("data/occurrences-count-groupby"), + predicates = predicates_info, + slot_name = "count", + expand = FALSE) + # result <- list(type = "data/occurrences-count-groupby") + # url <- url_lookup("data/occurrences-count") |> + # url_parse() + # facets <- as.list(group_by$name) + # names(facets) <- rep("facet", length(facets)) + # if(is.null(slice)){ + # slice <- tibble(slice_n = 30, slice_called = FALSE) + # } + # url$query <- c(build_query_gbif(identify, filter, geolocate), + # limit = 0, + # facets, + # facetLimit = slice$slice_n) + # result$url <- url_build(url) + # result$expand <- ifelse(length(facets) > 1, TRUE, FALSE) } # aggregate and return result$headers <- build_headers() diff --git a/R/collapse_occurrences_doi.R b/R/as_query-occurrences_doi.R similarity index 52% rename from R/collapse_occurrences_doi.R rename to R/as_query-occurrences_doi.R index 33601dbe..5fd9ca03 100644 --- a/R/collapse_occurrences_doi.R +++ b/R/as_query-occurrences_doi.R @@ -1,26 +1,24 @@ -#' Internal function to `collapse()` for `type = "doi"` -#' @importFrom rlang abort -#' @importFrom stringr str_split +#' Internal function to convert `data_request` with `type = "doi"` to a `query` #' @noRd #' @keywords Internal -collapse_occurrences_doi <- function(.query, error_call = caller_env()){ +as_query_occurrences_doi <- function(.query, + error_call = caller_env()){ if(is.null(.query$filter)){ - abort("A DOI must be specified using `filter(doi == \"my-doi-here\")`.", - call = error_call) + cli::cli_abort("A DOI must be specified using `filter(doi == \"my-doi-here\")`.", + call = error_call) } if(is.null(.query$filter$variable) && .query$filter$variable != "doi"){ - abort("No DOI has been supplied.", - call = error_call) + cli::cli_abort("No DOI has been supplied.", + call = error_call) } - if(pour("atlas", "acronym") != "ALA"){ - bullets <- c( + atlas <- potions::pour("atlas", "acronym") + if(!(atlas %in% c("ALA", "GBIF"))){ + c( "DOI downloads not supported by selected atlas.", - i = "`request_data(type = \"occurrences-doi\")` has only been implemented for ALA." - ) - abort(bullets, - call = error_call) + i = "`request_data(type = \"occurrences-doi\")` has only been implemented for ALA & GBIF") |> + cli::cli_abort(call = error_call) } doi <- .query$filter$value[[1]] @@ -32,17 +30,17 @@ collapse_occurrences_doi <- function(.query, error_call = caller_env()){ # extract useful part of DOI doi_str <- stringr::str_split(doi, "ala.")[[1]][2] - if (is.na(doi_str)) { - bullets <- c( + if(is.na(doi_str)){ + c( "DOI has not been generated by the ALA.", - i = "DOIs created by the ALA have a prefix of 10.26197/ala." - ) - abort(bullets, call = error_call) + i = "DOIs created by the ALA have a prefix of 10.26197/ala.") |> + cli::cli_abort(call = error_call) } result <- list( type = "data/occurrences-doi", - url = url_lookup("data/occurrences-doi", doi_string = doi_str), + url = url_lookup("data/occurrences-doi", + doi_string = doi_str), headers = build_headers(), download = TRUE) class(result) <- "data_query" diff --git a/R/collapse_species.R b/R/as_query-species.R similarity index 70% rename from R/collapse_species.R rename to R/as_query-species.R index a0bf0307..a9d8a0ec 100644 --- a/R/collapse_species.R +++ b/R/as_query-species.R @@ -1,14 +1,14 @@ -#' Internal function to `collapse()` for `type = "species"` +#' Internal function to convert `data_request` with `type = "species"` to a `query` #' @noRd #' @keywords Internal -collapse_species <- function(.query){ +as_query_species <- function(.query){ if(is_gbif()){ - result <- collapse_occurrences_gbif(.query, + result <- as_query_occurrences_gbif(.query, format = "SPECIES_LIST") result$type <- "data/species" result }else{ - collapse_species_atlas(.query) + as_query_species_atlas(.query) } } @@ -23,8 +23,8 @@ collapse_species_atlas <- function(.query){ # determine whether to use `group_by` or `species_facets()` if(is.null(.query$group_by)){ - .query$group_by <- tibble(name = species_facets(), - type = "field") + .query$group_by <- tibble::tibble(name = species_facets(), + type = "field") } # build a query @@ -36,18 +36,18 @@ collapse_species_atlas <- function(.query){ emailNotify = email_notify(), sourceTypeId = 2004, reasonTypeId = pour("user", "download_reason_id"), - email = pour("user", "email"), + email = potions::pour("user", "email"), facets = .query$group_by$name, parse_select_species(.query$select) ) # build url url <- url_lookup("data/species") |> - url_parse() + httr2::url_parse() url$query <- query # build output result <- list( type = "data/species", - url = url_build(url), + url = httr2::url_build(url), headers = build_headers(), filter = .query$filter, group_by = .query$group_by, @@ -57,16 +57,15 @@ collapse_species_atlas <- function(.query){ } #' parse `select()` for `atlas_species()` -#' @importFrom rlang warn -#' @importFrom glue glue -#' @importFrom glue glue_collapse #' @noRd #' @keywords Internal parse_select_species <- function(.select){ # parse labels for supplied field names - quosure_check <- lapply(.select, is_quosure) |> unlist() + quosure_check <- purrr::map(.select, is_quosure) |> + unlist() if(any(quosure_check)){ - named_fields <- lapply(.select[quosure_check], as_label) |> unlist() + named_fields <- purrr::map(.select[quosure_check], as_label) |> + unlist() }else{ named_fields <- NULL } @@ -86,10 +85,10 @@ parse_select_species <- function(.select){ # check for unexpected names name_check <- !(named_fields %in% c("counts", "synonyms", "lists")) if(any(name_check)){ - unexpected_names <- glue_collapse(named_fields[name_check], last = " and ") - bullets <- c("When type = 'species', `select()` only accepts 'counts', 'synonyms' or 'lists' as valid fields.", - i = glue("Unexpected fields: {unexpected_names}")) - warn(bullets) + unexpected_names <- glue::glue_collapse(named_fields[name_check], last = " and ") + c("When type = 'species', `select()` only accepts 'counts', 'synonyms' or 'lists' as valid fields.", + i = glue("Unexpected fields: {unexpected_names}")) |> + cli::cli_warn(bullets) } # parse 'correct' names if(any(named_fields == "counts")){result$count <- "true"} diff --git a/R/collapse_species_count.R b/R/as_query-species_count.R similarity index 79% rename from R/collapse_species_count.R rename to R/as_query-species_count.R index a5a8e345..6ee26f49 100644 --- a/R/collapse_species_count.R +++ b/R/as_query-species_count.R @@ -4,11 +4,10 @@ #' @noRd collapse_species_count <- function(.query){ if(is_gbif()){ - abort("`count()` is not supported for GBIF with type = 'species'") - ## TRUE? + cli::cli_abort("`count()` is not supported for GBIF with type = 'species'") }else{ - function_name <- "collapse_species_count_atlas" - arg_names <- names(formals(collapse_species_count_atlas)) + function_name <- "as_query_species_count_atlas" + arg_names <- names(formals(as_query_species_count_atlas)) } custom_call <- .query[names(.query) %in% arg_names] class(custom_call) <- "data_request" @@ -16,8 +15,6 @@ collapse_species_count <- function(.query){ } #' collapse for counts on LAs -#' @importFrom httr2 url_build -#' @importFrom httr2 url_parse #' @keywords Internal #' @noRd collapse_species_count_atlas <- function(identify = NULL, @@ -29,7 +26,7 @@ collapse_species_count_atlas <- function(identify = NULL, arrange = NULL ){ url <- url_lookup("data/species-count") |> - url_parse() + httr2::url_parse() query <- build_query(identify, filter, geolocate, @@ -48,16 +45,16 @@ collapse_species_count_atlas <- function(identify = NULL, facets <- c(as.list(group_by$name), species_facets()) names(facets) <- rep("facets", length(facets)) if(is.null(slice)){ - slice <- tibble(slice_n = 30, slice_called = FALSE) + slice <- tibble::tibble(slice_n = 30, slice_called = FALSE) } if(is.null(arrange)){ - arrange <- tibble(variable = "count", direction = "descending") + arrange <- tibble::tibble(variable = "count", direction = "descending") } - slice_arrange <- bind_cols(slice, arrange) + slice_arrange <- dplyr::bind_cols(slice, arrange) arrange_list <- check_slice_arrange(slice_arrange) url$query <- c(query, facets, arrange_list) result <- list(type = "data/species-count", - url = url_build(url), + url = httr2::url_build(url), headers = build_headers(), filter = filter, expand = TRUE, diff --git a/R/collapse_taxa.R b/R/as_query-taxa.R similarity index 50% rename from R/collapse_taxa.R rename to R/as_query-taxa.R index a72e05fe..10cf8d12 100644 --- a/R/collapse_taxa.R +++ b/R/as_query-taxa.R @@ -1,24 +1,25 @@ -#' Internal function to `collapse()` for `type = "taxa"`, `method = "metadata` +#' Internal function to run `as_query()` for `type = "taxa"`, `method = "metadata` #' @noRd #' @keywords Internal -collapse_taxa <- function(.query){ +as_query_taxa <- function(.query){ if(is.null(.query$identify)){ result <- list(type = "metadata/taxa") class(result) <- "query" result }else{ - if(ncol(.query$identify) > 1 | colnames(.query$identify)[1] != "search_term"){ - collapse_taxa_multiple(.query) + if(ncol(.query$identify) > 1 | + colnames(.query$identify)[1] != "search_term"){ + as_query_taxa_multiple(.query) }else{ - collapse_taxa_single(.query) + as_query_taxa_single(.query) } } } -#' Internal function to `collapse()` for a single taxonomic name +#' Internal function to `as_query()` for a single taxonomic name #' @noRd #' @keywords Internal -collapse_taxa_single <- function(.query){ +as_query_taxa_single <- function(.query){ urls <- lapply(.query$identify$search_term, function(a){url_lookup("metadata/taxa-single", name = a)}) |> @@ -26,8 +27,8 @@ collapse_taxa_single <- function(.query){ search_terms <- .query$identify$search_term # build object and return result <- list(type = "metadata/taxa-single", - url = tibble(url = urls, - search_term = search_terms), + url = tibble::tibble(url = urls, + search_term = search_terms), headers = build_headers()) class(result) <- "query" return(result) @@ -36,10 +37,11 @@ collapse_taxa_single <- function(.query){ #' Internal function to `collapse()` where multiple taxonomic levels are given #' @noRd #' @keywords Internal -collapse_taxa_multiple <- function(.query){ +as_query_taxa_multiple <- function(.query){ # get a data.frame, enforce use of accepted taxon levels identify_df <- .query$identify - colnames(identify_df) <- tolower(colnames(identify_df)) + colnames(identify_df) <- colnames(identify_df) |> + tolower() identify_df <- identify_df |> dplyr::select(dplyr::any_of(accepted_ranks())) @@ -47,16 +49,16 @@ collapse_taxa_multiple <- function(.query){ split_list <- split(.query$identify, seq_len(nrow(.query$identify))) base_url <- url_lookup("metadata/taxa-multiple") |> - url_parse() - urls <- lapply(split_list, - function(a, base_url){ - base_url$query <- as.list(a) - url_build(base_url) - }, - base_url = base_url) |> + httr2::url_parse() + urls <- purrr::map(split_list, + function(a, base_url){ + base_url$query <- as.list(a) + httr2::url_build(base_url) + }, + base_url = base_url) |> unlist() - search_terms <- lapply(split_list, - function(a){paste(a, collapse = "_")}) |> + search_terms <- purrr::map(split_list, + function(a){glue::glue_collapse(a, sep = "_")}) |> unlist() # build object and return @@ -68,6 +70,43 @@ collapse_taxa_multiple <- function(.query){ return(result) } +#' Internal function to create an identifiers query +#' @noRd +#' @keywords Internal +as_query_identifiers <- function(.query){ + if(is.null(.query$filter)){ + url_list <- url_lookup("metadata/identifiers") + names(url_list) <- "no-name-supplied" + }else{ + search_terms <- .query$filter$value + query <- as.list(search_terms) + base_url <- url_lookup("metadata/identifiers") + # create query urls + if(grepl("api.gbif.org", base_url)){ + base_url <- utils::URLdecode(base_url) + urls <- glue::glue(base_url, id = query) |> + unlist() + }else{ + base_url <- httr2::url_parse(base_url) + urls <- purrr::map(query, + function(a, base_url){ + names(a) <- "taxonID" + base_url$query <- as.list(a) + httr2::url_build(base_url) + }, + base_url = base_url) |> + unlist() + } + } + # build object and return + result <- list(type = "metadata/identifiers", + url = tibble(url = urls, + search_term = search_terms), + headers = build_headers()) + class(result) <- "query" + return(result) +} + #' Internal function to accept only specific taxon ranks for searching #' @noRd #' @keywords Internal diff --git a/R/collapse_unnest.R b/R/as_query-unnest.R similarity index 78% rename from R/collapse_unnest.R rename to R/as_query-unnest.R index aa4082ec..7bb58725 100644 --- a/R/collapse_unnest.R +++ b/R/as_query-unnest.R @@ -1,12 +1,10 @@ -#' Internal function to run `collapse()` for +#' Internal function to run `as_query()` for #' `request_metadata(type = "fields") |> unnest()` -#' @importFrom httr2 url_build -#' @importFrom httr2 url_parse #' @noRd #' @keywords Internal -collapse_fields_unnest <- function(.query){ +as_query_fields_unnest <- function(.query){ url <- url_lookup("metadata/fields-unnest") |> - url_parse() + httr2::url_parse() if(is_gbif()){ url$query <- list( limit = 0, @@ -19,20 +17,20 @@ collapse_fields_unnest <- function(.query){ } result <- list( type = "metadata/fields-unnest", - url = url_build(url)) + url = httr2::url_build(url)) class(result) <- "query" return(result) } -#' Internal function to run `collapse()` for +#' Internal function to run `as_query()` for #' `request_metadata(type = "lists") |> unnest()` #' @noRd #' @keywords Internal -collapse_lists_unnest <- function(.query){ +as_query_lists_unnest <- function(.query){ url <- url_lookup("metadata/lists-unnest", list_id = .query$filter$value[1]) |> - url_parse() + httr2::url_parse() # Request additional raw fields if `show_fields(all_fields = TRUE)` if(isTRUE(attributes(.query)$all_fields)) { @@ -48,16 +46,16 @@ collapse_lists_unnest <- function(.query){ result <- list( type = "metadata/lists-unnest", - url = url_build(url)) + url = httr2::url_build(url)) class(result) <- "query" return(result) } -#' Internal function to run `collapse()` for +#' Internal function to run `as_query()` for #' `request_metadata(type = "profiles") |> unnest()` #' @noRd #' @keywords Internal -collapse_profiles_unnest <- function(.query){ +as_query_profiles_unnest <- function(.query){ result <- list( type = "metadata/profiles-unnest", url = url_lookup("metadata/profiles-unnest", @@ -66,12 +64,12 @@ collapse_profiles_unnest <- function(.query){ return(result) } -#' Internal function to `collapse()` for +#' Internal function to `as_query()` for #' `request_metadata(type = "taxa") |> unnest()` #' @importFrom rlang abort #' @noRd #' @keywords Internal -collapse_taxa_unnest <- function(.query){ +as_query_taxa_unnest <- function(.query){ if(!is.null(.query$filter)){ id <- .query$filter$value[1] }else if(!is.null(.query$identify)){ diff --git a/R/as_query.R b/R/as_query.R new file mode 100644 index 00000000..5402f90a --- /dev/null +++ b/R/as_query.R @@ -0,0 +1,85 @@ +#' Convert an object to class `query` +#' +#' Functionally similar to [collapse()], but without passing through +#' [coalesce()] first. Primarily an internal function, but exported for +#' clarity and debugging purposes. +#' @details +#' Typically, queries in galah are piped using `galah_call()`, which builds +#' an object of class `"data_request"`, `"metadata_request"` or `"files_request"`. +#' This parses to an object of class `"query"` via [collapse()]. However, +#' [collapse()] first calls [coalesce()], which expands to a `query_set` +#' _before_ evaluating [collapse()]. In this context, `as_query()` serves two +#' functions: externally, it can be called to convert directly to a `query` +#' without running checks; and internally it allows a query to be appended +#' to a `query_set` without calling [collapse()], which would begin an +#' infinite loop (because `collapse()` calls `coalesce()`). +#' +#' For simple cases, this gives the same result as running +#' [collapse()] while the `run_checks` argument of [galah_config()] is set to +#' `FALSE`, but is slightly faster. For complex cases, however, it is likely +#' to generate irresolvable API calls, because e.g. taxonomic queries are not +#' parsed before the URL is built. It should therefore be used with care. +#' @name as_query.data_request +#' @order 1 +#' @export +as_query <- function(x, ...){ + UseMethod("as_query") +} + +#' @rdname as_query.data_request +#' @order 2 +#' @export +as_query.data_request <- function(x, ...){ + switch(x$type, + "occurrences" = { + if(is.null(x$group_by)){ + as_query_occurrences(x) + }else{ + as_query_species(x) + } + }, + "occurrences-count" = as_query_occurrences_count(x), + "occurrences-doi" = as_query_occurrences_doi(x), + "species" = as_query_species(x), + "species-count" = as_query_species_count(x), + "distributions" = as_query_distributions_data(x), + cli::cli_abort("unrecognised 'type'")) |> + structure(class = c("query", "list")) +} + +#' @rdname as_query.data_request +#' @order 3 +#' @export +as_query.metadata_request <- function(x, ...){ + switch(x$type, + "apis" = as_query_apis(), + "assertions" = as_query_assertions(), + "atlases" = as_query_atlases(), + "collections" = as_query_collections(x), + "datasets" = as_query_datasets(x), + "distributions" = as_query_distributions_metadata(x), + "fields" = as_query_fields(), + "fields-unnest" = as_query_fields_unnest(x), + "licences" = as_query_licences(), + "lists" = as_query_lists(x), + "lists-unnest" = as_query_lists_unnest(x), + "media" = as_query_media_metadata(x), + "profiles" = as_query_profiles(), + "profiles-unnest" = as_query_profiles_unnest(x), + "providers" = as_query_providers(x), + "ranks" = as_query_ranks(), + "reasons" = as_query_reasons(), + "taxa" = as_query_taxa(x), + "taxa-unnest" = as_query_taxa_unnest(x), + "identifiers" = as_query_identifiers(x), + cli::cli_abort("unrecognised 'type'") + ) |> + structure(class = c("query", "list")) +} + +#' @rdname as_query.data_request +#' @order 4 +#' @export +as_query.files_request <- function(x, ...){ + browser() +} \ No newline at end of file diff --git a/R/build_checks.R b/R/build_checks.R index 907442d6..594f00ac 100644 --- a/R/build_checks.R +++ b/R/build_checks.R @@ -43,7 +43,7 @@ parse_metadata <- function(names_vec, .query){ !grepl("-unnest$", names_vec) # unnest functions only parse in collect() if(any(metadata_lookup)){ metadata_names <- names_vec[metadata_lookup] - metadata_results <- lapply(.query[which(metadata_lookup)], collect) + metadata_results <- purrr::map(.query[which(metadata_lookup)], collect) names(metadata_results) <- metadata_names metadata_results }else{ diff --git a/R/build.R b/R/build_query.R similarity index 83% rename from R/build.R rename to R/build_query.R index 41ddda02..e60b5479 100644 --- a/R/build.R +++ b/R/build_query.R @@ -78,50 +78,6 @@ build_query <- function(identify = NULL, build_single_fq(query) } -#' Build query list from constituent arguments for GBIF only -#' @importFrom glue glue_data -#' @importFrom potions pour -#' @noRd -#' @keywords Internal -build_query_gbif <- function(identify = NULL, - filter = NULL, - location = NULL){ - if(is.null(identify)) { - taxa_query <- list(taxonKey = 1) - }else{ - taxa_query <- list(taxonKey = "`TAXON_PLACEHOLDER`") - } - # filter - if(is.null(filter)) { - filter_query <- NULL - }else{ - if(!inherits(filter, "data.frame")){ - abort("`filter` must be a `data.frame` or `tibble`") - } - if(nrow(filter) == 0) { - filter_query <- NULL - }else{ - filter_query <- build_filter_query(filter) - } - } - # merge - query <- c(taxa_query, filter_query) - # geographic stuff - if (!is.null(location)) { - # if location is for a point radius vs polygon/bbox - if(!is.null(names(location))){ - if(all(!is.null(location$radius))) { # `galah_radius()` will always pass radius argument - query$geoDistance <- glue_data(location, - "{lat},{lon},{radius}km") - }else - query$geometry <- location - } else { - query$geometry <- location - } - } - query -} - #' collapse multiple fq args into one #' @keywords Internal #' @noRd diff --git a/R/build_query_set.R b/R/build_query_set.R deleted file mode 100644 index 6ff20c79..00000000 --- a/R/build_query_set.R +++ /dev/null @@ -1,199 +0,0 @@ -#' Build a query set -#' -#' This function is designed to be called before `collapse()`. Primarily used -#' internally and for debugging, it shows the maximum set of APIs that need -#' to be evaluated to answer the question posed by the user. -#' @param x An object of class `data_request`, `metadata_request` or -#' `files_request` (see `galah_call()`). -#' @param mint_doi `logical`: by default no DOI will be generated. Set to -#' `TRUE` if you intend to use the data in a publication or similar. Only -#' applies to occurrence downloads. -#' @param thumbnail Logical: should thumbnail-size images be returned? Defaults -#' to `FALSE`, indicating full-size images are required. Only applies to image -#' downloads. -#' @param ... Arguments passed to other methods. -#' @returns An object of class `query_set` -#' @noRd -#' @keywords Internal -build_query_set <- function(x, mint_doi, thumbnail, ...){ - switch(class(x), - "data_request" = { - if(x$type == "distributions"){ - build_query_set_distributions(x) - }else{ - build_query_set_data(x, mint_doi = mint_doi, ...) - } - }, - "metadata_request" = build_query_set_metadata(x), - "files_request" = build_query_set_files(x, thumbnail = thumbnail, ...), - abort("unknown object class") - ) -} - -#' Internal function to build a `query_set` object -#' for object of class `data_request` -#' @noRd -#' @keywords Internal -build_query_set_data <- function(x, mint_doi, ...){ - if(!missing(mint_doi)){ - x$mint_doi <- mint_doi - } - # x$type <- check_type(x$type) # needed? - # handle sending dois via `filter()` - # important this happens first, as it affects `type` which affects later code - variables <- x$filter$variable - if(!is.null(variables)){ - if(length(variables) == 1 & variables[1] == "doi"){ - x$type <- "occurrences-doi" - } - } - # handle `run_checks` - fields_absent <- lapply( - x[c("arrange", "filter", "select", "group_by")], - is.null - ) |> - unlist() - if (pour("package", "run_checks") & x$type != "occurrences-doi"){ - # add check here to see whether any filters are specified - # it is possible to only call `identify()`, for example - if (any(!fields_absent) | x$type %in% c("species-count", "species")) { - result <- list(collapse_fields(), collapse_assertions()) - } else { - # for living atlases, we need `collapse_fields()` to check the `lsid` field - # this isn't required for GBIF which doesn't use `fq` for taxon queries - if(!is.null(x$identify) &!is_gbif()){ - result <- list(collapse_fields()) - }else{ - result <- list() - } - } - if (x$type %in% c("occurrences", "media", "species") & - atlas_supports_reasons_api()) { - result[[(length(result) + 1)]] <- collapse_reasons() - } - } else { # if select is required, we need fields even if `run_checks == FALSE` - if(!fields_absent[["select"]] | x$type %in% c("occurrences", "species")){ - result <- list(collapse_fields(), collapse_assertions()) - }else{ - result <- list() - } - } - # handle `identify()` - if(!is.null(x$identify) & x$type != "occurrences-doi"){ - result[[(length(result) + 1)]] <- collapse_taxa(list(identify = x$identify)) - } - # handle `apply_profile()` - if(!is.null(x$data_profile)){ - result[[(length(result) + 1)]] <- collapse_profiles() - } - # handle query - result[[(length(result) + 1)]] <- switch( - x$type, - "occurrences" = { - if(is.null(x$group_by)){ - collapse_occurrences(x) - }else{ - collapse_species(x) - } - }, - "occurrences-count" = collapse_occurrences_count(x), - "occurrences-doi" = collapse_occurrences_doi(x), - "species" = collapse_species(x), - "species-count" = collapse_species_count(x), - abort("unrecognised 'type'")) - class(result) <- "query_set" - result -} - -#' Internal function to build a `query_set` object -#' for object of class `data_request` when `type = distributions` -#' @noRd -#' @keywords Internal -build_query_set_distributions <- function(x, ...){ - if(is.null(x$identify) & is.null(x$filter)){ - # find all expert distributions - result <- list( - collapse_distributions_metadata(), - collapse_distributions(x) - ) - }else{ - if(!is.null(x$identify)){ - result <- list( - collapse_taxa(list(identify = x$identify)) - ) - result[[2]] <- collapse_distributions(x) - }else{ - # i.e. !is.null(x$filter) - result <- list(collapse_distributions(x)) - } - } - class(result) <- "query_set" - result -} - -#' Internal function to build a `query_set` object -#' for object of class `metadata_request` -#' @noRd -#' @keywords Internal -build_query_set_metadata <- function(x, ...){ - if(pour("package", "run_checks")){ - result <- switch(x$type, - "fields-unnest" = list(collapse_fields()), - "profiles-unnest" = list(collapse_profiles()), - list()) - }else{ - result <- list() - } - if(grepl("-unnest$", x$type)){ - if(x$type == "taxa-unnest"){ - # identify() calls must be parsed, irrespective of `run_checks` (which is parsed above) - if(!is.null(x$identify)){ - result[[(length(result) + 1)]] <- collapse_taxa(x) - } - if(is.null(x$identify) & is.null(x$filter)){ - abort("Requests of type `taxa-unnest` must also supply one of `filter()` or `identify()`.") - } - }else if(is.null(x$filter)){ - current_type <- x$type - bullets <- glue("Requests of type `{current_type}` containing `unnest` must supply `filter()`.") - abort(bullets) - } - } - result[[(length(result) + 1)]] <- switch(x$type, - "apis" = collapse_apis(), - "assertions" = collapse_assertions(), - "atlases" = collapse_atlases(), - "collections" = collapse_collections(x), - "datasets" = collapse_datasets(x), - "distributions" = collapse_distributions_metadata(x), - "fields" = collapse_fields(), - "fields-unnest" = collapse_fields_unnest(x), - "licences" = collapse_licences(), - "lists" = collapse_lists(x), - "lists-unnest" = collapse_lists_unnest(x), - "media" = collapse_media(x), - "profiles" = collapse_profiles(), - "profiles-unnest" = collapse_profiles_unnest(x), - "providers" = collapse_providers(x), - "ranks" = collapse_ranks(), - "reasons" = collapse_reasons(), - "taxa" = collapse_taxa(x), - "taxa-unnest" = collapse_taxa_unnest(x), - "identifiers" = collapse_identifiers(x), - abort("unrecognised 'type'") - ) - class(result) <- "query_set" - result -} - -#' Internal function to build a `query_set` object -#' for object of class `files_request` -#' @noRd -#' @keywords Internal -build_query_set_files <- function(x, ..., thumbnail){ - result <- list(switch(x$type, - "media" = collapse_media_files(x, thumbnail = thumbnail) - )) - class(result) <- "query_set" - result -} \ No newline at end of file diff --git a/R/coalesce.R b/R/coalesce.R new file mode 100644 index 00000000..9a861c97 --- /dev/null +++ b/R/coalesce.R @@ -0,0 +1,179 @@ +#' Force evaluation of a database query +#' +#' `coalesce` is an S3 generic function intended to be called before +#' [collapse()]. It is important as it shows the full set of queries +#' required to properly evaluate the user's request. This is often broader +#' than the single query returned by [collapse()]. It returns a `query_set` +#' object +#' @rdname coalesce +#' @param x An object to be coalesced. Works for `data_request`, +#' `metadata_request` and `file_request`. +#' @param ... Other arguments +#' @order 1 +#' @return An object of class `query_set`, which is a list of all `query` +#' objects required to properly evaluate the specified request. +#' @export +coalesce <- function(x, ...){ + UseMethod("coalesce") +} + +#' @rdname coalesce +#' @param mint_doi Logical: should a DOI be minted for this download? Only +#' applies to `type = "occurrences"` when atlas chosen is "ALA". +#' @order 2 +#' @export +coalesce.data_request <- function(x, mint_doi, ...){ + if(x$type == "distributions"){ + build_query_set_distributions(x) + }else{ + build_query_set_data(x, mint_doi = mint_doi, ...) + } +} + +#' @rdname coalesce +#' @order 3 +#' @export +coalesce.metadata_request <- function(x, ...){ + if(potions::pour("package", "run_checks")){ + result <- switch(x$type, + "fields-unnest" = list(request_metadata("fields") |> as_query()), + "profiles-unnest" = list(request_metadata("profiles") |> as_query()), + list()) + }else{ + result <- list() + } + if(grepl("-unnest$", x$type)){ + if(x$type == "taxa-unnest"){ + # identify() calls must be parsed, irrespective of `run_checks` (which is parsed above) + if(!is.null(x$identify)){ + result[[(length(result) + 1)]] <- as_query_taxa(x) # best syntax for this?? + } + if(is.null(x$identify) & is.null(x$filter)){ + abort("Requests of type `taxa-unnest` must also supply one of `filter()` or `identify()`.") + } + }else if(is.null(x$filter)){ + current_type <- x$type + bullets <- glue::glue("Requests of type `{current_type}` containing `unnest` must supply `filter()`.") + cli::cli_abort(bullets) + } + } + result[[(length(result) + 1)]] <- as_query(x) + class(result) <- "query_set" + result +} + +#' @rdname coalesce +#' @param thumbnail Logical: should thumbnail-size images be returned? Defaults +#' to `FALSE`, indicating full-size images are required. +#' @order 4 +#' @export +coalesce.files_request <- function(x, + thumbnail, + ...){ + # NOTE: switch is technically superfluous right now, but could be useful + # for future file types + result <- list(switch(x$type, + "media" = as_query_media_files(x, + thumbnail = thumbnail) + )) + class(result) <- "query_set" + result +} + +#' Internal function to build a `query_set` object +#' for object of class `data_request` +#' @noRd +#' @keywords Internal +build_query_set_data <- function(x, mint_doi, ...){ + if(!missing(mint_doi)){ + x$mint_doi <- mint_doi + } + # x$type <- check_type(x$type) # needed? + # handle sending dois via `filter()` + # important this happens first, as it affects `type`, which affects later code + variables <- x$filter$variable # NOTE: breaks for GBIF + if(!is.null(variables)){ + if(length(variables) == 1 & variables[1] == "doi"){ + x$type <- "occurrences-doi" + } + } + # handle `run_checks` + fields_absent <- purrr::map( + x[c("arrange", "filter", "select", "group_by")], + is.null + ) |> + unlist() + if (potions::pour("package", "run_checks") & + x$type != "occurrences-doi"){ + # add check here to see whether any filters are specified + # it is possible to only call `identify()`, for example + if (any(!fields_absent) | + x$type %in% c("species-count", "species")) { + result <- list(request_metadata("fields") |> as_query(), + request_metadata("assertions") |> as_query()) + } else { + # for living atlases, we need `collapse_fields()` to check the `lsid` field + # this isn't required for GBIF which doesn't use `fq` for taxon queries + if(!is.null(x$identify) &!is_gbif()){ + result <- list(request_metadata("fields") |> as_query()) + }else{ + result <- list() + } + } + if (x$type %in% c("occurrences", "media", "species") & + atlas_supports_reasons_api()) { + result[[(length(result) + 1)]] <- request_metadata("reasons") |> + as_query() + } + } else { # if select is required, we need fields even if `run_checks == FALSE` + if(!fields_absent[["select"]] | + x$type %in% c("occurrences", "species")){ + result <- list(request_metadata("fields") |> as_query(), + request_metadata("assertions") |> as_query()) + }else{ + result <- list() + } + } + # handle `identify()` + if(!is.null(x$identify) & + x$type != "occurrences-doi"){ + result[[(length(result) + 1)]] <- list(type = "taxa", + identify = x$identify) |> + as_query() # this syntax seems messy + } + # handle `apply_profile()` + if(!is.null(x$data_profile)){ + result[[(length(result) + 1)]] <- request_metadata("profiles") |> + as_query() + } + # handle query + result[[(length(result) + 1)]] <- as_query(x) + class(result) <- "query_set" + result +} + +#' Internal function to build a `query_set` object +#' for object of class `data_request` when `type = distributions` +#' @noRd +#' @keywords Internal +build_query_set_distributions <- function(x, ...){ + if(is.null(x$identify) & is.null(x$filter)){ + # find all expert distributions + result <- list( + as_query_distributions_metadata(), + as_query_distributions_data(x) + ) + }else{ + if(!is.null(x$identify)){ + result <- list( + collapse_taxa(list(identify = x$identify)) # wrong syntax? + ) + result[[2]] <- as_query_distributions_data(x) # NOTE: shouldn't call microfunctions directly + }else{ + # i.e. !is.null(x$filter) + result <- list(as_query_distributions_data(x)) + } + } + class(result) <- "query_set" + result +} \ No newline at end of file diff --git a/R/collapse.R b/R/collapse.R index c836e370..9b7dfbfa 100644 --- a/R/collapse.R +++ b/R/collapse.R @@ -12,43 +12,23 @@ #' @param x An object of class `data_request`, `metadata_request` or #' `files_request` #' @param ... Arguments passed on to other methods -#' @param .expand Logical: should the `query_set` be returned? This object -#' shows all the requisite data needed to process the supplied query. Defaults -#' to `FALSE`; if `TRUE` will append the `query_set` to an extra slot in the -#' `query` object. #' @param mint_doi Logical: should a DOI be minted for this download? Only #' applies to `type = "occurrences"` when atlas chosen is "ALA". #' @return An object of class `query`, which is a list-like object containing at #' least the slots `type` and `url`. #' @export -collapse.data_request <- function(x, ..., mint_doi, .expand = FALSE){ - query_set <- build_query_set(x, - mint_doi = mint_doi, - ...) - result <- query_set |> - build_checks() |> - parse_checks() |> - parse_query() - if(.expand){ - result$call <- query_set - } - result +collapse.data_request <- function(x, ..., mint_doi){ + coalesce(x, mint_doi, ...) |> + collapse() } # if calling `collapse()` after `request_metadata()` #' @rdname collapse.data_request #' @order 2 #' @export -collapse.metadata_request <- function(x, .expand = FALSE, ...){ - query_set <- build_query_set(x, ...) - result <- query_set |> - build_checks() |> - parse_checks() |> - parse_query() - if(.expand){ - result$call <- query_set - } - result +collapse.metadata_request <- function(x, ...){ + coalesce(x, ...) |> + collapse() } # if calling `collapse()` after `request_files()` @@ -63,9 +43,33 @@ collapse.files_request <- function(x, thumbnail = FALSE, ... ){ - build_query_set(x, - thumbnail = thumbnail, - ...) |> - # note: files requests do not need to call build_checks() - pluck(!!!list(1)) + coalesce(x, + thumbnail = thumbnail, + ...) |> + collapse() +} + +# if calling `collapse()` after `coalesce()` +#' @rdname collapse.data_request +#' @order 4 +#' @export +collapse.query_set <- function(x, ...){ + # note: files requests do not need to call build_checks() + if(grepl("^files", x[[1]]$type)){ + x |> + purrr::pluck(!!!list(1)) + }else{ + x |> + build_checks() |> + parse_checks() |> + parse_query() + } } + +# if calling `collapse()` after `as_query()` +#' @rdname collapse.data_request +#' @order 4 +#' @export +collapse.query <- function(x, ...){ + x +} \ No newline at end of file diff --git a/R/collapse_distributions.R b/R/collapse_distributions.R deleted file mode 100644 index 1fcc75aa..00000000 --- a/R/collapse_distributions.R +++ /dev/null @@ -1,27 +0,0 @@ -#' Internal function to `collapse` for type `data/distributions` -#' @importFrom rlang abort -#' @noRd -#' @keywords Internal -collapse_distributions <- function(.query){ - identify_supplied <- !is.null(.query$identify) - filter_supplied <- !is.null(.query$filter) - if(identify_supplied & filter_supplied){ - abort("`collapse(type = 'distributions')` only accepts one of `filter()` or `identify()`, not both") - } - if(identify_supplied){ - url <- url_lookup("data/distributions-taxa", lsid = "`TAXON_PLACEHOLDER`") - }else if(filter_supplied){ - values <- strsplit(.query$filter$value, "\\|")[[1]] - urls <- map(values, \(x){ - url_lookup("data/distributions-id", id = x)}) |> - unlist() - url <- tibble(url = urls) - }else{ # i.e. neither supplied, get all distributions via ID column - url <- url_lookup("data/distributions-id") - } - result <- list(type = "data/distributions", - url = url, - headers = build_headers()) - class(result) <- "query" - return(result) -} \ No newline at end of file diff --git a/R/collect_taxa.R b/R/collect_taxa.R index c0b0f30d..ef501476 100644 --- a/R/collect_taxa.R +++ b/R/collect_taxa.R @@ -96,19 +96,41 @@ collect_taxa_la <- function(.query){ collect_taxa_gbif <- function(.query){ search_terms <- .query$url$search_term result <- query_API(.query) |> - bind_rows() |> + clean_gbif_taxa() |> + dplyr::bind_rows() |> mutate("search_term" = search_terms, .before = 1) names(result) <- rename_columns(names(result), type = "taxa") # old code - result |> select(any_of(wanted_columns("taxa"))) + result |> + dplyr::select(dplyr::any_of(wanted_columns("taxa"))) +} + +#' Internal function to do cleaning for GBIF +#' @param result a list from a taxonomic web service +#' @noRd +#' @keywords Internal +clean_gbif_taxa <- function(result){ + purrr::map(result, + \(a){ + c( + purrr::pluck(a$usage), + { + x <- purrr::map(a$classification, + .f = \(b){b$name}) + names(x) <- purrr::map(a$classification, + .f = \(b){tolower(b$rank)}) + x + }, + a$diagnostics[lengths(a$diagnostics) < 2] + ) + }) } #' Internal function to do cleaning #' @param result a list from a taxonomic web service -#' @importFrom purrr pluck #' @noRd #' @keywords Internal clean_la_taxa <- function(result, search_terms){ - lapply(result, function(a){ + purrr::map(result, function(a){ # capture results if("_embedded" %in% names(a)) { # e.g. France @@ -117,7 +139,7 @@ clean_la_taxa <- function(result, search_terms){ } else { if("searchResults" %in% names(a)) { list_of_results <- a |> - pluck("searchResults", "results") + purrr::pluck("searchResults", "results") } else { # e.g. Portugal (single result) list_of_results <- list(a) } @@ -126,9 +148,13 @@ clean_la_taxa <- function(result, search_terms){ # find best string match to search term if (length(list_of_results) > 1) { # i.e. more than one match if ("name" %in% list_of_results[[1]]) { - taxon_names <- unlist(lapply(list_of_results, function(b){b$name})) + taxon_names <- purrr::map(list_of_results, + function(b){b$name}) |> + unlist() } else { # e.g. France - taxon_names <- unlist(lapply(list_of_results, function(b){b$scientificName})) + taxon_names <- purrr::map(list_of_results, + function(b){b$scientificName}) |> + unlist() } string_distances <- utils::adist( tolower(search_terms), @@ -149,7 +175,7 @@ clean_la_taxa <- function(result, search_terms){ } # unlist if necessary - atlas <- pour("atlas", "region") + atlas <- potions::pour("atlas", "region") if (any(atlas %in% c("France", "Portugal"))) { list_of_results <- list_of_results |> unlist() } @@ -158,42 +184,39 @@ clean_la_taxa <- function(result, search_terms){ } #' Internal function to `collect()` identifiers -#' @importFrom dplyr any_of -#' @importFrom dplyr bind_rows -#' @importFrom dplyr mutate -#' @importFrom dplyr select #' @noRd #' @keywords Internal collect_identifiers <- function(.query){ search_terms <- .query$url$search_term result <- query_API(.query) |> flat_lists_only() |> - bind_rows() + dplyr::bind_rows() if(any(colnames(result) == "taxonConceptID")){ result <- result |> - filter(!duplicated(result$taxonConceptID)) + dplyr::filter(!duplicated(result$taxonConceptID)) } if(!any(colnames(result) == "success")){ # GBIF doesn't indicate success # we avoid `is_gbif()` here because other atlases use GBIF APIs result$success <- TRUE result <- result |> - relocate(success, .before = 1) |> - rename("taxonConceptID" = "key") + dplyr::relocate(success, .before = 1) |> + dplyr::rename("taxonConceptID" = "key") } result <- result |> - mutate("search_term" = search_terms, .before = "success") + dplyr::mutate("search_term" = search_terms, .before = "success") # Check for invalid search terms if (galah_config()$package$verbose) { check_search_terms(result) } - names(result) <- rename_columns(names(result), type = "taxa") # old code + names(result) <- rename_columns(names(result), + type = "taxa") # old code result <- result |> - select(any_of(wanted_columns("taxa"))) + dplyr::select(dplyr::any_of(wanted_columns("taxa"))) attr(result, "call") <- "identifiers" attr(result, "region") <- pour("atlas", "region") result @@ -247,7 +270,6 @@ check_search_terms <- function(result, atlas) { #' Internal function to check for homonyms in search term provided to #' `search_taxa()` -#' @importFrom glue glue_collapse #' @noRd #' @keywords Internal check_homonyms <- function(result) { @@ -261,6 +283,6 @@ check_homonyms <- function(result) { i = "Use a `tibble` to clarify taxa, see `?search_taxa`.", x = glue("Homonym issue with \"{list_homonym_taxa}\".") ) - warn(bullets) + rlang::warn(bullets) } } diff --git a/R/parse_metadata.R b/R/parse_metadata_lists.R similarity index 100% rename from R/parse_metadata.R rename to R/parse_metadata_lists.R diff --git a/R/parse_metadata_unnest.R b/R/parse_metadata_profiles.R similarity index 100% rename from R/parse_metadata_unnest.R rename to R/parse_metadata_profiles.R diff --git a/man/as_query.data_request.Rd b/man/as_query.data_request.Rd new file mode 100644 index 00000000..100cb4a7 --- /dev/null +++ b/man/as_query.data_request.Rd @@ -0,0 +1,39 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/as_query.R +\name{as_query.data_request} +\alias{as_query.data_request} +\alias{as_query} +\alias{as_query.metadata_request} +\alias{as_query.files_request} +\title{Convert an object to class \code{query}} +\usage{ +as_query(x, ...) + +\method{as_query}{data_request}(x, ...) + +\method{as_query}{metadata_request}(x, ...) + +\method{as_query}{files_request}(x, ...) +} +\description{ +Functionally similar to \code{\link[=collapse]{collapse()}}, but without passing through +\code{\link[=coalesce]{coalesce()}} first. Primarily an internal function, but exported for +clarity and debugging purposes. +} +\details{ +Typically, queries in galah are piped using \code{galah_call()}, which builds +an object of class \code{"data_request"}, \code{"metadata_request"} or \code{"files_request"}. +This parses to an object of class \code{"query"} via \code{\link[=collapse]{collapse()}}. However, +\code{\link[=collapse]{collapse()}} first calls \code{\link[=coalesce]{coalesce()}}, which expands to a \code{query_set} +\emph{before} evaluating \code{\link[=collapse]{collapse()}}. In this context, \code{as_query()} serves two +functions: externally, it can be called to convert directly to a \code{query} +without running checks; and internally it allows a query to be appended +to a \code{query_set} without calling \code{\link[=collapse]{collapse()}}, which would begin an +infinite loop (because \code{collapse()} calls \code{coalesce()}). + +For simple cases, this gives the same result as running +\code{\link[=collapse]{collapse()}} while the \code{run_checks} argument of \code{\link[=galah_config]{galah_config()}} is set to +\code{FALSE}, but is slightly faster. For complex cases, however, it is likely +to generate irresolvable API calls, because e.g. taxonomic queries are not +parsed before the URL is built. It should therefore be used with care. +} diff --git a/man/coalesce.Rd b/man/coalesce.Rd new file mode 100644 index 00000000..8a194dd4 --- /dev/null +++ b/man/coalesce.Rd @@ -0,0 +1,40 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/coalesce.R +\name{coalesce} +\alias{coalesce} +\alias{coalesce.data_request} +\alias{coalesce.metadata_request} +\alias{coalesce.files_request} +\title{Force evaluation of a database query} +\usage{ +coalesce(x, ...) + +\method{coalesce}{data_request}(x, mint_doi, ...) + +\method{coalesce}{metadata_request}(x, ...) + +\method{coalesce}{files_request}(x, thumbnail, ...) +} +\arguments{ +\item{x}{An object to be coalesced. Works for \code{data_request}, +\code{metadata_request} and \code{file_request}.} + +\item{...}{Other arguments} + +\item{mint_doi}{Logical: should a DOI be minted for this download? Only +applies to \code{type = "occurrences"} when atlas chosen is "ALA".} + +\item{thumbnail}{Logical: should thumbnail-size images be returned? Defaults +to \code{FALSE}, indicating full-size images are required.} +} +\value{ +An object of class \code{query_set}, which is a list of all \code{query} +objects required to properly evaluate the specified request. +} +\description{ +\code{coalesce} is an S3 generic function intended to be called before +\code{\link[=collapse]{collapse()}}. It is important as it shows the full set of queries +required to properly evaluate the user's request. This is often broader +than the single query returned by \code{\link[=collapse]{collapse()}}. It returns a \code{query_set} +object +} diff --git a/man/collapse.data_request.Rd b/man/collapse.data_request.Rd index aa8801cd..478657ce 100644 --- a/man/collapse.data_request.Rd +++ b/man/collapse.data_request.Rd @@ -4,13 +4,19 @@ \alias{collapse.data_request} \alias{collapse.metadata_request} \alias{collapse.files_request} +\alias{collapse.query_set} +\alias{collapse.query} \title{Generate a query} \usage{ -\method{collapse}{data_request}(x, ..., mint_doi, .expand = FALSE) +\method{collapse}{data_request}(x, ..., mint_doi) -\method{collapse}{metadata_request}(x, .expand = FALSE, ...) +\method{collapse}{metadata_request}(x, ...) \method{collapse}{files_request}(x, thumbnail = FALSE, ...) + +\method{collapse}{query_set}(x, ...) + +\method{collapse}{query}(x, ...) } \arguments{ \item{x}{An object of class \code{data_request}, \code{metadata_request} or @@ -21,11 +27,6 @@ \item{mint_doi}{Logical: should a DOI be minted for this download? Only applies to \code{type = "occurrences"} when atlas chosen is "ALA".} -\item{.expand}{Logical: should the \code{query_set} be returned? This object -shows all the requisite data needed to process the supplied query. Defaults -to \code{FALSE}; if \code{TRUE} will append the \code{query_set} to an extra slot in the -\code{query} object.} - \item{thumbnail}{Logical: should thumbnail-size images be returned? Defaults to \code{FALSE}, indicating full-size images are required.} } From 64b61a3bd06bc9e3c8fb5196cdb2407219076537 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Wed, 30 Jul 2025 18:31:42 +1000 Subject: [PATCH 10/94] Start integrating predicates to GBIF workflows #272 --- R/build_predicates.R | 185 ++++++++++++++--------- R/check.R | 313 +++++++++++++++++---------------------- R/check_queue.R | 19 +-- R/handle_quosures_GBIF.R | 20 +-- R/parse_checks.R | 19 ++- R/query_API.R | 46 ++---- R/url_lookup.R | 26 ++-- R/utilities_internal.R | 5 +- 8 files changed, 314 insertions(+), 319 deletions(-) diff --git a/R/build_predicates.R b/R/build_predicates.R index b1711213..5dfe78d6 100644 --- a/R/build_predicates.R +++ b/R/build_predicates.R @@ -2,96 +2,147 @@ #' #' predicates are JSON scripts for passing to GBIF offline downloads API. #' https://www.gbif.org/developer/occurrence -#' @importFrom potions pour -#' @importFrom jsonlite toJSON -#' @importFrom jsonlite unbox +#' params x A list with slots relevant to building predicates #' @noRd #' @keywords Internal build_predicates <- function( - df, # where df is returned by galah_filter() - location, - format = "SIMPLE_CSV" -){ - if(nrow(df) < 1){ - return(NULL) - } + x + # format = "SIMPLE_CSV" # i.e. default is to return occurrences +){ + list( + creator = jsonlite::unbox( + potions::pour("user", "username", .pkg = "galah")), + notificationAddresses = jsonlite::unbox( + potions::pour("user", "email", .pkg = "galah")), + sendNotification = jsonlite::unbox( + potions::pour("package", "send_email", .pkg = "galah")), + format = jsonlite::unbox(format), + predicate = concatenate_predicates(x)) + # jsonlite::toJSON() +} + +#' join all queries +#' NOTE: There is a maximum of 101k entries in total. Should be possible to enforce that here +#' @noRd +#' @keywords Internal +concatenate_predicates <- function(x){ - predicates_list <- c( - parse_predicates(df), - parse_predicates_spatial(location)) + # check for taxonomic queries + if(!is.null(x$identify)){ + identify <- parse_predicates_identify(x$identify) + }else{ + identify <- NULL + } + + # check for spatial queries + if(!is.null(x$geolocate)){ + location <- parse_predicates_location(x$geolocate) + }else{ + location <- NULL + } - if(length(predicates_list) > 1){ - predicates_list <- list( - type = unbox("and"), - predicates = predicates_list - ) + # parse correctly given provided information + if(!is.null(x$filter)){ + result <- x$filter + if(is_and_query(x)){ + result$predicates <- c(result$predicates, + identify, + location) |> + remove_nulls_from_list() + }else{ # filter exists, but no type (e.g. it's length-1) + # NOTE: This code looks similar to when filter is missing + # consolidate? + result <- c(result, + identify, + location) |> + remove_nulls_from_list() + if(length(result) > 1){ + result <- list(type = jsonlite::unbox("and"), + predicates = result) + } + } + # i.e. if filter is missing + }else{ + result <- list(identify, location) |> + remove_nulls_from_list() + if(length(result) > 1){ + result <- list(type = jsonlite::unbox("and"), + predicates = result) + } } - data_list <- list( - creator = unbox(pour("user", "username", .pkg = "galah")), - notificationAddresses = pour("user", "email", .pkg = "galah"), - sendNotification = unbox(pour("package", "send_email", .pkg = "galah")), - format = unbox(format), - predicate = predicates_list - ) + # add class and return + class(result) <- c("galah_filter_predicate", "list") + result +} + +#' simple check for whether predicates begin with `and` +#' @noRd +#' @keywords Internal +is_and_query <- function(x){ + if(purrr::pluck_exists(x, "filter", "type")){ + if(purrr::pluck(x, "filter", "type") == "and"){ + TRUE + }else{ + FALSE + } + }else{ + FALSE + } +} + +#' clean up a list +#' @noRd +#' @keywords Internal +remove_nulls_from_list <- function(x){ + x[!unlist(purrr::map(x, is.null))] +} - toJSON(data_list) +#' handle taxonomic queries +#' @noRd +#' @keywords Internal +parse_predicates_identify <- function(x){ + if(!is.null(x)){ + result <- purrr::map(x$taxon_concept_id, + \(a){list(type = jsonlite::unbox("equals"), + key = jsonlite::unbox("TAXON_KEY"), + value = jsonlite::unbox(a))}) + if(length(result) > 1){ + list(type = "or", + result) + }else{ + result + } + }else{ + NULL + } } -#' most code borrowed from `build_query_gbif()` +#' handle spatial queries +#' NOTE: There is a limit of 10k points in geometry; should be possible to enforce that here #' @noRd #' @keywords Internal -parse_predicates_spatial <- function(location){ +parse_predicates_location <- function(location){ if(!is.null(location)) { # if location is for a point radius vs polygon/bbox if(!is.null(names(location))){ if(all(!is.null(location$radius))) { # `galah_radius()` will always pass radius argument - list(type = unbox("geoDistance"), - latitude = unbox(location$lat), - longitude = unbox(location$lon), - distance = unbox(paste0(location$radius, "km"))) |> + list(type = jsonlite::unbox("geoDistance"), + latitude = jsonlite::unbox(location$lat), + longitude = jsonlite::unbox(location$lon), + distance = jsonlite::unbox(paste0(location$radius, "km"))) |> list() }else{ - list(type = unbox("within"), - geometry = unbox(location)) |> + list(type = jsonlite::unbox("within"), + geometry = jsonlite::unbox(location)) |> list() } }else{ - list(type = unbox("within"), - geometry = unbox(location)) |> + list(type = jsonlite::unbox("within"), + geometry = jsonlite::unbox(location)) |> list() } }else{ NULL } -} - -#' parse galah_filter result into predicate format -#' @importFrom jsonlite unbox -#' @noRd -#' @keywords Internal -parse_predicates <- function(df){ - json_text <- lapply( - split(df, seq_len(nrow(df))), - function(a){ - list( - type = unbox(switch(a$logical, - "==" = "equals", - "<" = "lessThan", - "<=" = "lessThanOrEquals", - ">" = "greaterThan", - ">=" = "greaterThanOrEquals", - "!=" = "not" - )), - key = unbox(gbif_upper_case(a$variable)), - value = unbox(a$value) - ) - }) - names(json_text) <- NULL - return(json_text) -} -# NOTE: currently missing: `or`, `in`, `isNull`, `isNotNull` - -# test object: -# df <- galah_filter(year == 1850) -# df <- galah_filter(catalogNumber == 217880) \ No newline at end of file +} \ No newline at end of file diff --git a/R/check.R b/R/check.R index 57a5f113..0709cd99 100644 --- a/R/check.R +++ b/R/check.R @@ -14,14 +14,14 @@ check_atlas_inputs <- function(args){ #' Internal function to check for `data_request`s #' @noRd #' @keywords Internal -check_data_request <- function(request, error_call = caller_env()){ +check_data_request <- function(request, + error_call = caller_env()){ if(!inherits(request, "data_request")){ - bullets <- c( + cli::cli_abort(c( "Argument `.query` requires an object of type `data_request`.", i = "You can create this object using `galah_call()`.", - i = "Did you specify the incorrect argument?" - ) - abort(bullets, call = caller_env()) + i = "Did you specify the incorrect argument?"), + call = error_call) } } @@ -51,37 +51,39 @@ check_directory <- function(x){ #' Internal function to ensure a download file is given #' @noRd #' @keywords Internal -check_download_filename <- function(file, ext = "zip"){ +check_download_filename <- function(file, + ext = "zip"){ if(!is.null(file)){ # is `file` present - expected_suffix <- paste0(".", ext, "$") + expected_suffix <- glue::glue(".{ext}$") if(!grepl(expected_suffix, file)){ # expected suffix is missing if(grepl("\\.[[:alpha:]]{2,4}$", file)){ # does it have a different suffix? file <- gsub("\\.[[:alpha:]]{2,4}$", sub("\\$$", "", expected_suffix), file) # replace }else{ - file <- paste0(file, ".zip") # append + file <- glue::glue("{file}.zip") } } # no else{}, as all good here }else{ - current_time <- Sys.time() |> format("%Y-%m-%d_%H-%M-%S") - file <- paste0('data_', current_time, ".", ext) + current_time <- Sys.time() |> + format("%Y-%m-%d_%H-%M-%S") + file <- glue::glue("data_{current_time}.{ext}") } - cache_directory <- pour("package", "directory", .pkg = "galah") - glue("{cache_directory}/{file}") |> + cache_directory <- potions::pour("package", "directory", + .pkg = "galah") + glue::glue("{cache_directory}/{file}") |> as.character() # check_path()? # currently commented out in check.R } #' Subfunction to `check_login()` -#' @importFrom jsonlite fromJSON #' @noRd #' @keywords Internal check_email <- function(.query){ if(is_gbif()){ - email_text <- fromJSON(.query$body)$notificationAddresses + email_text <- jsonlite::fromJSON(.query$body)$notificationAddresses }else{ - email_text <- url_parse(.query$url)$query$email + email_text <- httr2::url_parse(.query$url)$query$email } if(is.null(email_text)) { abort_email_missing() @@ -92,20 +94,18 @@ check_email <- function(.query){ } #' Check files are filtered properly -#' @importFrom rlang abort #' @noRd #' @keywords Internal check_files_filter <- function(x){ if(!(x$variable %in% c("media"))){ - abort("Variable name must be a valid `type` accepted by `request_files()`.") + cli::cli_abort("Variable name must be a valid `type` accepted by `request_files()`.") } if(!inherits(x$data, "data.frame")){ - abort("rhs must be a `tibble` containing media information") + cli::cli_abort("rhs must be a `tibble` containing media information") } } #' check that objects passed within `galah_filter` have correct structure -#' @importFrom rlang abort #' @noRd #' @keywords Internal check_filter_tibbles <- function(x){ # where x is a list of tibbles @@ -119,7 +119,7 @@ check_filter_tibbles <- function(x){ # where x is a list of tibbles unlist() |> all() if(!syntax_valid){ - abort("There was a problem with `filter`, did you use correct syntax?") + cli::cli_abort("There was a problem with `filter`, did you use correct syntax?") } } @@ -147,21 +147,21 @@ check_fields <- function(.query) { # error message if(any(!is.na(check_result))) { - returned_invalid <- tibble( + returned_invalid <- tibble::tibble( function_name = c("`galah_filter()`", "`galah_group_by()`"), fields = check_result) |> tidyr::drop_na() glue_template <- "{returned_invalid$function_name}: {returned_invalid$fields}" - invalid_fields_message <- glue_data(returned_invalid, glue_template, .na = "") + invalid_fields_message <- glue::glue_data(returned_invalid, glue_template, .na = "") bullets <- c( "Can't use fields that don't exist.", i = "Use `search_all(fields)` to find a valid field ID.", x = glue("Can't find field(s) in"), - glue(" ", format_error_bullets(invalid_fields_message)) + glue::glue(" ", format_error_bullets(invalid_fields_message)) ) - abort(bullets) + rlang::abort(bullets) } } .query @@ -172,10 +172,11 @@ check_fields <- function(.query) { #' @importFrom rlang caller_env #' @noRd #' @keywords Internal -check_field_identities <- function(df, .query){ +check_field_identities <- function(df, + .query){ if(!is.null(.query$fields) & - pour("package", "run_checks", .pkg = "galah") & - pour("atlas", "region", .pkg = "galah") %in% c("Australia", "Spain", "Sweden") + potions::pour("package", "run_checks", .pkg = "galah") & + potions::pour("atlas", "region", .pkg = "galah") %in% c("Australia", "Spain", "Sweden") # NOTE: last line included because the remaining atlases use different # architecture which tends to mean queries are sent with non-DwC terms, # but return DwC terms. This triggers warnings that are technically @@ -191,26 +192,24 @@ check_field_identities <- function(df, .query){ if(any(missing_check)){ missing_fields <- .query$fields[missing_check] names(missing_fields) <- rep("*", length(missing_fields)) - bullets <- c("The following fields, requested in your query, were not downloaded:", - missing_fields) - warn(bullets) + c("The following fields, requested in your query, were not downloaded:", + missing_fields) |> + cli::cli_warn(bullets) } # check for additions added_check <- !(field_names %in% .query$fields) if(any(added_check)){ added_fields <- field_names[added_check] names(added_fields) <- rep("*", length(added_fields)) - bullets <- c("The following fields were downloaded, but weren't requested in your query:", - added_fields) - warn(bullets) + c("The following fields were downloaded, but weren't requested in your query:", + added_fields) |> + cli::cli_warn() } } df } #' sub-function to `check_fields()` for GBIF -#' @importFrom jsonlite fromJSON -#' @importFrom purrr pluck #' @noRd #' @keywords Internal check_fields_gbif_counts <- function(.query){ @@ -218,7 +217,7 @@ check_fields_gbif_counts <- function(.query){ valid_fields <- .query[["metadata/fields"]]$id valid_assertions <- .query[["metadata/assertions"]]$id valid_any <- c(valid_fields, valid_assertions) - url <- url_parse(.query$url[1]) + url <- httr2::url_parse(.query$url[1]) # get fields from url skip_fields <- c("limit", "facet", "facetLimit", @@ -230,7 +229,7 @@ check_fields_gbif_counts <- function(.query){ if (length(fields) > 0) { if (!all(fields %in% valid_any)) { invalid_fields <- fields[!(fields %in% valid_any)] - filter_invalid <- glue_collapse(invalid_fields, sep = ", ") + filter_invalid <- glue::glue_collapse(invalid_fields, sep = ", ") } } @@ -240,7 +239,7 @@ check_fields_gbif_counts <- function(.query){ fields <- unlist(url$query[which(query_names == "facet")]) if (!all(fields %in% valid_any)) { invalid_fields <- fields[!(fields %in% valid_any)] - group_by_invalid <- glue_collapse(invalid_fields, sep = ", ") + group_by_invalid <- glue::glue_collapse(invalid_fields, sep = ", ") } } @@ -248,8 +247,6 @@ check_fields_gbif_counts <- function(.query){ } #' sub-function to `check_fields()` for GBIF -#' @importFrom jsonlite fromJSON -#' @importFrom purrr pluck #' @noRd #' @keywords Internal check_fields_gbif_predicates <- function(.query){ @@ -261,14 +258,14 @@ check_fields_gbif_predicates <- function(.query){ toupper() # extract fields fields <- .query$body |> - fromJSON() |> - pluck("predicate", "predicates", "key") + jsonlite::fromJSON() |> + purrr::pluck("predicate", "predicates", "key") # check invalid filter_invalid <- NA if (length(fields) > 0) { if (!all(fields %in% valid_any)) { invalid_fields <- fields[!(fields %in% valid_any)] - filter_invalid <- glue_collapse(invalid_fields, sep = ", ") + filter_invalid <- glue::glue_collapse(invalid_fields, sep = ", ") } } c(filter_invalid, NA) @@ -302,7 +299,7 @@ check_fields_la <- function(.query){ if (length(filters) > 0) { if (!all(filters %in% valid_any)) { invalid_fields <- filters[!(filters %in% valid_any)] - filter_invalid <- glue_collapse(invalid_fields, sep = ", ") + filter_invalid <- glue::glue_collapse(invalid_fields, sep = ", ") } } } @@ -310,9 +307,9 @@ check_fields_la <- function(.query){ # galah_group_by fields check group_by_invalid <- NA if(inherits(.query$url, "data.frame")){ - url <- url_parse(.query$url$url[1]) + url <- httr2::url_parse(.query$url$url[1]) }else{ - url <- url_parse(.query$url[1]) + url <- httr2::url_parse(.query$url[1]) } queries <- url$query if (!is.null(queries$facets)) { @@ -320,7 +317,7 @@ check_fields_la <- function(.query){ if (length(facets) > 0) { if (!all(facets %in% valid_any)) { invalid_fields <- facets[!(facets %in% valid_any)] - group_by_invalid <- glue_collapse(invalid_fields, sep = ", ") + group_by_invalid <- glue::glue_collapse(invalid_fields, sep = ", ") } } } @@ -330,6 +327,8 @@ check_fields_la <- function(.query){ # If no args are supplied, set default columns returned as group = "basic" +#' @noRd +#' @keywords Internal check_groups <- function(group, n){ if(missing(group)){ if(n < 1){ @@ -352,65 +351,26 @@ check_groups <- function(group, n){ #' @noRd #' @keywords Internal check_identifiers <- function(.query){ + # For GBIF, which uses predicates, we 'promote' taxonomic queries to 'predicates' if(is_gbif()){ - if(.query$type %in% c("data/occurrences", "data/species")){ - check_identifiers_gbif_predicates(.query) - }else{ - check_identifiers_gbif(.query) # mainly for `data/occurrences-count` - } + .query$predicates$identify <- .query$`metadata/taxa-single` + .query + # otherwise we replace "(`TAXON_PLACEHOLDER`)" }else{ check_identifiers_la(.query) } } - -#' `check_identifiers()` for gbif -#' @noRd -#' @keywords Internal -check_identifiers_gbif <- function(.query){ - url <- url_parse(.query$url[1]) # FIXME: test if every >1 urls here - if(!is.null(url$query)){ - metadata_lookup <-grepl("metadata/taxa", names(.query)) - if(any(metadata_lookup)){ - identifiers <- .query[[which(metadata_lookup)[1]]] - taxon_query <- as.list(identifiers$taxon_concept_id) - names(taxon_query) <- rep("taxonKey", length(taxon_query)) - url$query <- c(taxon_query, - url$query[names(url$query) != "taxonKey"]) - .query$url[1] <- url_build(url) - } - } - .query -} - -#' `check_identifiers()` for gbif occurrences -#' @noRd -#' @keywords Internal -check_identifiers_gbif_predicates <- function(.query){ - if(!is.null(.query$body)){ - metadata_lookup <-grepl("metadata/taxa", names(.query)) - if(any(metadata_lookup)){ - identifiers <- .query[[which(metadata_lookup)[1]]] - .query$body <- sub("`TAXON_PLACEHOLDER`", - identifiers$taxon_concept_id[1], - .query$body) - } - } - .query -} #' `check_identifiers()` for living atlases -#' @importFrom httr2 url_build -#' @importFrom httr2 url_parse -#' @importFrom rlang abort -#' @importFrom stringr str_replace_all #' @noRd #' @keywords Internal -check_identifiers_la <- function(.query, error_call = caller_env()){ +check_identifiers_la <- function(.query, + error_call = caller_env()){ # FIXME: test if every >1 urls here if(inherits(.query$url, "data.frame")){ - url <- url_parse(.query$url$url[1]) + url <- httr2::url_parse(.query$url$url[1]) }else{ - url <- url_parse(.query$url[1]) + url <- httr2::url_parse(.query$url[1]) } queries <- url$query if(!is.null(queries$fq)){ @@ -425,11 +385,11 @@ check_identifiers_la <- function(.query, error_call = caller_env()){ } taxa_ids <- build_taxa_query(identifiers$taxon_concept_id) - queries$fq <- str_replace_all(queries$fq, - "\\(`TAXON_PLACEHOLDER`\\)", - taxa_ids) + queries$fq <- stringr::str_replace_all(queries$fq, + "\\(`TAXON_PLACEHOLDER`\\)", + taxa_ids) url$query <- queries - .query$url[1] <- url_build(url) + .query$url[1] <- httr2::url_build(url) }else{ # this only happens if there is a bug earlier in the code abort("The query has a taxonomic placeholder, but no taxon search has been run.") @@ -442,10 +402,10 @@ check_identifiers_la <- function(.query, error_call = caller_env()){ if(any(metadata_lookup)){ identifiers <- .query[[which(metadata_lookup)[1]]] taxa_id <- utils::URLencode(identifiers$taxon_concept_id[1], - reserved = TRUE) + reserved = TRUE) .query$url[1] <- sub("%60TAXON_PLACEHOLDER%60", taxa_id, .query$url[1]) }else{ - abort("The query has a taxonomic placeholder, but no taxon search has been run.") + rlang::abort("The query has a taxonomic placeholder, but no taxon search has been run.") } } } @@ -457,10 +417,11 @@ check_identifiers_la <- function(.query, error_call = caller_env()){ #' @noRd #' @keywords Internal #' @importFrom rlang caller_env -check_login <- function(.query, error_call = caller_env()) { +check_login <- function(.query, + error_call = caller_env()) { # Check for valid email for occurrences or species queries for all providers if(.query$type == "data/occurrences" | .query$type == "data/species"){ - switch(pour("atlas", "region"), + switch(potions::pour("atlas", "region"), "United Kingdom" = {}, "Global" = {check_email(.query); check_password(.query)}, check_email(.query)) @@ -498,19 +459,20 @@ check_media_cols <- function(.query){ #' @param .query a `query` object #' @noRd #' @keywords Internal -check_media_cols_present <- function(.query, error_call = caller_env()){ +check_media_cols_present <- function(.query, + error_call = rlang::caller_env()){ fields <- .query |> - pluck("url") |> - url_parse() |> - pluck("query", "fields") |> + purrr::pluck("url") |> + httr2::url_parse() |> + purrr::pluck("query", "fields") |> strsplit(",") |> - pluck(1) + purrr::pluck(1) fields_check <- image_fields() %in% fields if(!any(fields_check)){ - abort(c("No media fields requested.", - i = "Use `select()` to specify which media fields are required.", - i = "Valid fields are 'images', 'videos' and 'sounds'."), - call = error_call) + cli::cli_abort(c("No media fields requested.", + i = "Use `select()` to specify which media fields are required.", + i = "Valid fields are 'images', 'videos' and 'sounds'."), + call = error_call) }else{ image_fields()[fields_check] } @@ -525,7 +487,7 @@ check_named_input <- function(dots){ bullets <- c( "We detected a named input.", i = "This usually means that you've used `=` instead of `==`.") - abort(bullets) + cli::cli_abort(bullets) } } @@ -533,14 +495,15 @@ check_named_input <- function(dots){ #' @importFrom rlang warn #' @noRd #' @keywords Internal -check_n_inputs <- function(dots, error_call = caller_env()) { +check_n_inputs <- function(dots, + error_call = rlang::caller_env()) { if(length(dots) > 1){ n_geolocations <- length(dots) - bullets <- c( + c( "More than 1 spatial area provided.", "*" = glue("Using first location, ignoring additional {n_geolocations - 1} location(s).") - ) - warn(bullets, call = error_call) + ) |> + cli::cli_warn(call = error_call) } } @@ -549,7 +512,8 @@ check_n_inputs <- function(dots, error_call = caller_env()) { #' @importFrom stringr str_trim #' @noRd #' @keywords Internal -check_occurrence_response <- function(.query){ +check_occurrence_response <- function(.query, + error_call = rlang::caller_env()){ names(.query) <- camel_to_snake_case(names(.query)) if (!is.null(.query$status_code)) { @@ -559,23 +523,23 @@ check_occurrence_response <- function(.query){ bullets <- c( "There was a problem with your query.", - "*" = glue("message: {.query$message}")) + "*" = glue::glue("message: {.query$message}")) switch(as.character(error_type), - "500" = {abort(bullets, - call = caller_env())}, - "403" = {abort(c(bullets, - i = "Is the email you provided to `galah_config()` registered with the selected atlas?"), - call = caller_env())}, - "404" = {abort(c(bullets, - i = "Is the email you provided to `galah_config()` registered with the selected atlas?"), - call = caller_env())}, - "504" = {abort(c(bullets, - i = "This usually means that the selected API is down.", - i = "If you continue to receive this error, please email support@ala.org.au"), - call = caller_env())}, - abort("Aborting for unknown reasons.", # FIXME - call = caller_env())) + "500" = {cli::cli_abort(bullets, + call = error_call)}, + "403" = {cli::cli_abort(c(bullets, + i = "Is the email you provided to `galah_config()` registered with the selected atlas?"), + call = error_call)}, + "404" = {cli::cli_abort(c(bullets, + i = "Is the email you provided to `galah_config()` registered with the selected atlas?"), + call = error_call)}, + "504" = {cli::cli_abort(c(bullets, + i = "This usually means that the selected API is down.", + i = "If you continue to receive this error, please email support@ala.org.au"), + call = error_call)}, + cli::cli_abort("Aborting for unknown reasons.", # FIXME + call = error_call)) } else { if (.query$status %in% c("finished", # ALA "SUCCEEDED") # GBIF @@ -593,8 +557,7 @@ check_occurrence_response <- function(.query){ } # convert `key` to `status_url` if(is.null(.query$status_url) & !is.null(.query$key)){ - .query$status_url <- paste0("https://api.gbif.org/v1/occurrence/download/", - .query$key) + .query$status_url <- glue::glue("https://api.gbif.org/v1/occurrence/download/{.query$key}") } # add `queue_size` if(is.null(.query$queue_size)){ @@ -626,9 +589,9 @@ check_occurrence_status <- function(.query){ #' @noRd #' @keywords Internal check_password <- function(.query, - error_call = caller_env()){ + error_call = rlang::caller_env()){ if (.query$options$userpwd == ":") { - abort("GBIF requires a username and password to download occurrences or species.", + cli::cli_abort("GBIF requires a username and password to download occurrences or species.", call = error_call) } } @@ -654,21 +617,20 @@ check_password <- function(.query, # } #' Internal function to check a supplied profile is valid -#' @importFrom glue glue -#' @importFrom rlang abort #' @noRd #' @keywords Internal -check_profiles <- function(.query, error_call = caller_env()){ +check_profiles <- function(.query, + error_call = rlang::caller_env()){ if(!inherits(.query$url, "data.frame")){ query <- url_parse(.query$url[1])$query if(!is.null(query$qualityProfile)){ profile <- query$qualityProfile if(!profile %in% .query[["metadata/profiles"]]$shortName){ - bullets <- c( + c( "Unrecognised profile requested.", i = "See `?show_all(profiles)` for valid profiles.", - x = glue("Can't find profile `{profile}` for specified atlas.")) - abort(bullets, call = error_call) + x = "Can't find profile `{profile}` for specified atlas.") |> + cli::cli_abort(call = error_call) }else{ .query } @@ -681,28 +643,27 @@ check_profiles <- function(.query, error_call = caller_env()){ } #' Internal function to check that a reason code is valid -#' @importFrom glue glue -#' @importFrom rlang abort #' @noRd #' @keywords Internal -check_reason <- function(.query, error_call = caller_env()){ +check_reason <- function(.query, + error_call = rlang::caller_env()){ if(atlas_supports_reasons_api()) { if(.query$type %in% c("data/occurrences", "data/species")){ query <- url_parse(.query$url)$query if(is.null(query$reasonTypeId)){ - bullets <- c("Missing a valid download reason.", - i = "See `show_all(reasons)`.", - i = "Use `galah_config(download_reason_id = ...)` to set a download reason.") - abort(bullets, call = error_call) + c("Missing a valid download reason.", + i = "See `show_all(reasons)`.", + i = "Use `galah_config(download_reason_id = ...)` to set a download reason.") |> + cli::cli_abort(call = error_call) }else{ user_reason <- query$reasonTypeId valid_reasons <- .query[["metadata/reasons"]]$id if(!(user_reason %in% valid_reasons)){ - bullets <- c( + c( "Invalid download reason ID.", i = "Use `show_all(reasons)` to see all valid reasons.", - x = glue("\"{user_reason}\" does not match an existing reason ID.")) - abort(bullets, call = error_call) + x = "\"{user_reason}\" does not match an existing reason ID.") |> + cli::cli_abort(call = error_call) } } } @@ -712,17 +673,13 @@ check_reason <- function(.query, error_call = caller_env()){ #' Check that `select()` quosures can be parsed correctly #' NOTE: much of this content was previously in `parse_select()` (defunct) -#' @importFrom dplyr all_of -#' @importFrom dplyr filter -#' @importFrom httr2 url_parse -#' @importFrom httr2 url_build -#' @importFrom rlang is_quosure #' @noRd #' @keywords Internal -check_select <- function(.query){ +check_select <- function(.query, + error_call = rlang::caller_env()){ if(any(names(.query) == "select")){ if(is_gbif()){ - inform(c("skipping `select()`:", + cli::cli_inform(c("skipping `select()`:", i = "This function is not supported by the GBIF API v1")) }else{ # 1. build df to `select` from @@ -738,9 +695,10 @@ check_select <- function(.query){ # new step to avoid calling `show_all_assertions()` internally group <- group_initial[group_initial != "assertions"] if(length(group) > 0){ - group_cols <- lapply(group, preset_groups) |> + group_cols <- purrr::map(group, preset_groups) |> unlist() - group_names <- tidyselect::eval_select(all_of(group_cols), data = df) |> + group_names <- tidyselect::eval_select(dplyr::all_of(group_cols), + data = df) |> names() # note: technically `group_names` and `group_cols` are identical # BUT `eval_select()` will fail if invalid columns are given @@ -749,10 +707,10 @@ check_select <- function(.query){ } # 3. parse quosures to get list of field names - check_quosures <- lapply(.query$select, is_quosure) |> + check_quosures <- purrr::map(.query$select, rlang::is_quosure) |> unlist() dots <- .query$select[check_quosures] - dot_names <- lapply(dots, function(a){ + dot_names <- purrr::map(dots, function(a){ tidyselect::eval_select(a, data = df) |> names() }) |> @@ -792,10 +750,10 @@ check_select <- function(.query){ # having e.g. media columns _before_ `recordID` causes the download to fail field_values <- unique(c(group_names, individual_cols)) if(is.null(field_values)){ - bullets <- c("No fields selected", - i = "Please specify a valid set of fields in `select()`", - i = "You can look up valid fields using `show_all(fields)`") - abort(bullets) + c("No fields selected", + i = "Please specify a valid set of fields in `select()`", + i = "You can look up valid fields using `show_all(fields)`") |> + cli::cli_abort(call = error_call) } if(any(field_values == id_col)){ field_values <- c(id_col, field_values[field_values != id_col]) # recordID needs to be first @@ -807,20 +765,21 @@ check_select <- function(.query){ assertion_text <- "includeall" }else{ if(any(is_assertion)){ - assertion_text <- paste(field_values[is_assertion], collapse = ",") + assertion_text <- glue::glue_collapse(field_values[is_assertion], + sep = ",") }else{ assertion_text <- "none" } } - field_text <- paste(field_values[!is_assertion], - collapse = ",") + field_text <- glue::glue_collapse(field_values[!is_assertion], + sep = ",") # 7. replace `SELECT_PLACEHOLDER` with valid query # located in .query$url in query/fields - url <- url_parse(.query$url) # note: this assumes a single url every time + url <- httr2::url_parse(.query$url) # note: this assumes a single url every time url$query$fields <- field_text url$query$qa <- assertion_text - .query$url <- url_build(url) + .query$url <- httr2::url_build(url) .query$select <- NULL } } @@ -830,13 +789,15 @@ check_select <- function(.query){ #' Check for valid `type` #' @noRd #' @keywords Internal -check_type_valid <- function(type, valid, error_call = caller_env()) { +check_type_valid <- function(type, + valid, + error_call = rlang::caller_env()) { if(!any(valid == type)){ - bullets <- c( + c( glue("Unrecognised metadata requested."), i = "See `?show_all()` for a list of valid metadata types.", x = glue("Can't find metadata type `{type}`.") - ) - abort(bullets, call = error_call) + ) |> + cli::cli_abort(call = error_call) } } diff --git a/R/check_queue.R b/R/check_queue.R index f9e0f5a7..213866cb 100644 --- a/R/check_queue.R +++ b/R/check_queue.R @@ -21,8 +21,6 @@ check_queue <- function(.query, wait = FALSE){ } #' Internal function to check queue status, with rate limiting -#' @importFrom purrr rate_delay -#' @importFrom purrr rate_sleep #' @noRd #' @keywords Internal check_queue_loop <- function(.query){ @@ -30,9 +28,9 @@ check_queue_loop <- function(.query){ current_queue <- .query$queue_size continue <- TRUE iter <- 1 - verbose <- pour("package", "verbose", .pkg = "galah") + verbose <- potions::pour("package", "verbose", .pkg = "galah") if(verbose){ - inform(glue("Current queue length: {current_queue}")) + cli::cli_inform("Current queue length: {current_queue}") } while(continue == TRUE){ .query <- check_occurrence_status(.query) @@ -46,7 +44,7 @@ check_queue_loop <- function(.query){ return(.query) }else{ current_queue <- check_queue_size(.query, current_queue) - rate_sleep(rate_object, quiet = verbose) + purrr::rate_sleep(rate_object, quiet = verbose) } }else{ return(.query) @@ -55,21 +53,20 @@ check_queue_loop <- function(.query){ } #' Internal function for rate limiting -#' @importFrom purrr rate_backoff #' @noRd #' @keywords Internal set_rate <- function(){ - rate_backoff(pause_base = 0.5, - pause_cap = 60, - max_times = 100, - jitter = FALSE) + purrr::rate_backoff(pause_base = 0.5, + pause_cap = 60, + max_times = 100, + jitter = FALSE) } #' Internal function to check queue size #' @noRd #' @keywords Internal check_queue_size <- function(.query, current_queue){ - verbose <- pour("package", "verbose", .pkg = "galah") + verbose <- potions::pour("package", "verbose", .pkg = "galah") if(.query$queue_size < current_queue & .query$queue_size > 0){ current_queue <- .query$queue_size if(verbose){ diff --git a/R/handle_quosures_GBIF.R b/R/handle_quosures_GBIF.R index 6f5df1df..804ed37d 100644 --- a/R/handle_quosures_GBIF.R +++ b/R/handle_quosures_GBIF.R @@ -14,11 +14,10 @@ parse_quosures_data_gbif <- function(dots){ if(length(dots) > 0){ result <- purrr::map(dots, switch_expr_type_pred) - names(result) <- rep("predicate", length(result)) - + if(length(result) > 1L){ result <- list(type = jsonlite::unbox("and"), - predicates = result) + predicates = result) } class(result) <- c("galah_filter_predicate", "list") result @@ -116,7 +115,7 @@ parse_relational_pred <- function(x){ value = rhs) list( type = "not", - predicate = purrr::map(result, jsonlite::unbox)) + purrr::map(result, jsonlite::unbox)) # everything else is flat }else{ operator_text <- switch(operator, @@ -164,7 +163,6 @@ parse_logical_pred <- function(x){ env = rlang::quo_get_env(x)) |> switch_expr_type_pred() }) - names(subpredicates) <- rep("predicate", length(subpredicates)) list(type = jsonlite::unbox(logical_string), predicates = subpredicates) } @@ -192,14 +190,10 @@ parse_exclamation_pred <- function(x){ next_section$type <- "isNotNull" next_section }else{ - list( - type = "not", - predicate = {next_section}) + list(type = "not", next_section) } }else{ - list( - type = "not", - predicate = {next_section}) + list(type = "not", next_section) } } @@ -258,9 +252,7 @@ parse_between_pred <- function(x){ purrr::map(.f = jsonlite::unbox) list(type = jsonlite::unbox("and"), - predicates = list( - predicate = lower_bound, - predicate = upper_bound)) + predicates = list(lower_bound, upper_bound)) } #' Parse `call`s that contain `%in%` diff --git a/R/parse_checks.R b/R/parse_checks.R index f2bcc024..f7436248 100644 --- a/R/parse_checks.R +++ b/R/parse_checks.R @@ -12,17 +12,32 @@ parse_checks <- function(.query){ .query <- .query |> check_identifiers() |> check_select() - if(pour("package", "run_checks")) { + if(potions::pour("package", "run_checks")) { .query <- .query |> check_login() |> check_reason() |> check_fields() |> check_profiles() } - if(.query$type == "data/distributions" & !is.null(.query[["metadata/distributions"]])){ + # special cases: + # distributions + if(.query$type == "data/distributions" & + !is.null(.query[["metadata/distributions"]])){ .query$url <- tibble(url = glue(utils::URLdecode(.query$url), id = .query[["metadata/distributions"]]$id)) } + # GBIF predicates: + if(any(names(.query) == "predicates")){ + result <- .query |> + purrr::pluck("predicates") |> + build_predicates() + .query$predicates <- result + } + # TODO: might need to promote this up a bit + # the problem is that we need to add function-specific content + # but that content isn't available here + + # clean up .query <- remove_metadata(.query) } .query diff --git a/R/query_API.R b/R/query_API.R index 2802470c..626504ed 100644 --- a/R/query_API.R +++ b/R/query_API.R @@ -3,23 +3,19 @@ #' Note that this is a wrapper to `query_API_internal()` to handle single or #' multiple urls. Multiple urls *must* be given as a tibble, which *must* have a #' column named `url`. -#' @importFrom dplyr bind_cols -#' @importFrom dplyr bind_rows -#' @importFrom dplyr select -#' @importFrom dplyr slice -#' @importFrom purrr map #' @noRd #' @keywords Internal query_API <- function(.query, error_call = caller_env()) { if(inherits(.query$url, "data.frame")){ - verbose <- pour("package", "verbose", .pkg = "galah") & nrow(.query$url) > 1 + verbose <- potions::pour("package", "verbose", .pkg = "galah") & + nrow(.query$url) > 1 if(verbose){ progress_bar <- list(name = "Querying API", clear = TRUE) }else{ progress_bar <- FALSE } - map(.x = seq_len(nrow(.query$url)), + purrr::map(.x = seq_len(nrow(.query$url)), .f = function(a){ data_tr <- .query data_tr$url <- .query$url$url[[a]] @@ -38,19 +34,8 @@ query_API <- function(.query, error_call = caller_env()) { #' Internal function to run an API call using httr2 #' @noRd #' @keywords Internal -#' @importFrom dplyr bind_rows -#' @importFrom httr2 request -#' @importFrom httr2 req_error -#' @importFrom httr2 req_headers -#' @importFrom httr2 req_perform -#' @importFrom httr2 req_timeout -#' @importFrom httr2 resp_body_json -#' @importFrom httr2 resp_body_string -#' @importFrom purrr pluck -#' @importFrom rlang abort -#' @importFrom rlang inform query_API_internal <- function(.query, error_call = caller_env()) { - query <- request(.query$url) |> + query <- httr2::request(.query$url) |> add_headers(.query$headers) |> add_options(.query$options) |> # used by GBIF add_body(.query$body) # NOTE: adding `body` converts from GET to POST @@ -59,26 +44,26 @@ query_API_internal <- function(.query, error_call = caller_env()) { check_directory(.query$file) # handle thumbnails (which might fail if missing) - if (any(str_detect(.query$url, "thumbnail"))) { + if(any(stringr::str_detect(.query$url, "thumbnail"))) { query |> - req_error(is_error = \(resp) FALSE) |> - req_perform(path = .query$file, - verbosity = 0) + httr2::req_error(is_error = \(resp) FALSE) |> + httr2::req_perform(path = .query$file, + verbosity = 0) } else { query |> - req_perform(path = .query$file, - verbosity = 0) + httr2::req_perform(path = .query$file, + verbosity = 0) } # then other pings, which should resolve quickly # and can be allowed to fail otherwise }else{ res <- query |> - req_timeout(seconds = 20) |> - req_perform(verbosity = 0) + httr2::req_timeout(seconds = 20) |> + httr2::req_perform(verbosity = 0) if(grepl("^https://api.gbif.org/v1/occurrence/download/request", .query$url)){ - resp_body_string(res) + httr2::resp_body_string(res) }else{ - resp_body_json(res) # may not work for invalid URLs + httr2::resp_body_json(res) # may not work for invalid URLs } } } @@ -99,10 +84,9 @@ add_headers <- function(req, headers){ #' If supplied, add `body` arg to a `request()` #' @noRd #' @keywords Internal -#' @importFrom httr2 req_body_raw add_body <- function(req, body){ if(!is.null(body)){ - req <- req |> req_body_raw(body) + req <- req |> httr2::req_body_raw(body) # note: this is not `req_body_json()` because # we have already converted our list to json text # by this point diff --git a/R/url_lookup.R b/R/url_lookup.R index 5dfe28cb..dfa9406c 100644 --- a/R/url_lookup.R +++ b/R/url_lookup.R @@ -9,32 +9,26 @@ #' calling `verbose` via `pour`. This is so the developer can suppress messages #' independently of user preferences, since this function is often called #' internally. -#' @importFrom dplyr filter -#' @importFrom dplyr pull -#' @importFrom glue glue_data -#' @importFrom glue glue -#' @importFrom potions pour -#' @importFrom rlang abort #' @noRd #' @keywords internal url_lookup <- function(type, ..., quiet = FALSE, - error_call = caller_env()){ + error_call = rlang::caller_env()){ dots <- list(...) - current_atlas <- pour("atlas", "region") + current_atlas <- potions::pour("atlas", "region") # get requested url if(missing(type)){ type <- dots$type } url_string <- node_config |> - filter(node_config$type == {{type}}, - node_config$atlas == {{current_atlas}}) |> - pull(url) + dplyr::filter(node_config$type == {{type}}, + node_config$atlas == {{current_atlas}}) |> + dplyr::pull(url) # parse as needed if(length(url_string) > 0){ if(length(dots) > 0){ - glue_data(dots, url_string) |> + glue::glue_data(dots, url_string) |> as.character() |> utils::URLencode() }else{ @@ -44,11 +38,11 @@ url_lookup <- function(type, if(quiet){ return(NULL) }else{ - bullets <- c( - glue("No API is available for type `{type}`"), + c( + glue::glue("No API is available for type `{type}`"), i = glue("Selected atlas: {current_atlas}"), - i = "Use `show_all_apis()` to list valid API calls") - abort(bullets, call = error_call) + i = "Use `show_all_apis()` to list valid API calls") |> + cli::cli_abort(call = error_call) } } } \ No newline at end of file diff --git a/R/utilities_internal.R b/R/utilities_internal.R index 8a8ebb67..80aec8ab 100644 --- a/R/utilities_internal.R +++ b/R/utilities_internal.R @@ -15,7 +15,8 @@ wanted_columns <- function(type) { "guid", # species search "canonical_name", "status", "rank", - "match_type", "kingdom", "phylum", "class", "order", + "match_type", "confidence", "time_taken", + "kingdom", "phylum", "class", "order", "family", "genus", "species", "vernacular_name", "issues","subkingdom", "superclass", "infraclass", "subclass", "subinfraclass", "suborder", "superorder", @@ -54,7 +55,7 @@ rename_columns <- function(varnames, type) { }, "taxa" = { varnames[varnames == "classs"] <- "class" - varnames[varnames %in% c("usage_key", "usageKey", "guid", "reference_id", "referenceId")] <- "taxon_concept_id" + varnames[varnames %in% c("usage_key", "usageKey", "guid", "reference_id", "referenceId", "key")] <- "taxon_concept_id" varnames[varnames %in% c("genus_name", "genusName")] <- "genus" varnames[varnames %in% c("family_name", "familyName")] <- "family" varnames[varnames %in% c("order_name", "orderName")] <- "order" From 739810ba5b13e89182171df481e60fecda0fe260 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Mon, 4 Aug 2025 13:29:03 +1000 Subject: [PATCH 11/94] rename `parse_` functions to `collapse_` for greater clarity and consistency (#278) `collapse()` now only calls functions with a `collapse_` prefix --- NAMESPACE | 6 - R/build_checks.R | 62 --------- R/collapse.R | 6 +- R/collapse_checks.R | 122 ++++++++++++++++++ ...arse_metadata_lists.R => collapse_lists.R} | 19 ++- ...s_count.R => collapse_occurrences_count.R} | 77 +++++------ ...a_profiles.R => collapse_profile_values.R} | 29 ++--- R/collapse_query.R | 16 +++ ...ecies_count.R => collapse_species_count.R} | 36 +++--- R/handle_request_objects.R | 26 ++-- R/parse_checks.R | 59 --------- R/parse_query.R | 16 --- 12 files changed, 224 insertions(+), 250 deletions(-) delete mode 100644 R/build_checks.R create mode 100644 R/collapse_checks.R rename R/{parse_metadata_lists.R => collapse_lists.R} (78%) rename R/{parse_occurrences_count.R => collapse_occurrences_count.R} (79%) rename R/{parse_metadata_profiles.R => collapse_profile_values.R} (73%) create mode 100644 R/collapse_query.R rename R/{parse_species_count.R => collapse_species_count.R} (67%) delete mode 100644 R/parse_checks.R delete mode 100644 R/parse_query.R diff --git a/NAMESPACE b/NAMESPACE index 1f56beb7..6bbba5a4 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -121,7 +121,6 @@ importFrom(dplyr,any_of) importFrom(dplyr,arrange) importFrom(dplyr,bind_cols) importFrom(dplyr,bind_rows) -importFrom(dplyr,c_across) importFrom(dplyr,case_match) importFrom(dplyr,collapse) importFrom(dplyr,collect) @@ -129,7 +128,6 @@ importFrom(dplyr,compute) importFrom(dplyr,count) importFrom(dplyr,desc) importFrom(dplyr,filter) -importFrom(dplyr,full_join) importFrom(dplyr,group_by) importFrom(dplyr,join_by) importFrom(dplyr,last_col) @@ -138,12 +136,9 @@ importFrom(dplyr,pull) importFrom(dplyr,relocate) importFrom(dplyr,rename) importFrom(dplyr,right_join) -importFrom(dplyr,rowwise) importFrom(dplyr,select) importFrom(dplyr,slice) importFrom(dplyr,slice_head) -importFrom(dplyr,starts_with) -importFrom(dplyr,ungroup) importFrom(glue,glue) importFrom(glue,glue_collapse) importFrom(glue,glue_data) @@ -174,7 +169,6 @@ importFrom(rlang,f_lhs) importFrom(rlang,f_rhs) importFrom(rlang,format_error_bullets) importFrom(rlang,get_env) -importFrom(rlang,get_expr) importFrom(rlang,inform) importFrom(rlang,is_bare_environment) importFrom(rlang,is_empty) diff --git a/R/build_checks.R b/R/build_checks.R deleted file mode 100644 index 594f00ac..00000000 --- a/R/build_checks.R +++ /dev/null @@ -1,62 +0,0 @@ -#' Internal function to build necessary metadata into a single object -#' I.e. to parse a `query_set` object within `collapse()` -#' @noRd -#' @keywords Internal -build_checks <- function(.query){ - # get basic description of `query_set` object - n <- length(.query) - names_vec <- unlist(lapply(.query, function(a){a$type})) - # look for any `data` - data_lookup <- grepl("^data", names_vec) - if(any(data_lookup)){ - data_names <- names_vec[data_lookup] - # parse any `metadata` - metadata_results <- parse_metadata(names_vec, .query) - # parse `data`, including supplied metadata - # this assumes only one `data` field is available per `query_set` - .query[[which(data_lookup)]] |> - add_metadata(metadata_results) - }else if(any(names_vec %in% c("metadata/fields-unnest", - "metadata/profiles-unnest", - "metadata/taxa-unnest"))){ - # this code accounts for `unnest` functions that require lookups - # metadata/fields-unnest calls check_fields(), requiring fields and assertions - # metadata/profiles-unnest calls profile_short_name(), which requires profiles - if(length(.query) > 1){ - metadata_results <- parse_metadata(names_vec, .query) - .query[[2]] |> - add_metadata(metadata_results) - }else{ - .query[[1]] - } - }else{ - # if no metadata are needed, return .query unaltered - .query[[1]] - } -} - -#' Internal function to parse metadata -#' @noRd -#' @keywords Internal -parse_metadata <- function(names_vec, .query){ - metadata_lookup <- grepl("^metadata", names_vec) & - !grepl("-unnest$", names_vec) # unnest functions only parse in collect() - if(any(metadata_lookup)){ - metadata_names <- names_vec[metadata_lookup] - metadata_results <- purrr::map(.query[which(metadata_lookup)], collect) - names(metadata_results) <- metadata_names - metadata_results - }else{ - NULL - } -} - -#' Internal function to pass metadata to `collapse()` functions -#' called by `compute.query_set()` -#' @noRd -#' @keywords Internal -add_metadata <- function(query, meta){ - result <- c(query, meta) - class(result) <- "query" - return(result) -} \ No newline at end of file diff --git a/R/collapse.R b/R/collapse.R index 9b7dfbfa..d05c05c1 100644 --- a/R/collapse.R +++ b/R/collapse.R @@ -60,9 +60,9 @@ collapse.query_set <- function(x, ...){ purrr::pluck(!!!list(1)) }else{ x |> - build_checks() |> - parse_checks() |> - parse_query() + collapse_build_checks() |> + collapse_run_checks() |> + collapse_query() } } diff --git a/R/collapse_checks.R b/R/collapse_checks.R new file mode 100644 index 00000000..1551217e --- /dev/null +++ b/R/collapse_checks.R @@ -0,0 +1,122 @@ +#' Internal function to build necessary metadata into a single object +#' I.e. to parse a `query_set` object within `collapse()` +#' @noRd +#' @keywords Internal +collapse_build_checks <- function(.query){ + # get basic description of `query_set` object + n <- length(.query) + names_vec <- unlist(lapply(.query, function(a){a$type})) + # look for any `data` + data_lookup <- grepl("^data", names_vec) + if(any(data_lookup)){ + data_names <- names_vec[data_lookup] + # parse any `metadata` + metadata_results <- collapse_parse_metadata(names_vec, .query) + # parse `data`, including supplied metadata + # this assumes only one `data` field is available per `query_set` + .query[[which(data_lookup)]] |> + add_metadata(metadata_results) + }else if(any(names_vec %in% c("metadata/fields-unnest", + "metadata/profiles-unnest", + "metadata/taxa-unnest"))){ + # this code accounts for `unnest` functions that require lookups + # metadata/fields-unnest calls check_fields(), requiring fields and assertions + # metadata/profiles-unnest calls profile_short_name(), which requires profiles + if(length(.query) > 1){ + metadata_results <- collapse_parse_metadata(names_vec, .query) + .query[[2]] |> + collapse_add_metadata(metadata_results) + }else{ + .query[[1]] + } + }else{ + # if no metadata are needed, return .query unaltered + .query[[1]] + } +} + +#' Internal function to run metadata checks +#' This is useful for testing, particularly in testing `galah_select()` +#' called by `collapse()` +#' @noRd +#' @keywords Internal +collapse_run_checks <- function(.query){ + # "data/" functions require pre-processing of metadata, + # as do `unnest()`/`show_values()` functions + if(grepl("^data/", .query$type) | + grepl("-unnest$", .query$type)){ + # some checks should happen regardless of `run_checks` + .query <- .query |> + check_identifiers() |> + check_select() + if(potions::pour("package", "run_checks")) { + .query <- .query |> + check_login() |> + check_reason() |> + check_fields() |> + check_profiles() + } + # special cases: + # distributions + if(.query$type == "data/distributions" & + !is.null(.query[["metadata/distributions"]])){ + .query$url <- tibble(url = glue(utils::URLdecode(.query$url), + id = .query[["metadata/distributions"]]$id)) + } + # GBIF predicates: + if(any(names(.query) == "predicates")){ + result <- .query |> + purrr::pluck("predicates") |> + build_predicates() + .query$predicates <- result + } + # TODO: might need to promote this up a bit + # the problem is that we need to add function-specific content + # but that content isn't available here + + # clean up + .query <- collapse_remove_metadata(.query) + } + .query +} + +#' Internal function to collapse metadata +#' @noRd +#' @keywords Internal +collapse_metadata <- function(names_vec, .query){ + metadata_lookup <- grepl("^metadata", names_vec) & + !grepl("-unnest$", names_vec) # unnest functions only parse in collect() + if(any(metadata_lookup)){ + metadata_names <- names_vec[metadata_lookup] + metadata_results <- purrr::map(.query[which(metadata_lookup)], collect) + names(metadata_results) <- metadata_names + metadata_results + }else{ + NULL + } +} + +#' Internal function to pass metadata to `collapse()` functions +#' called by `compute.query_set()` +#' @noRd +#' @keywords Internal +collapse_add_metadata <- function(query, meta){ + result <- c(query, meta) + class(result) <- "query" + return(result) +} + +#' Internal function to reduce size of internally computed objects +#' called by `compute.query()` +#' @noRd +#' @keywords Internal +collapse_remove_metadata <- function(.query){ + names_lookup <- grepl("^metadata/", names(.query)) + if(any(names_lookup)){ + x <- .query[!names_lookup] + }else{ + x <- .query + } + class(x) <- "query" + x +} \ No newline at end of file diff --git a/R/parse_metadata_lists.R b/R/collapse_lists.R similarity index 78% rename from R/parse_metadata_lists.R rename to R/collapse_lists.R index 5dca92f0..be40ecdb 100644 --- a/R/parse_metadata_lists.R +++ b/R/collapse_lists.R @@ -2,11 +2,10 @@ #' Required for pagination #' Should run a query with `max = 0` to get total n #' Then use `max` and `offset` to paginate up to `n` -#' @importFrom dplyr select #' @noRd #' @keywords Internal -parse_lists <- function(.query){ - url <- url_parse(.query$url) +collapse_lists <- function(.query){ + url <- httr2::url_parse(.query$url) n_requested <- as.integer(url$query$max) # make decisions about how much pagination is needed if(n_requested <= 500){ # we haven't hit pagination limit @@ -15,19 +14,19 @@ parse_lists <- function(.query){ n <- get_max_n(.query) n_pages <- ceiling(n$max_requested / n$paginate) offsets <- (seq_len(n_pages) - 1) * n$paginate - result <- tibble( + result <- tibble::tibble( offset = offsets, max = c( rep(n$paginate, n_pages - 1), n$max_requested - offsets[n_pages])) - result$url <- lapply( + result$url <- purrr::map( split(result, seq_len(nrow(result))), function(a){ url$query <- list(offset = a$offset, max = a$max) - url_build(url) + httr2::url_build(url) }) |> unlist() - .query$url <- select(result, "url") + .query$url <- dplyr::select(result, "url") } .query } @@ -36,7 +35,7 @@ parse_lists <- function(.query){ #' @noRd #' @keywords Internal get_max_n <- function(.query){ - url <- url_parse(.query$url) + url <- httr2::url_parse(.query$url) if(is_gbif()){ count_field <- "count" }else{ @@ -46,10 +45,10 @@ get_max_n <- function(.query){ paginate = 500, max_available = { url$query <- list(max = 0) - list(url = url_build(url), + list(url = httr2::url_build(url), headers = .query$headers) |> query_API() |> - pluck(count_field) # NOTE: only tested for ALA + purrr::pluck(count_field) # NOTE: only tested for ALA }) n$max_requested <- min(c(n$requested, n$max_available)) return(n) diff --git a/R/parse_occurrences_count.R b/R/collapse_occurrences_count.R similarity index 79% rename from R/parse_occurrences_count.R rename to R/collapse_occurrences_count.R index c69fb3ff..4cd05674 100644 --- a/R/parse_occurrences_count.R +++ b/R/collapse_occurrences_count.R @@ -9,7 +9,7 @@ #' @param .query An object of class `data_query` #' @keywords Internal #' @noRd -parse_occurrences_count <- function(.query){ +collapse_occurrences_count <- function(.query){ if(is_gbif()){ if(.query$expand){ abort("Grouped counts haven't been (re)implemented for GBIF yet") @@ -19,9 +19,9 @@ parse_occurrences_count <- function(.query){ } }else{ if(.query$expand){ - parse_occurrences_count_groupby(.query) + collapse_occurrences_count_groupby(.query) }else{ - parse_occurrences_count_nogroupby(.query) + collapse_occurrences_count_nogroupby(.query) } } } @@ -31,8 +31,8 @@ parse_occurrences_count <- function(.query){ #' Internal function to handle facet counting, adjustment etc. #' @noRd #' @keywords Internal -parse_occurrences_count_nogroupby <- function(.query){ - url <- url_parse(.query$url) +collapse_occurrences_count_nogroupby <- function(.query){ + url <- httr2::url_parse(.query$url) # check if a limit has been set if(!is.null(url$query$flimit)){ @@ -46,7 +46,7 @@ parse_occurrences_count_nogroupby <- function(.query){ if(.query$arrange$slice_n < n_facets){ url$query$foffset <- n_facets - .query$arrange$slice_n } - .query$url <- url_build(url) + .query$url <- httr2::url_build(url) .query # message when limit is hit @@ -55,11 +55,11 @@ parse_occurrences_count_nogroupby <- function(.query){ limit <- url$query$flimit |> prettyNum(big.mark=",", preserve.width="none") n_total_facets <- n_facets |> prettyNum(big.mark=",", preserve.width="none") - bullets <- c( + c( cli::cli_text(cli::col_yellow(glue("Limiting to first {limit} of {n_total_facets} rows."))), cli::cli_text(cli::col_magenta("Use `atlas_counts(limit = )` to return more rows.")) - ) - inform(bullets) + ) |> + cli::cli_inform() } # .query$url <- url_build(url) .query @@ -70,21 +70,13 @@ parse_occurrences_count_nogroupby <- function(.query){ } #' Determine set of queries when expand = TRUE -#' @importFrom dplyr bind_rows -#' @importFrom dplyr c_across -#' @importFrom dplyr full_join -#' @importFrom dplyr rowwise -#' @importFrom dplyr starts_with -#' @importFrom dplyr ungroup -#' @importFrom glue glue_collapse -#' @importFrom httr2 url_build -#' @importFrom httr2 url_parse #' @noRd #' @keywords Internal -parse_occurrences_count_groupby <- function(.query, error_call = caller_env()){ +collapse_occurrences_count_groupby <- function(.query, + error_call = caller_env()){ data_cached <- .query # get url - url <- url_parse(.query$url) + url <- httr2::url_parse(.query$url) # remove last-provided facet facet_list <- url$query[names(url$query) == "facets"] @@ -96,11 +88,10 @@ parse_occurrences_count_groupby <- function(.query, error_call = caller_env()){ length(facet_list) > 1) { atlas <- pour("atlas", "region") n_fields <- length(facet_list) - bullets <- c( + c( "Too many fields passed to `group_by()`.", - x = glue("Selected atlas ({atlas}) accepts a maximum of 1 field, not {n_fields}.") - ) - abort(bullets, call = error_call) + x = "Selected atlas ({atlas}) accepts a maximum of 1 field, not {n_fields}.") |> + cli::cli_abort(call = error_call) } # save out facet limits to add back later @@ -114,15 +105,15 @@ parse_occurrences_count_groupby <- function(.query, error_call = caller_env()){ url$query <- c( url$query[names(url$query) != "facets"], facet_list[-length(facet_list)]) - .query$url <- url_build(url) + .query$url <- httr2::url_build(url) # check number of facets n_facets <- check_facet_count(.query, warn = FALSE) # incorporate this into the query - url <- url_parse(.query$url) + url <- httr2::url_parse(.query$url) url$query$flimit <- max(n_facets) - .query$url <- url_build(url) + .query$url <- httr2::url_build(url) # run query to get list of count tibbles result <- query_API(.query) @@ -145,40 +136,40 @@ parse_occurrences_count_groupby <- function(.query, error_call = caller_env()){ names(levels_list) <- names(result) levels_list <- c(levels_list, list(stringsAsFactors = FALSE)) result_df <- do.call(expand.grid, levels_list) |> - tibble() + tibble::tibble() for(i in seq_along(result_list)){ - result_df <- full_join(result_df, result_list[[i]], by = kept_facets[i]) + result_df <- dplyr::full_join(result_df, result_list[[i]], by = kept_facets[i]) } }else{ result_df <- result_list[[1]] } # # extract existing fq statements - query <- url_parse(.query$url)$query + query <- httr2::url_parse(.query$url)$query if(is.null(query$fq)){ fqs <- NULL }else{ fqs <- strsplit(query$fq, "AND")[[1]] - fqs <- fqs[!grepl(paste(kept_facets, collapse = "|"), fqs)] # remove fqs that relate to parsed facets + fqs <- fqs[!grepl(glue::glue_collapse(kept_facets, sep = "|"), fqs)] # remove fqs that relate to parsed facets if(length(fqs) < 1){ fqs <- NULL }else{ - fqs <- paste(fqs, collapse = " AND ") + fqs <- glue::glue_collapse(fqs, sep = " AND ") } } # glue `fq` statements together result_df <- result_df |> - rowwise() |> - mutate(query = glue_collapse(c_across(starts_with("fq")), sep = " AND ")) |> - select(-starts_with("fq")) |> - ungroup() + dplyr::rowwise() |> + dplyr::mutate(query = glue::glue_collapse(dplyr::c_across(dplyr::starts_with("fq")), sep = " AND ")) |> + dplyr::select(-dplyr::starts_with("fq")) |> + dplyr::ungroup() if(!is.null(fqs)){ - result_df$query <- glue("{fqs} AND {result_df$query}") + result_df$query <- glue::glue("{fqs} AND {result_df$query}") } # recombine into urls - url_final <- url_parse(.query$url) + url_final <- httr2::url_parse(.query$url) query_without_fq <- c( url_final$query[!(names(url_final$query) %in% c("fq", "facets", "flimit", "foffset"))], @@ -187,11 +178,11 @@ parse_occurrences_count_groupby <- function(.query, error_call = caller_env()){ url_list <- lapply(result_df$query, function(a, url){ url$query <- c(list(fq = a), query_without_fq) - url_build(url) + httr2::url_build(url) }, url = url_final) result_df$url <- unlist(url_list) - result_df <- select(result_df, -query) + result_df <- dplyr::select(result_df, -query) # join and export result <- c(list( @@ -207,7 +198,7 @@ parse_occurrences_count_groupby <- function(.query, error_call = caller_env()){ #' @noRd #' @keywords Internal check_facet_count <- function(.query, warn = TRUE, error_call = caller_env()){ - url <- url_parse(.query$url) + url <- httr2::url_parse(.query$url) current_limit <- url$query$flimit if(is.null(current_limit)){ @@ -222,13 +213,13 @@ check_facet_count <- function(.query, warn = TRUE, error_call = caller_env()){ } url$query$flimit <- 0 temp_data <- .query - temp_data$url <- url_build(url) + temp_data$url <- httr2::url_build(url) temp_data$slot_name <- NULL result <- query_API(temp_data) if(length(result) < 1){ 0 }else{ - lapply(result, function(a){a$count}) |> unlist() + purrr::map(result, function(a){a$count}) |> unlist() } # if(inherits(result, "data.frame")){ # group_by arg present # n_available <- result$count diff --git a/R/parse_metadata_profiles.R b/R/collapse_profile_values.R similarity index 73% rename from R/parse_metadata_profiles.R rename to R/collapse_profile_values.R index db6a6780..de03d0c1 100644 --- a/R/parse_metadata_profiles.R +++ b/R/collapse_profile_values.R @@ -1,27 +1,27 @@ -#' Internal function to call `compute` for `request_metadata(type = "profiles-unnest")` +#' Internal function to call `collapse` for `request_metadata(type = "profiles-unnest")` #' @noRd #' @keywords Internal -parse_profile_values <- function(.query){ +collapse_profile_values <- function(.query){ url <- .query |> - pluck("url") |> - url_parse() + purrr::pluck("url") |> + httr2::url_parse() profile_name <- extract_profile_name(url) short_name <- profile_short_name(profile_name) if (!pour("atlas", "region") == "Spain") { path_name <- url |> - pluck("path") |> + httr2::pluck("path") |> dirname() - url$path <- glue("{path_name}/{short_name}") + url$path <- glue::glue("{path_name}/{short_name}") } result <- list(type = .query$type, - url = url_build(url)) + url = httr2::url_build(url)) class(result) <- "query" return(result) } # this doesn't print for some reason #' Internal function to convert between long and short names -#' for data profiles. Only used by `compute_profile_values()` +#' for data profiles. Only used by `collapse_profile_values()` #' @noRd #' @keywords Internal profile_short_name <- function(profile) { @@ -43,11 +43,10 @@ profile_short_name <- function(profile) { } } if (is.na(short_name)) { - bullets <- c( + c( "Unknown profile detected.", - i = "See a listing of valid data quality profiles with `show_all_profiles()`." - ) - abort(bullets, call = caller_env()) + i = "See a listing of valid data quality profiles with `show_all_profiles()`.") |> + cli::cli_abort(call = caller_env()) }else{ short_name } @@ -58,13 +57,13 @@ profile_short_name <- function(profile) { #' @noRd #' @keywords Internal extract_profile_name <- function(url) { - atlas <- pour("atlas", "region") + atlas <- potions::pour("atlas", "region") if (atlas == "Spain") { profile_name <- url |> - pluck("query", "profileName") + purrr::pluck("query", "profileName") } else { profile_name <- url |> - pluck("path") |> + purrr::pluck("path") |> basename() } return(profile_name) diff --git a/R/collapse_query.R b/R/collapse_query.R new file mode 100644 index 00000000..92021c55 --- /dev/null +++ b/R/collapse_query.R @@ -0,0 +1,16 @@ +#' Internal function to parse a `query_set` into a single `query` +#' @param x a `query_set` +#' @noRd +#' @keywords Internal +collapse_query <- function(x){ + switch(x$type, + "data/occurrences-count-groupby" = collapse_occurrences_count(x), + "data/occurrences-count" = collapse_occurrences_count(x), + "data/species-count" = collapse_species_count(x), + # "-unnest" functions require some checks + "metadata/profiles-unnest" = collapse_profile_values(x), # check this + # some "metadata/" functions require pagination under some circumstances + "metadata/lists" = collapse_lists(x), # always paginates + x # remaining "metadata/" functions are passed as-is # fixme to make a new object type + ) +} \ No newline at end of file diff --git a/R/parse_species_count.R b/R/collapse_species_count.R similarity index 67% rename from R/parse_species_count.R rename to R/collapse_species_count.R index 6b0f5b6c..5239ac2c 100644 --- a/R/parse_species_count.R +++ b/R/collapse_species_count.R @@ -1,29 +1,23 @@ #' Internal function to compute for species counts -#' @importFrom tibble as_tibble #' @noRd #' @keywords Internal -parse_species_count <- function(.query){ +collapse_species_count <- function(.query){ if(.query$expand){ - .query <- build_species_query_list(.query) + .query <- collapse_species_query_list(.query) }else{ - .query$url <- as_tibble(data.frame(url = .query$url)) + .query$url <- tibble::tibble(url = .query$url) } .query } #' Internal function to generate correct set of species-count queries when #' `group_by()` is set -#' @importFrom dplyr bind_cols -#' @importFrom dplyr select -#' @importFrom httr2 url_build -#' @importFrom httr2 url_parse -#' @importFrom tibble tibble #' @noRd #' @keywords Internal -build_species_query_list <- function(.query){ +collapse_species_query_list <- function(.query){ # remove `species_facets()` from query - url <- url_parse(.query$url) + url <- httr2::url_parse(.query$url) query_temp <- url$query query_temp <- query_temp[-which( unlist(query_temp) == species_facets() & @@ -34,7 +28,7 @@ build_species_query_list <- function(.query){ # rebuild a .query object for this query data_temp <- .query data_temp$type <- "data/occurrences-count" - data_temp$url <- url_build(url) + data_temp$url <- httr2::url_build(url) data_temp$expand <- ifelse(n_facet_terms > 1, TRUE, FALSE) # collect using `occurrences-count` code (to parse expand correctly) @@ -45,13 +39,13 @@ build_species_query_list <- function(.query){ split(df, seq_len(nrow(df))), function(a){ x <- a[, - ncol(a)] - paste( - paste(names(x), x, sep = ":"), - collapse = " AND ") + glue::glue_collapse( + glue::glue("{names(x)}:{x}"), + sep = " AND ") }) |> unlist() # modify url to only have `species_facets()` in facets slot - url <- url_parse(.query$url) + url <- httr2::url_parse(.query$url) query <- url$query query <- query[-which( unlist(query_temp) != species_facets() & @@ -61,14 +55,14 @@ build_species_query_list <- function(.query){ # create new fq urls new_fqs <- paste(url$query$fq, fq_args, sep = " AND ") - urls <- lapply(new_fqs, function(a, x){ + urls <- purrr::map(new_fqs, function(a, x){ x$query$fq <- a - url_build(x) + httr2::url_build(x) }, x = url) |> unlist() # convert to a tibble to pass back to .query - .query$url <- bind_cols( - select(df, -count), - tibble(url = urls)) + .query$url <- dplyr::bind_cols( + dplyr::select(df, -count), + tibble::tibble(url = urls)) return(.query) } diff --git a/R/handle_request_objects.R b/R/handle_request_objects.R index 3de50ae5..b454c62f 100644 --- a/R/handle_request_objects.R +++ b/R/handle_request_objects.R @@ -1,16 +1,12 @@ #' This should parse out a request object and return quosures thereafter -#' @importFrom rlang eval_tidy -#' @importFrom rlang get_expr -#' @importFrom rlang quo_get_expr -#' @importFrom stringr str_detect #' @noRd #' @keywords Internal detect_request_object <- function(dots){ if (length(dots) > 0) { - call_string <- get_expr(dots)[[1]] |> - quo_get_expr() |> + call_string <- rlang::get_expr(dots)[[1]] |> + rlang::quo_get_expr() |> deparse() |> - paste(collapse = " ") # captures multi-lines + glue::glue_collapse(sep = " ") # captures multi-lines # note: no leading "^" below, # because pipes can parse to e.g. `galah_identify(galah_call(...` types <- c( @@ -21,8 +17,8 @@ detect_request_object <- function(dots){ "^~.$", "^.$") |> paste(collapse = "|") - if (str_detect(call_string, types)) { # note: "~." or "." indicate presence of the magrittr pipe (%>%) - eval_request <- eval_tidy(dots[[1]]) + if (stringr::str_detect(call_string, types)) { # note: "~." or "." indicate presence of the magrittr pipe (%>%) + eval_request <- rlang::eval_tidy(dots[[1]]) c(list(eval_request), dots[-1]) }else{ dots @@ -42,7 +38,7 @@ update_data_request <- function(data_request, ...){ dots <- dots[[1]] } } - result <- lapply( + result <- purrr::map( names(data_request), # i.e. for all slots in object of class `data_request` function(a){ if(any(names(dots) == a)){ # object is present in `data_request` @@ -88,8 +84,8 @@ update_data_request <- function(data_request, ...){ #' @noRd #' @keywords Internal update_select <- function(x, y){ - quosure_check_x <- lapply(x, is_quosure) |> unlist() - quosure_check_y <- lapply(y, is_quosure) |> unlist() + quosure_check_x <- purrr::map(x, rlang::is_quosure) |> unlist() + quosure_check_y <- purrr::map(y, rlang::is_quosure) |> unlist() if(any(quosure_check_y)){ result <- append(x[quosure_check_x], y[quosure_check_y]) }else if(any(quosure_check_x)){ @@ -114,7 +110,7 @@ update_select <- function(x, y){ #' @keywords Internal bind_unique_rows <- function(x, y, column){ result <- list(x, y) |> - bind_rows() |> - tibble() - filter(result, !duplicated(result[[column]])) + dplyr::bind_rows() |> + tibble::tibble() + dplyr::filter(result, !duplicated(result[[column]])) } \ No newline at end of file diff --git a/R/parse_checks.R b/R/parse_checks.R deleted file mode 100644 index f7436248..00000000 --- a/R/parse_checks.R +++ /dev/null @@ -1,59 +0,0 @@ -#' Internal function to run metadata checks -#' This is useful for testing, particularly in testing `galah_select()` -#' called by `collapse()` -#' @noRd -#' @keywords Internal -parse_checks <- function(.query){ - # "data/" functions require pre-processing of metadata, - # as do `unnest()`/`show_values()` functions - if(grepl("^data/", .query$type) | - grepl("-unnest$", .query$type)){ - # some checks should happen regardless of `run_checks` - .query <- .query |> - check_identifiers() |> - check_select() - if(potions::pour("package", "run_checks")) { - .query <- .query |> - check_login() |> - check_reason() |> - check_fields() |> - check_profiles() - } - # special cases: - # distributions - if(.query$type == "data/distributions" & - !is.null(.query[["metadata/distributions"]])){ - .query$url <- tibble(url = glue(utils::URLdecode(.query$url), - id = .query[["metadata/distributions"]]$id)) - } - # GBIF predicates: - if(any(names(.query) == "predicates")){ - result <- .query |> - purrr::pluck("predicates") |> - build_predicates() - .query$predicates <- result - } - # TODO: might need to promote this up a bit - # the problem is that we need to add function-specific content - # but that content isn't available here - - # clean up - .query <- remove_metadata(.query) - } - .query -} - -#' Internal function to reduce size of internally computed objects -#' called by `compute.query()` -#' @noRd -#' @keywords Internal -remove_metadata <- function(.query){ - names_lookup <- grepl("^metadata/", names(.query)) - if(any(names_lookup)){ - x <- .query[!names_lookup] - }else{ - x <- .query - } - class(x) <- "query" - x -} \ No newline at end of file diff --git a/R/parse_query.R b/R/parse_query.R deleted file mode 100644 index 29c95046..00000000 --- a/R/parse_query.R +++ /dev/null @@ -1,16 +0,0 @@ -#' Internal function to parse a `query_set` into a single `query` -#' @param x a `query_set` -#' @noRd -#' @keywords Internal -parse_query <- function(x){ - switch(x$type, - "data/occurrences-count-groupby" = parse_occurrences_count(x), - "data/occurrences-count" = parse_occurrences_count(x), - "data/species-count" = parse_species_count(x), - # "-unnest" functions require some checks - "metadata/profiles-unnest" = parse_profile_values(x), # check this - # some "metadata/" functions require pagination under some circumstances - "metadata/lists" = parse_lists(x), # always paginates - x # remaining "metadata/" functions are passed as-is # fixme to make a new object type - ) -} \ No newline at end of file From aff474a003dae975c38a4b59e84bf480a8601957 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Mon, 4 Aug 2025 15:08:55 +1000 Subject: [PATCH 12/94] Fix bugs from recent refactoring (#278) --- NAMESPACE | 18 -- R/as_query-species.R | 4 +- R/as_query-species_count.R | 4 +- R/as_query.R | 23 +- R/coalesce.R | 6 +- R/collapse.R | 6 +- R/collapse_checks.R | 10 +- R/galah_filter.R | 20 +- R/galah_group_by.R | 17 +- R/handle_quosures.R | 307 ++++++++++-------------- R/handle_quosures_GBIF.R | 6 +- man/as_query.data_request.Rd | 16 +- man/collapse.data_request.Rd | 6 +- tests/testthat/test-atlas_occurrences.R | 23 -- tests/testthat/test-atlas_species.R | 32 ++- 15 files changed, 232 insertions(+), 266 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 6bbba5a4..2177ba6e 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -158,31 +158,13 @@ importFrom(rlang,.data) importFrom(rlang,abort) importFrom(rlang,as_label) importFrom(rlang,as_name) -importFrom(rlang,as_quosure) -importFrom(rlang,as_string) importFrom(rlang,caller_env) -importFrom(rlang,enquo) importFrom(rlang,enquos) -importFrom(rlang,eval_tidy) -importFrom(rlang,expr_text) -importFrom(rlang,f_lhs) -importFrom(rlang,f_rhs) importFrom(rlang,format_error_bullets) -importFrom(rlang,get_env) importFrom(rlang,inform) -importFrom(rlang,is_bare_environment) -importFrom(rlang,is_empty) importFrom(rlang,is_list) importFrom(rlang,is_quosure) importFrom(rlang,is_string) -importFrom(rlang,is_syntactic_literal) -importFrom(rlang,new_quosure) -importFrom(rlang,parse_expr) -importFrom(rlang,quo_get_env) -importFrom(rlang,quo_get_expr) -importFrom(rlang,quo_is_call) -importFrom(rlang,quo_is_symbol) -importFrom(rlang,quo_squash) importFrom(rlang,try_fetch) importFrom(rlang,warn) importFrom(sf,st_as_sf) diff --git a/R/as_query-species.R b/R/as_query-species.R index a9d8a0ec..452fa5e7 100644 --- a/R/as_query-species.R +++ b/R/as_query-species.R @@ -15,7 +15,7 @@ as_query_species <- function(.query){ #' calculate the query to be returned for a given living atlas #' @noRd #' @keywords Internal -collapse_species_atlas <- function(.query){ +as_query_species_atlas <- function(.query){ # set default columns if(is.null(.query$select)){ .query$select <- galah_select(group = "taxonomy") @@ -88,7 +88,7 @@ parse_select_species <- function(.select){ unexpected_names <- glue::glue_collapse(named_fields[name_check], last = " and ") c("When type = 'species', `select()` only accepts 'counts', 'synonyms' or 'lists' as valid fields.", i = glue("Unexpected fields: {unexpected_names}")) |> - cli::cli_warn(bullets) + cli::cli_warn() } # parse 'correct' names if(any(named_fields == "counts")){result$count <- "true"} diff --git a/R/as_query-species_count.R b/R/as_query-species_count.R index 6ee26f49..c49123db 100644 --- a/R/as_query-species_count.R +++ b/R/as_query-species_count.R @@ -2,7 +2,7 @@ #' @keywords Internal #' @param .query an object of class `data_request` #' @noRd -collapse_species_count <- function(.query){ +as_query_species_count <- function(.query){ if(is_gbif()){ cli::cli_abort("`count()` is not supported for GBIF with type = 'species'") }else{ @@ -17,7 +17,7 @@ collapse_species_count <- function(.query){ #' collapse for counts on LAs #' @keywords Internal #' @noRd -collapse_species_count_atlas <- function(identify = NULL, +as_query_species_count_atlas <- function(identify = NULL, filter = NULL, geolocate = NULL, data_profile = NULL, diff --git a/R/as_query.R b/R/as_query.R index 5402f90a..a3f44188 100644 --- a/R/as_query.R +++ b/R/as_query.R @@ -20,7 +20,13 @@ #' to generate irresolvable API calls, because e.g. taxonomic queries are not #' parsed before the URL is built. It should therefore be used with care. #' @name as_query.data_request +#' @param x An object to convert to a `query`. Supported classes are the same +#' as those produced by [galah_call()], namely `data_request`, +#' `metadata_request` or `files_request`. +#' @param ... Other arguments, currently ignored #' @order 1 +#' @return An object of class `query`, which is a list-like object containing at +#' least the slots `type` and `url`. #' @export as_query <- function(x, ...){ UseMethod("as_query") @@ -78,8 +84,21 @@ as_query.metadata_request <- function(x, ...){ } #' @rdname as_query.data_request +#' @param thumbnail Logical: should thumbnail-size images be returned? Defaults +#' to `FALSE`, indicating full-size images are required. #' @order 4 #' @export -as_query.files_request <- function(x, ...){ - browser() +as_query.files_request <- function(x, + thumbnail, + ...){ + # NOTE: switch is technically superfluous right now, but could be useful + # for future file types + + # This code is identical to `collapse.files_request()` + result <- list(switch(x$type, + "media" = as_query_media_files(x, + thumbnail = thumbnail) + )) + class(result) <- "query_set" + result } \ No newline at end of file diff --git a/R/coalesce.R b/R/coalesce.R index 9a861c97..0c9a1162 100644 --- a/R/coalesce.R +++ b/R/coalesce.R @@ -137,9 +137,9 @@ build_query_set_data <- function(x, mint_doi, ...){ # handle `identify()` if(!is.null(x$identify) & x$type != "occurrences-doi"){ - result[[(length(result) + 1)]] <- list(type = "taxa", - identify = x$identify) |> - as_query() # this syntax seems messy + result[[(length(result) + 1)]] <- request_metadata() |> + identify(x$identify) |> + as_query() } # handle `apply_profile()` if(!is.null(x$data_profile)){ diff --git a/R/collapse.R b/R/collapse.R index d05c05c1..a67e597e 100644 --- a/R/collapse.R +++ b/R/collapse.R @@ -9,8 +9,10 @@ #' argument. #' @name collapse.data_request #' @order 1 -#' @param x An object of class `data_request`, `metadata_request` or -#' `files_request` +#' @param x An object to run `collapse()` on. Classes supported by `galah` +#' include `data_request`, `metadata_request` and `files_request` for building +#' queries; and `query` or `query_set` once constructed (via [as_query()] or +#' [coalesce()]). #' @param ... Arguments passed on to other methods #' @param mint_doi Logical: should a DOI be minted for this download? Only #' applies to `type = "occurrences"` when atlas chosen is "ALA". diff --git a/R/collapse_checks.R b/R/collapse_checks.R index 1551217e..9974b2f0 100644 --- a/R/collapse_checks.R +++ b/R/collapse_checks.R @@ -5,17 +5,17 @@ collapse_build_checks <- function(.query){ # get basic description of `query_set` object n <- length(.query) - names_vec <- unlist(lapply(.query, function(a){a$type})) + names_vec <- unlist(purrr::map(.query, function(a){a$type})) # look for any `data` data_lookup <- grepl("^data", names_vec) if(any(data_lookup)){ data_names <- names_vec[data_lookup] # parse any `metadata` - metadata_results <- collapse_parse_metadata(names_vec, .query) + metadata_results <- collapse_run_metadata(names_vec, .query) # parse `data`, including supplied metadata # this assumes only one `data` field is available per `query_set` .query[[which(data_lookup)]] |> - add_metadata(metadata_results) + collapse_add_metadata(metadata_results) }else if(any(names_vec %in% c("metadata/fields-unnest", "metadata/profiles-unnest", "metadata/taxa-unnest"))){ @@ -23,7 +23,7 @@ collapse_build_checks <- function(.query){ # metadata/fields-unnest calls check_fields(), requiring fields and assertions # metadata/profiles-unnest calls profile_short_name(), which requires profiles if(length(.query) > 1){ - metadata_results <- collapse_parse_metadata(names_vec, .query) + metadata_results <- collapse_run_metadata(names_vec, .query) .query[[2]] |> collapse_add_metadata(metadata_results) }else{ @@ -83,7 +83,7 @@ collapse_run_checks <- function(.query){ #' Internal function to collapse metadata #' @noRd #' @keywords Internal -collapse_metadata <- function(names_vec, .query){ +collapse_run_metadata <- function(names_vec, .query){ metadata_lookup <- grepl("^metadata", names_vec) & !grepl("-unnest$", names_vec) # unnest functions only parse in collect() if(any(metadata_lookup)){ diff --git a/R/galah_filter.R b/R/galah_filter.R index 54f5f0fd..3e66744e 100644 --- a/R/galah_filter.R +++ b/R/galah_filter.R @@ -104,17 +104,9 @@ #' count() |> #' collect() #' } -#' @importFrom rlang caller_env -#' @importFrom rlang enquos -#' @importFrom rlang eval_tidy -#' @importFrom rlang get_env -#' @importFrom rlang new_quosure -#' @importFrom rlang parse_expr -#' @importFrom rlang quo_get_expr -#' @importFrom rlang quo_squash #' @export filter.data_request <- function(.data, ...){ - dots <- enquos(..., .ignore_empty = "all") + dots <- rlang::enquos(..., .ignore_empty = "all") check_named_input(dots) if(is_gbif()){ filters <- parse_quosures_data_gbif(dots) # `handle_quosures_GBIF.R` @@ -131,7 +123,7 @@ filter.data_request <- function(.data, ...){ #' @order 2 #' @export filter.metadata_request <- function(.data, ...){ - dots <- enquos(..., .ignore_empty = "all") + dots <- rlang::enquos(..., .ignore_empty = "all") check_named_input(dots) parse_quosures_metadata(.data, dots) } @@ -156,12 +148,12 @@ parse_quosures_metadata <- function(request, dots){ supplied_type <- dots_parsed$variable[1] if(!(supplied_type %in% c("taxa", "media")) & !grepl("s$", supplied_type)){ - filter_type <- paste0(supplied_type, "s") + filter_type <- glue::glue("{supplied_type}s") }else{ filter_type <- supplied_type } if(grepl("-unnest$", initial_type)){ - request$type <- paste0(filter_type, "-unnest") + request$type <- glue::glue("{filter_type}-unnest") }else{ request$type <- filter_type } @@ -173,7 +165,7 @@ parse_quosures_metadata <- function(request, dots){ #' @importFrom rlang .data #' @export filter.files_request <- function(.data, ...){ - dots <- enquos(..., .ignore_empty = "all") + dots <- rlang::enquos(..., .ignore_empty = "all") check_named_input(dots) dots_parsed <- parse_quosures_files(dots) check_files_filter(dots_parsed) @@ -186,7 +178,7 @@ filter.files_request <- function(.data, ...){ #' @order 4 #' @export galah_filter <- function(..., profile = NULL){ - dots <- enquos(..., .ignore_empty = "all") |> + dots <- rlang::enquos(..., .ignore_empty = "all") |> detect_request_object() check_named_input(dots) switch(class(dots[[1]])[1], diff --git a/R/galah_group_by.R b/R/galah_group_by.R index eed65e45..6d7168cc 100644 --- a/R/galah_group_by.R +++ b/R/galah_group_by.R @@ -62,20 +62,21 @@ galah_group_by <- function(...){ parse_group_by <- function(dot_names){ if(length(dot_names) > 0){ if(length(dot_names) > 3){ - bullets <- c( + c( "Too many fields supplied.", - i = "`group_by.data_request` accepts a maximum of 3 fields." - ) - abort(bullets, call = caller_env()) + i = "`group_by.data_request` accepts a maximum of 3 fields.") |> + cli::cli_abort(call = caller_env()) } if(length(dot_names) > 0){ - df <- tibble(name = dot_names) - df$type <- ifelse(str_detect(df$name, "[[:lower:]]"), "field", "assertions") + df <- tibble::tibble(name = dot_names) + df$type <- ifelse(stringr::str_detect(df$name, "[[:lower:]]"), + "field", + "assertions") }else{ - df <- tibble(name = "name", type = "type", .rows = 0) + df <- tibble::tibble(name = "name", type = "type", .rows = 0) } }else{ - df <- tibble(name = "name", type = "type", .rows = 0) + df <- tibble::tibble(name = "name", type = "type", .rows = 0) } return(df) diff --git a/R/handle_quosures.R b/R/handle_quosures.R index a75f4e79..13e722a1 100644 --- a/R/handle_quosures.R +++ b/R/handle_quosures.R @@ -6,14 +6,12 @@ # These are called 'first' by `galah_` functions #' parse quosures for objects of class `data_request` -#' @importFrom dplyr bind_rows -#' @importFrom tibble tibble #' @noRd #' @keywords internal parse_quosures_data <- function(dots){ if(length(dots) > 0){ - result <- lapply(dots, switch_expr_type) |> - bind_rows() |> + result <- purrr::map(dots, switch_expr_type) |> + dplyr::bind_rows() |> clean_assertions() |> clean_logical_statements() result$query <- as.character(result$query) @@ -21,7 +19,7 @@ parse_quosures_data <- function(dots){ result <- NULL } if(is.null(result)){ - result <- tibble( + result <- tibble::tibble( variable = character(), logical = character(), value = character(), @@ -34,19 +32,16 @@ parse_quosures_data <- function(dots){ #' #' Major difference here is there is no need for parsing; simply return #' stuff that is a named object -#' @importFrom rlang abort -#' @importFrom rlang eval_tidy -#' @importFrom rlang quo_get_expr #' @noRd #' @keywords internal parse_quosures_basic <- function(dots){ if(length(dots) > 0){ - parsed_dots <- lapply(dots, function(a){ + parsed_dots <- purrr::map(dots, \(a){ switch(expr_type(a), "symbol" = {parse_symbol(a)}, - "call" = {eval_tidy(a)}, - "literal" = {quo_get_expr(a)}, - abort("Quosure type not recognised.")) + "call" = {rlang::eval_tidy(a)}, + "literal" = {rlang::quo_get_expr(a)}, + cli::cli_abort("Quosure type not recognised.")) }) unlist(parsed_dots) }else{ @@ -56,42 +51,37 @@ parse_quosures_basic <- function(dots){ #' parse quosures, but for `filter.files_request()` where we expect large amounts #' of data to be supplied -#' @importFrom rlang as_label -#' @importFrom rlang as_string -#' @importFrom rlang is_quosure -#' @importFrom rlang f_lhs -#' @importFrom rlang f_rhs -#' @importFrom rlang quo_get_env -#' @importFrom rlang quo_get_expr -#' @importFrom tibble tibble #' @noRd #' @keywords internal parse_quosures_files <- function(dots){ if(length(dots) > 0){ check_named_input(dots) - dot_expr <- quo_get_expr(dots[[1]]) # i.e. only first entry is available + dot_expr <- rlang::quo_get_expr(dots[[1]]) # i.e. only first entry is available # get formula lhs - lhs <- f_lhs(dot_expr) - if(is_quosure(lhs)){ - lhs <- quo_get_expr(lhs) + lhs <- rlang::f_lhs(dot_expr) + if(rlang::is_quosure(lhs)){ + lhs <- rlang::quo_get_expr(lhs) } - lhs <- as_string(lhs) |> dequote() + lhs <- rlang::as_string(lhs) |> + dequote() # get rhs - x <- new_quosure(f_rhs(dot_expr), env = quo_get_env(dots[[1]])) + x <- rlang::f_rhs(dot_expr) |> + rlang::new_quosure(env = rlang::quo_get_env(dots[[1]])) rhs <- switch(expr_type(x), - "call" = {eval_tidy(x)}, - "symbol" = {if(exists(quo_get_expr(x), - where = quo_get_env(x))){ - eval_tidy(x) + "call" = {rlang::eval_tidy(x)}, + "symbol" = {if(exists(rlang::quo_get_expr(x), + where = rlang::quo_get_env(x))){ + rlang::eval_tidy(x) }else{ - as_label(x) + rlang::as_label(x) }}, - "literal" = {quo_get_expr(x)}, + "literal" = {rlang::quo_get_expr(x)}, abort("Quosure type not recognised.")) if(inherits(rhs, "data.frame")){ - list(variable = dequote(lhs), data = rhs) + list(variable = dequote(lhs), + data = rhs) }else{ - tibble( + tibble::tibble( variable = dequote(lhs), logical = "==", value = rhs) @@ -115,12 +105,12 @@ clean_assertions <- function(df){ check_1 <- concatenate_assertions(df[assertions_check, ], logical = "!=") check_2 <- concatenate_assertions(df[assertions_check, ], logical = "==") if(all(is.null(c(check_1, check_2)))){ - bind_rows( + dplyr::bind_rows( df[assertions_check, ], df[!assertions_check, ] ) }else{ - bind_rows( + dplyr::bind_rows( check_1, check_2, df[!assertions_check, ] @@ -178,33 +168,27 @@ dequote <- function(x){ #' Switch functions for quosures #' @param x A (single) quosure -#' @importFrom rlang abort -#' @importFrom rlang quo_get_expr #' @noRd #' @keywords internal switch_expr_type <- function(x, ...){ switch(expr_type(x), "symbol" = {parse_symbol(x)}, "call" = {parse_call(x, ...)}, - "literal" = {quo_get_expr(x)}, - abort("Quosure type not recognised.") + "literal" = {rlang::quo_get_expr(x)}, + cli::cli_abort("Quosure type not recognised.") ) } #' Get type from quosures #' @param x A (single) quosure -#' @importFrom rlang quo_is_symbol -#' @importFrom rlang quo_is_call -#' @importFrom rlang quo_get_expr -#' @importFrom rlang is_syntactic_literal #' @noRd #' @keywords internal expr_type <- function(x){ - if(quo_is_symbol(x)){ + if(rlang::quo_is_symbol(x)){ "symbol" - }else if(quo_is_call(x)){ + }else if(rlang::quo_is_call(x)){ "call" - }else if(is_syntactic_literal(quo_get_expr(x))){ + }else if(rlang::quo_get_expr(x) |> rlang::is_syntactic_literal()){ "literal" }else{ typeof(x) @@ -213,22 +197,18 @@ expr_type <- function(x){ #' Check whether symbols exist before they are parsed #' @param x A (single) quosure -#' @importFrom rlang quo_get_expr -#' @importFrom rlang quo_get_env -#' @importFrom rlang eval_tidy -#' @importFrom rlang as_label #' @noRd #' @keywords internal parse_symbol <- function(x){ - if(exists(quo_get_expr(x), where = quo_get_env(x))){ - result <- eval_tidy(x) + if(exists(rlang::quo_get_expr(x), where = rlang::quo_get_env(x))){ + result <- rlang::eval_tidy(x) if(inherits(result, "function")){ # special case for functions like 'data' - as_label(x) # which exist in Global + rlang::as_label(x) # which exist in Global }else{ result } }else{ - as_label(x) + rlang::as_label(x) } } @@ -243,20 +223,14 @@ parse_symbol <- function(x){ #' galah behavior. So `x <- 1; y <- 10; filter(y == x)` will parse to #' `list(y = 1)` not `list(10 = 1)`. Advanced R suggests using `:=` for these #' cases, which could be added to `switch` below -#' @importFrom rlang abort -#' @importFrom rlang as_quosure -#' @importFrom rlang as_string -#' @importFrom rlang eval_tidy -#' @importFrom rlang quo_get_expr -#' @importFrom rlang quo_get_env #' @noRd #' @keywords internal parse_call <- function(x, ...){ - y <- quo_get_expr(x) - env_tr <- quo_get_env(x) + y <- rlang::quo_get_expr(x) + env_tr <- rlang::quo_get_env(x) switch_lookup <- y[[1]] |> deparse() |> - as_string() |> + rlang::as_string() |> function_type() switch(switch_lookup, # i.e. switch depending on what function is called "relational_operator" = parse_relational(x, ...), @@ -266,7 +240,7 @@ parse_call <- function(x, ...){ "is.na" = parse_is_na(x, ...), "between" = parse_between(x, ...), "%in%" = parse_in(x, ...), - eval_tidy(x) # if unknown, parse + rlang::eval_tidy(x) # if unknown, parse # {filter_error()} # if unknown, error ) } @@ -296,33 +270,21 @@ function_type <- function(x){ # assumes x is a string #' Take standard filter-style queries and parse to `galah_filter()`-style `tibble` #' Called by `parse_call` -#' @importFrom dplyr all_of -#' @importFrom dplyr rename -#' @importFrom dplyr select -#' @importFrom rlang as_label -#' @importFrom rlang as_quosure -#' @importFrom rlang as_string -#' @importFrom rlang f_lhs -#' @importFrom rlang is_empty -#' @importFrom rlang is_bare_environment -#' @importFrom rlang parse_expr -#' @importFrom rlang f_rhs -#' @importFrom tibble tibble #' @noRd #' @keywords internal parse_relational <- function(x, ...){ - expr <- quo_get_expr(x) + expr <- rlang::quo_get_expr(x) if(length(expr) != 3L){filter_error()} - lhs <- f_lhs(expr) |> - as_label() |> + lhs <- rlang::f_lhs(expr) |> + rlang::as_label() |> dequote() - rhs <- as_quosure(f_rhs(expr), - env = quo_get_env(x)) |> + rhs <- rlang::as_quosure(rlang::f_rhs(expr), + env = rlang::quo_get_env(x)) |> switch_expr_type() |> as.character() - result <- tibble( + result <- tibble::tibble( variable = lhs, logical = as.character(expr[[1]]), # should probably be `relational` value = rhs) @@ -346,33 +308,29 @@ parse_relational <- function(x, ...){ } #' Handle & and | statements -#' @importFrom rlang as_quosure -#' @importFrom rlang as_string -#' @importFrom rlang quo_get_env #' @noRd #' @keywords internal parse_logical <- function(x, ...){ - provided_string <- quo_get_expr(x)[[1]] |> as_string() + provided_string <- rlang::quo_get_expr(x)[[1]] |> + rlang::as_string() if(grepl("\\|{1,2}", provided_string)){ logical_string <- " OR " }else{ logical_string <- " AND " } - linked_statements <- lapply(quo_get_expr(x)[-1], - function(a){ - switch_expr_type( - as_quosure(a, env = quo_get_env(x)), ...) - }) |> - bind_rows() + linked_statements <- purrr::map(rlang::quo_get_expr(x)[-1], + \(a){ + a |> + rlang::as_quosure(env = rlang::quo_get_env(x)) |> + switch_expr_type(...) + }) |> + dplyr::bind_rows() concatenate_logical_tibbles(linked_statements, provided_string = provided_string, logical_string = logical_string) } #' Internal function to handle concatenation of logicals -#' @importFrom glue glue -#' @importFrom glue glue_collapse -#' @importFrom tibble tibble #' @noRd #' @keywords Internal concatenate_logical_tibbles <- function(df, @@ -381,65 +339,60 @@ concatenate_logical_tibbles <- function(df, if(all(df$variable == "assertions")){ query_text <- df$query |> gsub("^-", "", x = _) |> - glue_collapse(sep = logical_string) + glue::glue_collapse(sep = logical_string) if(all(df$logical == "!=")){ - query_text <- glue("-({query_text})") + query_text <- glue::glue("-({query_text})") } }else{ query_text <- df$query |> - glue_collapse(sep = logical_string) + glue::glue_collapse(sep = logical_string) } tibble( - variable = glue_collapse(df$variable, sep = provided_string), - logical = glue_collapse(df$logical, sep = provided_string), - value = glue_collapse(df$value, sep = provided_string), - query = as.character(glue("{query_text}"))) + variable = glue::glue_collapse(df$variable, sep = provided_string), + logical = glue::glue_collapse(df$logical, sep = provided_string), + value = glue::glue_collapse(df$value, sep = provided_string), + query = as.character(glue::glue("{query_text}"))) } #' Parse `call`s that contain brackets #' Where this happens, they are always length-2, with "(" as the first entry. -#' @importFrom rlang as_quosure -#' @importFrom rlang quo_get_expr -#' @importFrom rlang quo_get_env #' @noRd #' @keywords internal parse_brackets <- function(x, ...){ - if(length(quo_get_expr(x)) != 2L){filter_error()} - switch_expr_type(as_quosure(quo_get_expr(x)[[-1]], - env = quo_get_env(x)), - ...) # pass this down the chain + if(length(rlang::quo_get_expr(x)) != 2L){ + filter_error() + } + rlang::quo_get_expr(x)[[-1]] |> + rlang::as_quosure(env = rlang::quo_get_env(x)) |> + switch_expr_type(...) # pass this down the chain } #' Parse `call`s that contain exclamations #' Where this happens, they are always length-2, with "(" as the first entry. -#' @importFrom rlang as_quosure -#' @importFrom rlang quo_get_expr -#' @importFrom rlang quo_get_env #' @noRd #' @keywords internal parse_exclamation <- function(x){ # extract call after `!`, preserves that `!` = TRUE - switch_expr_type(as_quosure(quo_get_expr(x)[[-1]], - env = quo_get_env(x)), - excl = TRUE) # pass this down the chain + rlang::quo_get_expr(x)[[-1]] |> + rlang::as_quosure(env = rlang::quo_get_env(x)) |> + switch_expr_type(excl = TRUE) # pass this down the chain } #' Parse `call`s that contain `is.na()` #' Where this happens, they are always length-2, with "(" as the first entry. -#' @importFrom rlang as_quosure -#' @importFrom rlang is_empty -#' @importFrom rlang quo_get_expr -#' @importFrom rlang quo_get_env #' @noRd #' @keywords internal parse_is_na <- function(x, ...){ - if(length(quo_get_expr(x)) != 2L){filter_error()} + if(length(rlang::quo_get_expr(x)) != 2L){ + filter_error() + } dots <- list(...) - logical <- ifelse(is_empty(dots), "==", "!=") + logical <- ifelse(rlang::is_empty(dots), "==", "!=") # for LA cases - result <- tibble( - variable = switch_expr_type(as_quosure(quo_get_expr(x)[[2]], - env = quo_get_env(x))), + result <- tibble::tibble( + variable = rlang::quo_get_expr(x)[[2]] |> + rlang::as_quosure(env = rlang::quo_get_env(x)) |> + switch_expr_type(), logical = logical, value = as.character("")) result$query <- parse_solr(result) @@ -448,85 +401,81 @@ parse_is_na <- function(x, ...){ #' Parse `call`s that contain `dplyr::between()` #' Where this happens, they are always length-4, with "between" as the first entry. -#' @importFrom rlang as_quosure #' @noRd #' @keywords internal parse_between <- function(x, excl){ - if(length(quo_get_expr(x)) < 4L){filter_error()} + if(length(rlang::quo_get_expr(x)) < 4L){ + filter_error() + } # for LA cases if(isTRUE(excl)) { logical <- c(as.character(">"), c(as.character("<"))) } else{ logical <- c(as.character("<"), c(as.character(">"))) } - result <- tibble( - variable = c(rep(as_label(quo_get_expr(x)[[2]]))), + result <- tibble::tibble( + variable = c(rep(rlang::as_label(rlang::quo_get_expr(x)[[2]]))), logical = logical, value = as.character( - c(switch_expr_type(as_quosure(quo_get_expr(x)[[3]], - env = quo_get_env(x))), - switch_expr_type(as_quosure(quo_get_expr(x)[[4]], - env = quo_get_env(x)))))) - result$query <- c(parse_solr(result[1,]), parse_solr(result[2,])) + c(switch_expr_type(rlang::as_quosure(rlang::quo_get_expr(x)[[3]], + env = rlang::quo_get_env(x))), + switch_expr_type(rlang::as_quosure(rlang::quo_get_expr(x)[[4]], + env = rlang::quo_get_env(x)))))) + result$query <- c(parse_solr(result[1,]), + parse_solr(result[2,])) return(result) } #' Parse `call`s that contain `%in%` #' #' Where this happens, they are always length-3, with "%in%" as the first entry. -#' @importFrom glue glue -#' @importFrom glue glue_collapse -#' @importFrom rlang as_quosure -#' @importFrom rlang enquo -#' @importFrom rlang parse_expr #' @noRd #' @keywords internal parse_in <- function(x, excl){ # convert to logical format using OR statements - variable <- as_label(quo_get_expr(x)[[2]]) + variable <- rlang::quo_get_expr(x)[[2]] |> + rlang::as_label() logical <- ifelse(missing(excl), "==", "!=") - value <- switch_expr_type(as_quosure(quo_get_expr(x)[[3]], - env = quo_get_env(x))) + value <- rlang::quo_get_expr(x)[[3]] |> + rlang::as_quosure(env = rlang::quo_get_env(x)) |> + switch_expr_type() # handle apostrophes (') - if(any(str_detect(value, "\\'"))) { + if(any(stringr::str_detect(value, "\\'"))) { value <- gsub("'", "\\\\'", value) } # convert to formula - in_as_or_statements <- rlang::parse_expr( - glue::glue_collapse( - glue("{variable} {logical} '{value}'"), - sep = " | " - )) + in_as_or_statements <- glue::glue_collapse( + glue::glue("{variable} {logical} '{value}'"), + sep = " | " + ) |> + rlang::parse_expr() # convert to quosure and pass to `parse_logical()` - as_quosure(in_as_or_statements, quo_get_env(x)) |> + rlang::as_quosure(in_as_or_statements, + rlang::quo_get_env(x)) |> parse_logical() } #' Parse `call`s that contain `c()` #' Where this happens, they are always length-2, with "c()" as the first entry. -#' @importFrom glue glue -#' @importFrom glue glue_collapse -#' @importFrom rlang as_quosure -#' @importFrom rlang enquo -#' @importFrom rlang parse_expr -#' @importFrom rlang quo_get_env -#' @importFrom rlang quo_get_expr #' @noRd #' @keywords internal parse_c <- function(x, excl){ - if(length(quo_get_expr(x)) < 2L){filter_error()} + if(length(quo_get_expr(x)) < 2L){ + filter_error() + } # convert to logical format using OR statements - variable <- quo_get_expr(x)[[1]] |> - as_label() + variable <- rlang::quo_get_expr(x)[[1]] |> + rlang::as_label() logical <- ifelse(missing(excl), "==", "!=") - value <- as_quosure(quo_get_expr(x)[[3]], - env = quo_get_env(x)) |> + value <- rlang::quo_get_expr(x)[[3]] |> + rlang::as_quosure(env = rlang::quo_get_env(x)) |> switch_expr_type() - in_as_or_statements <- glue_collapse( - glue("{variable} {logical} '{value}'"), + in_as_or_statements <- glue::glue_collapse( + glue::glue("{variable} {logical} '{value}'"), sep = " | ") |> parse_expr() - parse_logical(enquo(in_as_or_statements), quo_get_env(x)) # pass this to parse_logical + parse_logical(rlang::enquo(in_as_or_statements), + rlang::quo_get_env(x)) # pass this to parse_logical } @@ -538,7 +487,7 @@ parse_c <- function(x, excl){ #' @keywords internal parse_solr <- function(df){ if(nrow(df) > 1){ - lapply( + purrr::map( split(df, seq_len(nrow(df))), switch_solr) |> unlist() @@ -555,47 +504,47 @@ switch_solr <- function(df){ switch(df$logical, "==" = query_term(df$variable, df$value, TRUE), "!=" = query_term(df$variable, df$value, FALSE), - ">=" = glue_data(df, "{variable}:[{value} TO *]"), + ">=" = glue::glue_data(df, "{variable}:[{value} TO *]"), ">" = { lowest_value <- query_term(df$variable, df$value, TRUE) - glue_data(df, "{variable}:[{value} TO *] AND -{lowest_value}")}, - "<=" = glue_data(df, "{variable}:[* TO {value}]"), + glue::glue_data(df, "{variable}:[{value} TO *] AND -{lowest_value}")}, + "<=" = glue::glue_data(df, "{variable}:[* TO {value}]"), "<" = { highest_value <- query_term(df$variable, df$value, TRUE) - glue_data(df, "{variable}:[* TO {value}] AND -{highest_value}")} + glue::glue_data(df, "{variable}:[* TO {value}] AND -{highest_value}")} ) } #' Generic error for unknown cases -#' @importFrom rlang abort #' @noRd #' @keywords internal -filter_error <- function(){abort("Invalid argument passed to `filter()`.")} +filter_error <- function(){ + cli::cli_abort("Invalid argument passed to `filter()`.", + call = caller_env()) +} #' Subfunction called by `parse_solr()` -#' @importFrom glue glue -#' @importFrom rlang expr_text #' @noRd #' @keywords internal query_term <- function(name, value, include) { # add quotes around value - value <- lapply(value, expr_text) # format query value as solr-readable text + value <- purrr::map(value, rlang::expr_text) # format query value as solr-readable text if(value %in% c("", "\"\"")) { if(include){ - value_str <- glue("(*:* AND -{name}:*)") # queries with "==" + value_str <- glue::glue("(*:* AND -{name}:*)") # queries with "==" }else{ - value_str <- glue("({name}:*)") # queries with "!=" + value_str <- glue::glue("({name}:*)") # queries with "!=" } } else { # assertions do not require brackets if(name == "assertions"){ - value_str <- glue("{name}:{value}") + value_str <- glue::glue("{name}:{value}") }else{ - value_str <- glue("({name}:{value})") + value_str <- glue::glue("({name}:{value})") } # negations have a leading `-` if(!include){ - value_str <- glue("-{value_str}") + value_str <- glue::glue("-{value_str}") } } value_str diff --git a/R/handle_quosures_GBIF.R b/R/handle_quosures_GBIF.R index 804ed37d..7d61ceef 100644 --- a/R/handle_quosures_GBIF.R +++ b/R/handle_quosures_GBIF.R @@ -82,7 +82,9 @@ parse_relational_pred <- function(x){ # get expression expr <- rlang::quo_get_expr(x) - if(length(expr) != 3L){filter_error()} + if(length(expr) != 3L){ + filter_error() + } # parse out separate parts operator <- as.character(expr[[1]]) @@ -268,7 +270,7 @@ parse_in_pred <- function(x){ gbif_upper_case() # galah function rhs <- rlang::as_quosure(rlang::quo_get_expr(x)[[3]], - env = quo_get_env(x)) |> + env = rlang::quo_get_env(x)) |> switch_expr_type() ## NOTE: Not clear that this is correct. ## Should parse to: `"values": ["cat1", "cat2", "cat3"]` diff --git a/man/as_query.data_request.Rd b/man/as_query.data_request.Rd index 100cb4a7..f1f128c1 100644 --- a/man/as_query.data_request.Rd +++ b/man/as_query.data_request.Rd @@ -13,7 +13,21 @@ as_query(x, ...) \method{as_query}{metadata_request}(x, ...) -\method{as_query}{files_request}(x, ...) +\method{as_query}{files_request}(x, thumbnail, ...) +} +\arguments{ +\item{x}{An object to convert to a \code{query}. Supported classes are the same +as those produced by \code{\link[=galah_call]{galah_call()}}, namely \code{data_request}, +\code{metadata_request} or \code{files_request}.} + +\item{...}{Other arguments, currently ignored} + +\item{thumbnail}{Logical: should thumbnail-size images be returned? Defaults +to \code{FALSE}, indicating full-size images are required.} +} +\value{ +An object of class \code{query}, which is a list-like object containing at +least the slots \code{type} and \code{url}. } \description{ Functionally similar to \code{\link[=collapse]{collapse()}}, but without passing through diff --git a/man/collapse.data_request.Rd b/man/collapse.data_request.Rd index 478657ce..2cc0457d 100644 --- a/man/collapse.data_request.Rd +++ b/man/collapse.data_request.Rd @@ -19,8 +19,10 @@ \method{collapse}{query}(x, ...) } \arguments{ -\item{x}{An object of class \code{data_request}, \code{metadata_request} or -\code{files_request}} +\item{x}{An object to run \code{collapse()} on. Classes supported by \code{galah} +include \code{data_request}, \code{metadata_request} and \code{files_request} for building +queries; and \code{query} or \code{query_set} once constructed (via \code{\link[=as_query]{as_query()}} or +\code{\link[=coalesce]{coalesce()}}).} \item{...}{Arguments passed on to other methods} diff --git a/tests/testthat/test-atlas_occurrences.R b/tests/testthat/test-atlas_occurrences.R index 4f38d70a..d2077254 100644 --- a/tests/testthat/test-atlas_occurrences.R +++ b/tests/testthat/test-atlas_occurrences.R @@ -209,29 +209,6 @@ test_that("`atlas_occurrences()` places DOI in `attr()` correctly", { rm(cache_dir) }) -test_that("group_by works on occurrences", { - skip_if_offline(); skip_on_ci() - # compare group_by with atlas_species - x <- galah_call() |> - filter(year == 2024, - genus == "Crinia") |> - group_by(speciesID) |> - collect() - y <- galah_call() |> - filter(year == 2024, - genus == "Crinia") |> - atlas_species() - expect_equal(x, y) - # try with a different variable - z <- galah_call() |> - filter(year == 2024, - genus == "Crinia") |> - group_by(genusID) |> - collect() - expect_true(inherits(z, c("tbl_df", "tbl", "data.frame"))) - expect_equal(colnames(z)[1], "taxon_concept_id") -}) - test_that("atlas_occurrences() doesn't return secret information", { skip_if_offline(); skip_on_ci() RUN <- FALSE diff --git a/tests/testthat/test-atlas_species.R b/tests/testthat/test-atlas_species.R index d964630a..0e6548c1 100644 --- a/tests/testthat/test-atlas_species.R +++ b/tests/testthat/test-atlas_species.R @@ -19,14 +19,17 @@ test_that("atlas_species returns correct results when piped", { galah_identify("perameles") |> galah_filter(year > 2000) |> atlas_species() - expected_species <- c("Perameles nasuta", "Perameles gunnii", - "Perameles pallescens", "Perameles bougainville") + expected_species <- c("Perameles nasuta", + "Perameles gunnii", + "Perameles fasciata", + "Perameles pallescens", + "Perameles bougainville") expected_cols <- c("taxon_concept_id", "species_name", "scientific_name_authorship", "taxon_rank", "kingdom", "phylum", "class", "order", "family", "genus", "vernacular_name") expect_setequal(names(species), expected_cols) - expect_equal(species$species_name[1:4], expected_species) + expect_equal(species$species_name[1:5], expected_species) expect_gt(nrow(species), 1) expect_s3_class(species, c("tbl_df", "tbl", "data.frame")) }) @@ -112,3 +115,26 @@ test_that("atlas_species reformats column names when empty tibble is returned", expect_equal(nrow(species), 0) expect_s3_class(species, c("tbl_df", "tbl", "data.frame")) }) + +test_that("group_by works on occurrences", { + skip_if_offline(); skip_on_ci() + # compare group_by with atlas_species + x <- galah_call() |> + filter(year == 2024, + genus == "Crinia") |> + group_by(speciesID) |> + collect() + y <- galah_call() |> + filter(year == 2024, + genus == "Crinia") |> + atlas_species() + expect_equal(x, y) + # try with a different variable + z <- galah_call() |> + filter(year == 2024, + genus == "Crinia") |> + group_by(genusID) |> + collect() + expect_true(inherits(z, c("tbl_df", "tbl", "data.frame"))) + expect_equal(colnames(z)[1], "taxon_concept_id") +}) From 71c56034cbdd286586b63af07ae87b64a10501cf Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Wed, 6 Aug 2025 15:53:53 +1000 Subject: [PATCH 13/94] Switch to predicates search for GBIF counts (#272) work in progress, some use cases and tests still fail --- R/as_query-occurrences.R | 23 ++----- R/as_query-occurrences_count.R | 24 ++++++-- R/build_predicates.R | 76 +++++++----------------- R/check.R | 40 +++++++------ R/collapse.R | 1 + R/collapse_checks.R | 11 ---- R/collapse_occurrences_count.R | 54 +++++++++++++++-- R/collect_occurrences_count.R | 29 ++++----- R/handle_quosures_GBIF.R | 53 +++++++++-------- tests/testthat/test-international-GBIF.R | 42 +++++++++++-- 10 files changed, 196 insertions(+), 157 deletions(-) diff --git a/R/as_query-occurrences.R b/R/as_query-occurrences.R index 511f261e..779d329c 100644 --- a/R/as_query-occurrences.R +++ b/R/as_query-occurrences.R @@ -51,22 +51,6 @@ as_query_occurrences_uk <- function(.query){ #' @noRd #' @keywords Internal as_query_occurrences_gbif <- function(.query, format = "SIMPLE_CSV"){ - - body <- build_predicates( - .query$filter, - .query$identify, - .query$geolocate, - format = "SIMPLE_CSV") - - # deal with user-specified taxonomic names - if(!is.null(identify)){ - .query$filter <- rbind( - .query$filter, - tibble::tibble(variable = "taxonKey", - logical = "==", - value = "`TAXON_PLACEHOLDER`", - query = "")) - } # get user string username <- potions::pour("user", "username", .pkg = "galah") password <- potions::pour("user", "password", .pkg = "galah") @@ -83,9 +67,10 @@ as_query_occurrences_gbif <- function(.query, format = "SIMPLE_CSV"){ options = list( httpauth = 1, userpwd = user_string), - body = build_predicates(.query$filter, - .query$geolocate, - format = format)) + body = list(filter = .query$filter, + identify = .query$identify, + geolocate = .query$geolocate, + format = "SIMPLE_CSV")) class(result) <- "query" return(result) } diff --git a/R/as_query-occurrences_count.R b/R/as_query-occurrences_count.R index 0ddb05d4..a8105c47 100644 --- a/R/as_query-occurrences_count.R +++ b/R/as_query-occurrences_count.R @@ -82,8 +82,9 @@ as_query_occurrences_count_gbif <- function(identify = NULL, slice = NULL ){ # compile supplied arguments into a list - # honestly this is a little messy, but hard to call build_predicates() - # at this stage, as taxonomic info hasn't yet been parsed + # honestly this is a little messy, but the alternative is to call + # [build_predicates()], which is messier as taxonomic info hasn't yet been + # parsed. Instead we call [build_predicates()] during [collapse_query()]. predicates_info <- list(identify = identify, filter = filter, geolocate = geolocate, @@ -93,20 +94,34 @@ as_query_occurrences_count_gbif <- function(identify = NULL, slice), limit = 0) + # get user string + username <- potions::pour("user", "username", .pkg = "galah") + password <- potions::pour("user", "password", .pkg = "galah") + user_string <- glue::glue("{username}:{password}") + # get relevant information if(is.null(group_by)){ result <- list( type = "data/occurrences-count", url = url_lookup("data/occurrences-count"), - predicates = predicates_info, + headers = list( + `User-Agent` = galah_version_string(), + `X-USER-AGENT` = galah_version_string(), + `Content-Type` = "application/json", + Accept = "application/json"), + options = list( + httpauth = 1, + userpwd = user_string), + body = predicates_info, slot_name = "count", expand = FALSE) # add facets }else{ + # NOT FUNCTIONAL result <- list( type = "data/occurrences-count", url = url_lookup("data/occurrences-count-groupby"), - predicates = predicates_info, + body = predicates_info, slot_name = "count", expand = FALSE) # result <- list(type = "data/occurrences-count-groupby") @@ -125,7 +140,6 @@ as_query_occurrences_count_gbif <- function(identify = NULL, # result$expand <- ifelse(length(facets) > 1, TRUE, FALSE) } # aggregate and return - result$headers <- build_headers() class(result) <- "query" result } diff --git a/R/build_predicates.R b/R/build_predicates.R index 5dfe78d6..f99eeb23 100644 --- a/R/build_predicates.R +++ b/R/build_predicates.R @@ -1,31 +1,8 @@ -#' Build predicates -#' -#' predicates are JSON scripts for passing to GBIF offline downloads API. -#' https://www.gbif.org/developer/occurrence -#' params x A list with slots relevant to building predicates -#' @noRd -#' @keywords Internal -build_predicates <- function( - x - # format = "SIMPLE_CSV" # i.e. default is to return occurrences -){ - list( - creator = jsonlite::unbox( - potions::pour("user", "username", .pkg = "galah")), - notificationAddresses = jsonlite::unbox( - potions::pour("user", "email", .pkg = "galah")), - sendNotification = jsonlite::unbox( - potions::pour("package", "send_email", .pkg = "galah")), - format = jsonlite::unbox(format), - predicate = concatenate_predicates(x)) - # jsonlite::toJSON() -} - #' join all queries #' NOTE: There is a maximum of 101k entries in total. Should be possible to enforce that here #' @noRd #' @keywords Internal -concatenate_predicates <- function(x){ +build_predicates <- function(x){ # check for taxonomic queries if(!is.null(x$identify)){ @@ -41,39 +18,32 @@ concatenate_predicates <- function(x){ location <- NULL } - # parse correctly given provided information + # filter is last if(!is.null(x$filter)){ - result <- x$filter - if(is_and_query(x)){ - result$predicates <- c(result$predicates, - identify, - location) |> - remove_nulls_from_list() - }else{ # filter exists, but no type (e.g. it's length-1) - # NOTE: This code looks similar to when filter is missing - # consolidate? - result <- c(result, - identify, - location) |> - remove_nulls_from_list() - if(length(result) > 1){ - result <- list(type = jsonlite::unbox("and"), - predicates = result) - } - } - # i.e. if filter is missing + filters <- x$filter }else{ - result <- list(identify, location) |> - remove_nulls_from_list() - if(length(result) > 1){ - result <- list(type = jsonlite::unbox("and"), - predicates = result) - } + filters <- NULL } - # add class and return - class(result) <- c("galah_filter_predicate", "list") - result + # parse correctly given provided information + if(is_and_query(x)){ + combined_list <- c(filters$predicates, + identify, + location) + }else{ # filter exists, but no type (e.g. it's length-1) + combined_list <- c(filters, + identify, + location) + } + if(length(combined_list) < 1){ + NULL + }else{ + combined_list <- remove_nulls_from_list(combined_list) + names(combined_list) <- NULL + list(type = "and", + predicates = combined_list) + # NOTE: This is messy for length-1, but does work + } } #' simple check for whether predicates begin with `and` diff --git a/R/check.R b/R/check.R index 0709cd99..fd601864 100644 --- a/R/check.R +++ b/R/check.R @@ -81,14 +81,18 @@ check_download_filename <- function(file, #' @keywords Internal check_email <- function(.query){ if(is_gbif()){ - email_text <- jsonlite::fromJSON(.query$body)$notificationAddresses + # actually we check the userpwd entry here + email_text <- .query$options$userpwd + if(email_text == ":"){ + abort_email_missing() + } }else{ email_text <- httr2::url_parse(.query$url)$query$email - } - if(is.null(email_text)) { - abort_email_missing() - }else if(email_text == ""){ - abort_email_missing() + if(is.null(email_text)) { + abort_email_missing() + }else if(email_text == ""){ + abort_email_missing() + } } .query } @@ -124,17 +128,11 @@ check_filter_tibbles <- function(x){ # where x is a list of tibbles } #' Internal function to check whether fields are valid -#' @importFrom dplyr bind_rows -#' @importFrom dplyr pull -#' @importFrom glue glue_collapse -#' @importFrom glue glue_data -#' @importFrom httr2 url_parse -#' @importFrom rlang format_error_bullets #' @noRd #' @keywords Internal check_fields <- function(.query) { - if(pour("package", "run_checks")){ + if(potions::pour("package", "run_checks")){ if(is_gbif()){ if(.query$type == "data/occurrences"){ check_result <- check_fields_gbif_predicates(.query) @@ -161,7 +159,7 @@ check_fields <- function(.query) { x = glue("Can't find field(s) in"), glue::glue(" ", format_error_bullets(invalid_fields_message)) ) - rlang::abort(bullets) + cli::cli_abort(bullets) } } .query @@ -210,6 +208,7 @@ check_field_identities <- function(df, } #' sub-function to `check_fields()` for GBIF +#' NOTE: This is probably obsolete once we start using predicates for counts #' @noRd #' @keywords Internal check_fields_gbif_counts <- function(.query){ @@ -250,16 +249,21 @@ check_fields_gbif_counts <- function(.query){ #' @noRd #' @keywords Internal check_fields_gbif_predicates <- function(.query){ + # set fields to check against valid_fields <- .query[["metadata/fields"]]$id valid_assertions <- .query[["metadata/assertions"]]$id valid_any <- c(valid_fields, valid_assertions) |> camel_to_snake_case() |> toupper() + # extract fields - fields <- .query$body |> - jsonlite::fromJSON() |> - purrr::pluck("predicate", "predicates", "key") + predicates <- .query |> + purrr::pluck("body", "filter") |> + unlist() + keys <- grepl(".key$", names(predicates)) + fields <- predicates[keys] + # check invalid filter_invalid <- NA if (length(fields) > 0) { @@ -353,7 +357,7 @@ check_groups <- function(group, n){ check_identifiers <- function(.query){ # For GBIF, which uses predicates, we 'promote' taxonomic queries to 'predicates' if(is_gbif()){ - .query$predicates$identify <- .query$`metadata/taxa-single` + .query$body$identify <- .query$`metadata/taxa-single` .query # otherwise we replace "(`TAXON_PLACEHOLDER`)" }else{ diff --git a/R/collapse.R b/R/collapse.R index a67e597e..ce90321a 100644 --- a/R/collapse.R +++ b/R/collapse.R @@ -45,6 +45,7 @@ collapse.files_request <- function(x, thumbnail = FALSE, ... ){ + # convert to `query_set` then parse coalesce(x, thumbnail = thumbnail, ...) |> diff --git a/R/collapse_checks.R b/R/collapse_checks.R index 9974b2f0..bc4be73c 100644 --- a/R/collapse_checks.R +++ b/R/collapse_checks.R @@ -63,17 +63,6 @@ collapse_run_checks <- function(.query){ .query$url <- tibble(url = glue(utils::URLdecode(.query$url), id = .query[["metadata/distributions"]]$id)) } - # GBIF predicates: - if(any(names(.query) == "predicates")){ - result <- .query |> - purrr::pluck("predicates") |> - build_predicates() - .query$predicates <- result - } - # TODO: might need to promote this up a bit - # the problem is that we need to add function-specific content - # but that content isn't available here - # clean up .query <- collapse_remove_metadata(.query) } diff --git a/R/collapse_occurrences_count.R b/R/collapse_occurrences_count.R index 4cd05674..03dfc210 100644 --- a/R/collapse_occurrences_count.R +++ b/R/collapse_occurrences_count.R @@ -15,7 +15,7 @@ collapse_occurrences_count <- function(.query){ abort("Grouped counts haven't been (re)implemented for GBIF yet") # compute_grouped_counts_GBIF(.query) }else{ - .query + collapse_occurreces_count_gbif(.query) } }else{ if(.query$expand){ @@ -28,6 +28,47 @@ collapse_occurrences_count <- function(.query){ # Note: the above handles types `data/occurrences-count-groupby` # and `data/occurrences-count`. This is probably inefficient. + +#' Function to construct `body` arg for GBIF +#' predicates are JSON scripts for passing to GBIF offline downloads API. +#' https://www.gbif.org/developer/occurrence +#' @param x A list with slots relevant to building predicates +#' @noRd +#' @keywords Internal +collapse_occurreces_count_gbif <- function(x){ + # GBIF predicates: + if(any(names(x) == "body")){ + result <- switch(x$type, + "data/occurrences" = { + list( + creator = potions::pour("user", "username", .pkg = "galah"), + notificationAddresses = potions::pour("user", "email", .pkg = "galah"), + sendNotification = potions::pour("package", "send_email", .pkg = "galah"), + format = x$format, + predicate = build_predicates(x$body)) + }, + "data/occurrences-count" = { + predicates_list <- build_predicates(x$body) + if(is.null(predicates_list)){ + list(limit = 0) + }else{ + list( + predicate = predicates_list, + limit = 0) + } + }, + "data/occurrences-count-groupby" = { + browser() + # note there is a `facet` arg suggested in the API docs, may work here + } + ) |> + jsonlite::toJSON(auto_unbox = TRUE, + pretty = TRUE) + x$body <- result + } + x +} + #' Internal function to handle facet counting, adjustment etc. #' @noRd #' @keywords Internal @@ -118,8 +159,9 @@ collapse_occurrences_count_groupby <- function(.query, # run query to get list of count tibbles result <- query_API(.query) if(is.null(result)){system_down_message("count")} - result <- lapply(result, - function(a){a$fieldResult |> bind_rows()}) + result <- purrr::map(result, + \(a){a$fieldResult |> + dplyr::bind_rows()}) names(result) <- facet_names[-length(facet_names)] # expand to a tibble that gives all combinations @@ -132,13 +174,15 @@ collapse_occurrences_count_groupby <- function(.query, # convert to all combinations of levels if(length(result_list) > 1){ - levels_list <- lapply(result_list, function(a){a[[1]]}) + levels_list <- purrr::map(result_list, \(a){a[[1]]}) names(levels_list) <- names(result) levels_list <- c(levels_list, list(stringsAsFactors = FALSE)) result_df <- do.call(expand.grid, levels_list) |> tibble::tibble() for(i in seq_along(result_list)){ - result_df <- dplyr::full_join(result_df, result_list[[i]], by = kept_facets[i]) + result_df <- dplyr::full_join(result_df, + result_list[[i]], + by = kept_facets[i]) } }else{ result_df <- result_list[[1]] diff --git a/R/collect_occurrences_count.R b/R/collect_occurrences_count.R index edf9b404..060bb89c 100644 --- a/R/collect_occurrences_count.R +++ b/R/collect_occurrences_count.R @@ -16,22 +16,23 @@ collect_occurrences_count <- function(.query){ #' @noRd #' @keywords Internal collect_occurrences_count_gbif <- function(.query){ + result <- query_API(.query) - if(length(result$facets) < 1 & !is.null(result$count)){ # first handle single values + # if(length(result$facets) < 1 & !is.null(result$count)){ # first handle single values tibble(count = result$count) - }else{ - # note: this only works for length(facets) == 1 - result_df <- result |> - pluck(!!!list("facets", 1, "counts")) |> - bind_rows() - names(result_df)[1] <- .query$url |> - url_parse() |> - pluck("query", "facet") - # names(result_df)[1] <- result |> - # pluck(!!!list("facets", 1, "field")) |> - # tolower() - result_df - } + # }else{ + # # note: this only works for length(facets) == 1 + # result_df <- result |> + # pluck(!!!list("facets", 1, "counts")) |> + # bind_rows() + # names(result_df)[1] <- .query$url |> + # url_parse() |> + # pluck("query", "facet") + # # names(result_df)[1] <- result |> + # # pluck(!!!list("facets", 1, "field")) |> + # # tolower() + # result_df + # } } #' `collect()` for `type = "data/occurrences-count"` for living atlases diff --git a/R/handle_quosures_GBIF.R b/R/handle_quosures_GBIF.R index 7d61ceef..48ea7cc1 100644 --- a/R/handle_quosures_GBIF.R +++ b/R/handle_quosures_GBIF.R @@ -14,13 +14,15 @@ parse_quosures_data_gbif <- function(dots){ if(length(dots) > 0){ result <- purrr::map(dots, switch_expr_type_pred) - + names(result) <- NULL # NOTE: This step is *crucial* + # without it, jsonlite::toJSON() wraps predicates in `{}` instead of `[]` + # which is then rejected by GBIF if(length(result) > 1L){ - result <- list(type = jsonlite::unbox("and"), - predicates = result) + list(type = "and", + predicates = result) + }else{ + result } - class(result) <- c("galah_filter_predicate", "list") - result }else{ NULL } @@ -103,8 +105,8 @@ parse_relational_pred <- function(x){ # these can be parse as 'in' if(length(rhs) > 1){ list( - type = jsonlite::unbox("in"), - key = jsonlite::unbox(lhs), + type = "in", + key = lhs, values = rhs) # otherwise we assume they are length-1 and continue @@ -112,12 +114,12 @@ parse_relational_pred <- function(x){ # 'does not equal' is handled hierarchically if(operator == "!="){ - result <- list(type = "equals", - key = lhs, - value = rhs) list( type = "not", - purrr::map(result, jsonlite::unbox)) + list(type = "equals", + key = lhs, + value = rhs)) + # everything else is flat }else{ operator_text <- switch(operator, @@ -126,11 +128,9 @@ parse_relational_pred <- function(x){ "<=" = "lessThanOrEquals", ">" = "greaterThan", ">=" = "greaterThanOrEquals") - purrr::map( - list(type = operator_text, - key = lhs, - value = rhs), - jsonlite::unbox) + list(type = operator_text, + key = lhs, + value = rhs) } } } @@ -165,7 +165,8 @@ parse_logical_pred <- function(x){ env = rlang::quo_get_env(x)) |> switch_expr_type_pred() }) - list(type = jsonlite::unbox(logical_string), + names(subpredicates) <- NULL + list(type = logical_string, predicates = subpredicates) } @@ -192,10 +193,12 @@ parse_exclamation_pred <- function(x){ next_section$type <- "isNotNull" next_section }else{ - list(type = "not", next_section) + list(type = "not", + next_section) } }else{ - list(type = "not", next_section) + list(type = "not", + next_section) } } @@ -245,15 +248,13 @@ parse_between_pred <- function(x){ lower_bound <- list(type = "greaterThanOrEquals", key = lhs, - value = rlang::as_label(x_expr[[3]])) |> - purrr::map(.f = jsonlite::unbox) + value = rlang::as_label(x_expr[[3]])) upper_bound <- list(type = "lessThanOrEquals", key = lhs, - value = rlang::as_label(x_expr[[4]])) |> - purrr::map(.f = jsonlite::unbox) + value = rlang::as_label(x_expr[[4]])) - list(type = jsonlite::unbox("and"), + list(type = "and", predicates = list(lower_bound, upper_bound)) } @@ -282,7 +283,7 @@ parse_in_pred <- function(x){ # format as list list( - type = jsonlite::unbox("in"), - key = jsonlite::unbox(lhs), + type = "in", + key = lhs, values = rhs) } \ No newline at end of file diff --git a/tests/testthat/test-international-GBIF.R b/tests/testthat/test-international-GBIF.R index d100e09e..4b529f30 100644 --- a/tests/testthat/test-international-GBIF.R +++ b/tests/testthat/test-international-GBIF.R @@ -146,7 +146,11 @@ test_that("show_values works for GBIF fields", { test_that("atlas_counts works for GBIF", { skip_if_offline(); skip_on_ci() - expect_gt(atlas_counts()$count, 0) + galah_call() |> + count() |> + collect() |> + pull("count") |> + expect_gt(0) }) test_that("atlas_counts fails for GBIF when type = 'species'", { @@ -163,9 +167,10 @@ test_that("`count()` works with `filter()` for GBIF", { count() |> collapse() expect_s3_class(x, "query") - expect_equal(length(x), 5) + expect_equal(length(x), 7) expect_equal(names(x), - c("type", "url", "slot_name", "expand", "headers")) + c("type", "url", "headers", "options", + "body", "slot_name", "expand")) expect_equal(x$type, "data/occurrences-count") # compute y <- compute(x) @@ -181,13 +186,15 @@ test_that("`count` works with `identify` for GBIF", { skip_if_offline(); skip_on_ci() # collapse x <- request_data() |> - identify("Mammalia") |> + identify("Mammalia", "Aves") |> + filter(year >= 2020, basisOfRecord == "HUMAN_OBSERVATION") |> count() |> collapse() expect_s3_class(x, "query") - expect_equal(length(x), 5) + expect_equal(length(x), 7) expect_equal(names(x), - c("type", "url", "slot_name", "expand", "headers")) + c("type", "url", "headers", "options", + "body", "slot_name", "expand")) expect_equal(x$type, "data/occurrences-count") # compute y <- compute(x) @@ -289,6 +296,29 @@ test_that("`count()` works with `galah_radius()` for GBIF", { expect_lt(result$count, result_space$count) }) +test_that("`count` works with `identify` for GBIF when `run_checks` = TRUE", { + skip_if_offline(); skip_on_ci() + galah_config(run_checks = TRUE) + # collapse + x <- request_data() |> + identify("Litoria peronii") |> + filter(year >= 2020, basisOfRecord == "HUMAN_OBSERVATION") |> + collapse() # ERRORS HERE + expect_s3_class(x, "query") + expect_equal(length(x), 5) + expect_equal(names(x), + c("type", "url", "slot_name", "expand", "headers")) + expect_equal(x$type, "data/occurrences-count") + # compute + y <- compute(x) + expect_s3_class(y, "computed_query") + # collect + z <- collect(y) + expect_s3_class(z, c("tbl_df", "tbl", "data.frame")) + expect_gt(z$count, 1) + expect_equal(nrow(z), 1) +}) + test_that("`atlas_occurrences()` works with `galah_polygon()` for GBIF", { skip_if_offline(); skip_on_ci() wkt <- "POLYGON((142.36 -29.01,142.36 -29.39,142.74 -29.39,142.74 -29.01,142.36 -29.01))" From 3613a45cc0fd892adc27621a1e4263b0ce90ee6c Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Thu, 7 Aug 2025 16:52:16 +1000 Subject: [PATCH 14/94] Support `group_by()` with `count()` for GBIF (#272) First pass at this, as it still only supports one facet value. Note that `show_values()` for type `fields` could benefit from using the occurrences/search API, which will need to be recoded. That API also supports longer facet results and so may be useful for building queries with >1 facet. --- R/as_query-occurrences_count.R | 20 +++---- R/build_predicates.R | 101 ++++++++++++++++----------------- R/collapse_occurrences.R | 18 ++++++ R/collapse_occurrences_count.R | 50 +++++++++------- R/collapse_query.R | 2 + R/collect_occurrences_count.R | 101 ++++++++++++++------------------- R/handle_quosures.R | 6 +- 7 files changed, 153 insertions(+), 145 deletions(-) create mode 100644 R/collapse_occurrences.R diff --git a/R/as_query-occurrences_count.R b/R/as_query-occurrences_count.R index a8105c47..5ca7fc6e 100644 --- a/R/as_query-occurrences_count.R +++ b/R/as_query-occurrences_count.R @@ -100,7 +100,7 @@ as_query_occurrences_count_gbif <- function(identify = NULL, user_string <- glue::glue("{username}:{password}") # get relevant information - if(is.null(group_by)){ + # if(is.null(group_by)){ result <- list( type = "data/occurrences-count", url = url_lookup("data/occurrences-count"), @@ -116,14 +116,14 @@ as_query_occurrences_count_gbif <- function(identify = NULL, slot_name = "count", expand = FALSE) # add facets - }else{ - # NOT FUNCTIONAL - result <- list( - type = "data/occurrences-count", - url = url_lookup("data/occurrences-count-groupby"), - body = predicates_info, - slot_name = "count", - expand = FALSE) + # }else{ + # # NOT FUNCTIONAL + # result <- list( + # type = "data/occurrences-count", + # url = url_lookup("data/occurrences-count-groupby"), + # body = predicates_info, + # slot_name = "count", + # expand = FALSE) # result <- list(type = "data/occurrences-count-groupby") # url <- url_lookup("data/occurrences-count") |> # url_parse() @@ -138,7 +138,7 @@ as_query_occurrences_count_gbif <- function(identify = NULL, # facetLimit = slice$slice_n) # result$url <- url_build(url) # result$expand <- ifelse(length(facets) > 1, TRUE, FALSE) - } + # } # aggregate and return class(result) <- "query" result diff --git a/R/build_predicates.R b/R/build_predicates.R index f99eeb23..336a813e 100644 --- a/R/build_predicates.R +++ b/R/build_predicates.R @@ -4,48 +4,39 @@ #' @keywords Internal build_predicates <- function(x){ - # check for taxonomic queries - if(!is.null(x$identify)){ - identify <- parse_predicates_identify(x$identify) - }else{ - identify <- NULL - } - - # check for spatial queries - if(!is.null(x$geolocate)){ - location <- parse_predicates_location(x$geolocate) - }else{ - location <- NULL - } - - # filter is last - if(!is.null(x$filter)){ - filters <- x$filter - }else{ - filters <- NULL - } - - # parse correctly given provided information - if(is_and_query(x)){ - combined_list <- c(filters$predicates, - identify, - location) - }else{ # filter exists, but no type (e.g. it's length-1) - combined_list <- c(filters, - identify, - location) - } - if(length(combined_list) < 1){ + # combine provided information + filters_list <- c( + parse_predicates_filter(x), + parse_predicates_identify(x$identify), + parse_predicates_location(x$geolocate)) |> + remove_nulls_from_list() + + # return correctly structured object + if(length(filters_list) < 1){ NULL }else{ - combined_list <- remove_nulls_from_list(combined_list) - names(combined_list) <- NULL + names(filters_list) <- NULL # important for parsing with toJSON list(type = "and", - predicates = combined_list) + predicates = filters_list) # NOTE: This is messy for length-1, but does work } } +#' Cleanly handle filter args +#' @noRd +#' @keywords Internal +parse_predicates_filter <- function(x){ + if(is.null(x)){ + NULL + }else{ + if(is_and_query(x)){ + x$filter$predicates + }else{ + x$filter + } + } +} + #' simple check for whether predicates begin with `and` #' @noRd #' @keywords Internal @@ -61,22 +52,15 @@ is_and_query <- function(x){ } } -#' clean up a list -#' @noRd -#' @keywords Internal -remove_nulls_from_list <- function(x){ - x[!unlist(purrr::map(x, is.null))] -} - #' handle taxonomic queries #' @noRd #' @keywords Internal parse_predicates_identify <- function(x){ if(!is.null(x)){ result <- purrr::map(x$taxon_concept_id, - \(a){list(type = jsonlite::unbox("equals"), - key = jsonlite::unbox("TAXON_KEY"), - value = jsonlite::unbox(a))}) + \(a){list(type = "equals", + key = "TAXON_KEY", + value = a)}) if(length(result) > 1){ list(type = "or", result) @@ -97,22 +81,33 @@ parse_predicates_location <- function(location){ # if location is for a point radius vs polygon/bbox if(!is.null(names(location))){ if(all(!is.null(location$radius))) { # `galah_radius()` will always pass radius argument - list(type = jsonlite::unbox("geoDistance"), - latitude = jsonlite::unbox(location$lat), - longitude = jsonlite::unbox(location$lon), - distance = jsonlite::unbox(paste0(location$radius, "km"))) |> + list(type = "geoDistance", + latitude = location$lat, + longitude = location$lon, + distance = glue::glue("{location$radius} km")) |> list() }else{ - list(type = jsonlite::unbox("within"), - geometry = jsonlite::unbox(location)) |> + list(type = "within", + geometry = location) |> list() } }else{ - list(type = jsonlite::unbox("within"), - geometry = jsonlite::unbox(location)) |> + list(type = "within", + geometry = location) |> list() } }else{ NULL } +} + +#' clean up a list +#' @noRd +#' @keywords Internal +remove_nulls_from_list <- function(x){ + if(length(x) < 1){ + NULL + }else{ + x[!unlist(purrr::map(x, is.null))] + } } \ No newline at end of file diff --git a/R/collapse_occurrences.R b/R/collapse_occurrences.R new file mode 100644 index 00000000..e51b7492 --- /dev/null +++ b/R/collapse_occurrences.R @@ -0,0 +1,18 @@ +#' Internal function to collapse occurrences; called by `collapse_query` +#' @noRd +#' @keywords Internal +collapse_occurrences <- function(x){ + if(any(names(x) == "body")){ + result <- list( + creator = potions::pour("user", "username", .pkg = "galah"), + notificationAddresses = potions::pour("user", "email", .pkg = "galah"), + sendNotification = potions::pour("package", "send_email", .pkg = "galah"), + format = x$format, + predicate = build_predicates(x$body)) |> + jsonlite::toJSON(auto_unbox = TRUE, + pretty = TRUE) + x$body <- result + x + } + x +} \ No newline at end of file diff --git a/R/collapse_occurrences_count.R b/R/collapse_occurrences_count.R index 03dfc210..32990924 100644 --- a/R/collapse_occurrences_count.R +++ b/R/collapse_occurrences_count.R @@ -15,7 +15,7 @@ collapse_occurrences_count <- function(.query){ abort("Grouped counts haven't been (re)implemented for GBIF yet") # compute_grouped_counts_GBIF(.query) }else{ - collapse_occurreces_count_gbif(.query) + collapse_occurrences_count_gbif(.query) } }else{ if(.query$expand){ @@ -35,31 +35,20 @@ collapse_occurrences_count <- function(.query){ #' @param x A list with slots relevant to building predicates #' @noRd #' @keywords Internal -collapse_occurreces_count_gbif <- function(x){ +collapse_occurrences_count_gbif <- function(x){ # GBIF predicates: if(any(names(x) == "body")){ result <- switch(x$type, - "data/occurrences" = { - list( - creator = potions::pour("user", "username", .pkg = "galah"), - notificationAddresses = potions::pour("user", "email", .pkg = "galah"), - sendNotification = potions::pour("package", "send_email", .pkg = "galah"), - format = x$format, - predicate = build_predicates(x$body)) - }, "data/occurrences-count" = { - predicates_list <- build_predicates(x$body) - if(is.null(predicates_list)){ - list(limit = 0) - }else{ - list( - predicate = predicates_list, - limit = 0) - } + list( + predicate = build_predicates(x$body), + limit = 0, + facets = parse_predicates_groupby(x$body$group_by)) |> + remove_nulls_from_list() }, "data/occurrences-count-groupby" = { browser() - # note there is a `facet` arg suggested in the API docs, may work here + # note that facets currently handled above, but could be split here if needed } ) |> jsonlite::toJSON(auto_unbox = TRUE, @@ -69,6 +58,19 @@ collapse_occurreces_count_gbif <- function(x){ x } +#' handle `group_by` in predicates +#' @noRd +#' @keywords Internal +parse_predicates_groupby <- function(groupby){ + if(!is.null(groupby)){ + array(data = gbif_upper_case(groupby$name), + dim = 1, + dimnames = NULL) + }else{ + NULL + } +} + #' Internal function to handle facet counting, adjustment etc. #' @noRd #' @keywords Internal @@ -93,11 +95,15 @@ collapse_occurrences_count_nogroupby <- function(.query){ # message when limit is hit }else{ if(as.integer(url$query$flimit) < n_facets){ - limit <- url$query$flimit |> prettyNum(big.mark=",", preserve.width="none") - n_total_facets <- n_facets |> prettyNum(big.mark=",", preserve.width="none") + limit <- url$query$flimit |> + prettyNum(big.mark = ",", + preserve.width = "none") + n_total_facets <- n_facets |> + prettyNum(big.mark = ",", + preserve.width = "none") c( - cli::cli_text(cli::col_yellow(glue("Limiting to first {limit} of {n_total_facets} rows."))), + cli::cli_text(cli::col_yellow("Limiting to first {limit} of {n_total_facets} rows.")), cli::cli_text(cli::col_magenta("Use `atlas_counts(limit = )` to return more rows.")) ) |> cli::cli_inform() diff --git a/R/collapse_query.R b/R/collapse_query.R index 92021c55..c48665b7 100644 --- a/R/collapse_query.R +++ b/R/collapse_query.R @@ -4,6 +4,8 @@ #' @keywords Internal collapse_query <- function(x){ switch(x$type, + "data/occurrences" = collapse_occurrences(x), + "data/species" = collapse_occurrences(x), # again, optimised for GBIF "data/occurrences-count-groupby" = collapse_occurrences_count(x), "data/occurrences-count" = collapse_occurrences_count(x), "data/species-count" = collapse_species_count(x), diff --git a/R/collect_occurrences_count.R b/R/collect_occurrences_count.R index 060bb89c..5d6f3874 100644 --- a/R/collect_occurrences_count.R +++ b/R/collect_occurrences_count.R @@ -10,43 +10,43 @@ collect_occurrences_count <- function(.query){ } #' `collect()` for `type = "data/occurrences-count"` for gbif -#' @importFrom dplyr bind_rows -#' @importFrom httr2 url_parse -#' @importFrom purrr pluck #' @noRd #' @keywords Internal -collect_occurrences_count_gbif <- function(.query){ - +collect_occurrences_count_gbif <- function(.query, + error_call = rlang::caller_env()){ + # get response from GBIF result <- query_API(.query) - # if(length(result$facets) < 1 & !is.null(result$count)){ # first handle single values - tibble(count = result$count) - # }else{ - # # note: this only works for length(facets) == 1 - # result_df <- result |> - # pluck(!!!list("facets", 1, "counts")) |> - # bind_rows() - # names(result_df)[1] <- .query$url |> - # url_parse() |> - # pluck("query", "facet") - # # names(result_df)[1] <- result |> - # # pluck(!!!list("facets", 1, "field")) |> - # # tolower() - # result_df - # } + + # handle obvious errors + if(is.null(result$count)){ + cli::cli_abort("API returned a NULL result", call = error_call) + } + + # parse results + if(length(result$facets) < 1){ # first handle single values + tibble::tibble(count = result$count) + }else{ + # note: this only works for length(facets) == 1 + result_df <- result |> + purrr::pluck(!!!list("facets", 1, "counts")) |> + dplyr::bind_rows() + names(result_df)[1] <- result |> + purrr::pluck(!!!list("facets", 1, "field")) |> + tolower() + result_df + } } #' `collect()` for `type = "data/occurrences-count"` for living atlases -#' @importFrom dplyr bind_rows -#' @importFrom tibble tibble #' @noRd #' @keywords Internal collect_occurrences_count_la <- function(.query){ result <- query_API(.query) if(length(result$facetResults) < 1 & !is.null(result$totalRecords)){ # first handle single values - tibble(count = result$totalRecords) + tibble::tibble(count = result$totalRecords) }else{ # then when group_by() is specified clean_group_by(result, .query) |> - bind_rows() |> + dplyr::bind_rows() |> clean_labels() |> arrange_counts(direction = .query$arrange$direction, variable = .query$arrange$variable) @@ -54,65 +54,54 @@ collect_occurrences_count_la <- function(.query){ } #' Internal function to clean objects returned by group_by() -#' @importFrom dplyr bind_cols -#' @importFrom dplyr bind_rows -#' @importFrom dplyr select -#' @importFrom dplyr slice -#' @importFrom purrr pluck #' @returns A list #' @noRd #' @keywords Internal clean_group_by <- function(result, .query){ access_list <- list(1, "fieldResult") if(inherits(.query$url, "data.frame")){ - added_cols <- select(.query$url, -url) - lapply(seq_along(result), function(a){ + added_cols <- dplyr::select(.query$url, -url) + purrr::map(seq_along(result), \(a){ # get list-cols and convert to df - result_df <- pluck(result[[a]], !!!access_list) |> - bind_rows() + result_df <- result[[a]] |> + purrr::pluck(!!!access_list) |> + dplyr::bind_rows() # add supplied cols added_cols |> - slice(a) |> - bind_cols(result_df) + dplyr::slice(a) |> + dplyr::bind_cols(result_df) }) }else{ - pluck(result, !!!access_list) + purrr::pluck(result, !!!access_list) } } #' Internal function to clean up columns when group_by() is specified -#' @importFrom dplyr all_of -#' @importFrom dplyr any_of -#' @importFrom dplyr last_col -#' @importFrom dplyr relocate -#' @importFrom dplyr rename -#' @importFrom dplyr select -#' @importFrom stringr str_extract -#' @importFrom stringr str_replace #' @noRd #' @keywords Internal clean_labels <- function(df){ if(any(colnames(df) == "i18nCode")){ values <- df$i18nCode |> - str_extract("\\.([:graph:]|\\s)+$") |> - str_replace("^\\.", "") + stringr::str_extract("\\.([:graph:]|\\s)+$") |> + stringr::str_replace("^\\.", "") variable <- df$i18nCode[1] |> - str_extract("^[:graph:]+\\.") |> - str_replace("\\.$", "") + stringr::str_extract("^[:graph:]+\\.") |> + stringr::str_replace("\\.$", "") df[[variable]] <- values df |> - select(-any_of(c("label", "i18nCode", "fq"))) |> - relocate("count", .after = last_col()) + dplyr::select(-dplyr::any_of(c("label", "i18nCode", "fq"))) |> + dplyr::relocate("count", + .after = dplyr::last_col()) }else{ # Some atlases (e.g. Estonia) only have "label" column if(any(colnames(df) == "label")){ - field_name <- str_extract(df$fq[1], "[^:]+") |> + field_name <- stringr::str_extract(df$fq[1], "[^:]+") |> as.character() col_lookup <- c("label") names(col_lookup) <- field_name df |> - rename(all_of(col_lookup)) |> - select(-"fq") + dplyr::rename(dplyr::all_of(col_lookup)) |> + dplyr::select(-"fq") }else{ # some are completely empty df @@ -121,8 +110,6 @@ clean_labels <- function(df){ } #' Internal function to arrange count df -#' @importFrom dplyr arrange -#' @importFrom dplyr desc #' @noRd #' @keywords Internal arrange_counts <- function(df, @@ -130,9 +117,9 @@ arrange_counts <- function(df, variable = "count"){ var_symbol <- as.symbol(variable) if(direction == "ascending" & variable == "count"){ - arrange(df, !!var_symbol) + dplyr::arrange(df, !!var_symbol) } else if(direction == "descending" & variable != "count"){ - arrange(df, dplyr::desc(!!var_symbol)) + dplyr::arrange(df, dplyr::desc(!!var_symbol)) } else { df } diff --git a/R/handle_quosures.R b/R/handle_quosures.R index 13e722a1..9974ecb2 100644 --- a/R/handle_quosures.R +++ b/R/handle_quosures.R @@ -318,7 +318,7 @@ parse_logical <- function(x, ...){ }else{ logical_string <- " AND " } - linked_statements <- purrr::map(rlang::quo_get_expr(x)[-1], + linked_statements <- purrr::map(as.list(rlang::quo_get_expr(x)[-1]), \(a){ a |> rlang::as_quosure(env = rlang::quo_get_env(x)) |> @@ -450,8 +450,8 @@ parse_in <- function(x, excl){ ) |> rlang::parse_expr() # convert to quosure and pass to `parse_logical()` - rlang::as_quosure(in_as_or_statements, - rlang::quo_get_env(x)) |> + rlang::as_quosure(x = in_as_or_statements, + env = rlang::quo_get_env(x)) |> parse_logical() } From 8ca899f3327995b3c37680cdbff5890e5a9b370c Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Tue, 12 Aug 2025 16:18:49 +1000 Subject: [PATCH 15/94] Support `group_by()` with `count()` for > 1 fields in GBIF queries (#272) First pass at this, will need cleaning up --- R/as_query-occurrences_count.R | 72 +++++------- R/as_query-species_count.R | 4 +- R/build_predicates.R | 13 +++ R/check.R | 22 ++-- R/collapse_checks.R | 2 +- ...t.R => collapse_occurrences_count_atlas.R} | 106 +++--------------- R/collapse_occurrences_count_gbif.R | 103 +++++++++++++++++ R/collapse_query.R | 22 +++- R/collapse_species_count.R | 10 +- R/collect_occurrences_count.R | 49 ++++++-- R/galah_group_by.R | 31 ++--- R/handle_quosures_GBIF.R | 8 -- R/messages.R | 22 ++-- R/query_API.R | 46 ++++++-- R/sysdata.rda | Bin 17845 -> 17853 bytes R/utilities_internal.R | 38 ++++++- tests/testthat/test-international-GBIF.R | 66 +++++++++-- 17 files changed, 379 insertions(+), 235 deletions(-) rename R/{collapse_occurrences_count.R => collapse_occurrences_count_atlas.R} (65%) create mode 100644 R/collapse_occurrences_count_gbif.R diff --git a/R/as_query-occurrences_count.R b/R/as_query-occurrences_count.R index 5ca7fc6e..11dfabde 100644 --- a/R/as_query-occurrences_count.R +++ b/R/as_query-occurrences_count.R @@ -40,8 +40,7 @@ as_query_occurrences_count_atlas <- function(identify = NULL, url = httr2::url_build(url), headers = build_headers(), filter = filter, - slot_name = "totalRecords", - expand = FALSE) + slot_name = "totalRecords") }else{ url <- url_lookup("data/occurrences-count-groupby") |> httr2::url_parse() @@ -63,7 +62,6 @@ as_query_occurrences_count_atlas <- function(identify = NULL, url = httr2::url_build(url), headers = build_headers(), filter = filter, - expand = ifelse(length(facets) > 1, TRUE, FALSE), arrange = slice_arrange) } class(result) <- "query" @@ -94,52 +92,36 @@ as_query_occurrences_count_gbif <- function(identify = NULL, slice), limit = 0) - # get user string + # get strings username <- potions::pour("user", "username", .pkg = "galah") password <- potions::pour("user", "password", .pkg = "galah") user_string <- glue::glue("{username}:{password}") - # get relevant information - # if(is.null(group_by)){ - result <- list( - type = "data/occurrences-count", - url = url_lookup("data/occurrences-count"), - headers = list( - `User-Agent` = galah_version_string(), - `X-USER-AGENT` = galah_version_string(), - `Content-Type` = "application/json", - Accept = "application/json"), - options = list( - httpauth = 1, - userpwd = user_string), - body = predicates_info, - slot_name = "count", - expand = FALSE) - # add facets - # }else{ - # # NOT FUNCTIONAL - # result <- list( - # type = "data/occurrences-count", - # url = url_lookup("data/occurrences-count-groupby"), - # body = predicates_info, - # slot_name = "count", - # expand = FALSE) - # result <- list(type = "data/occurrences-count-groupby") - # url <- url_lookup("data/occurrences-count") |> - # url_parse() - # facets <- as.list(group_by$name) - # names(facets) <- rep("facet", length(facets)) - # if(is.null(slice)){ - # slice <- tibble(slice_n = 30, slice_called = FALSE) - # } - # url$query <- c(build_query_gbif(identify, filter, geolocate), - # limit = 0, - # facets, - # facetLimit = slice$slice_n) - # result$url <- url_build(url) - # result$expand <- ifelse(length(facets) > 1, TRUE, FALSE) - # } - # aggregate and return + # handle type + if(is.null(group_by)){ + data_type <- "data/occurrences-count" + }else{ + data_type <- "data/occurrences-count-groupby" + } + + # build object + ## Note that unlike with other atlases, parsing of `group_by` is handled + ## by `collapse()` rather than here. + result <- list( + type = data_type, + url = url_lookup("data/occurrences-count"), + headers = list( + `User-Agent` = galah_version_string(), + `X-USER-AGENT` = galah_version_string(), + `Content-Type` = "application/json", + Accept = "application/json"), + options = list( + httpauth = 1, + userpwd = user_string), + body = predicates_info, + slot_name = "count") + + # classify and return class(result) <- "query" result } diff --git a/R/as_query-species_count.R b/R/as_query-species_count.R index c49123db..13c87269 100644 --- a/R/as_query-species_count.R +++ b/R/as_query-species_count.R @@ -39,8 +39,7 @@ as_query_species_count_atlas <- function(identify = NULL, result <- list(type = "data/species-count", url = url_build(url), headers = build_headers(), - filter = filter, - expand = FALSE) + filter = filter) }else{ facets <- c(as.list(group_by$name), species_facets()) names(facets) <- rep("facets", length(facets)) @@ -57,7 +56,6 @@ as_query_species_count_atlas <- function(identify = NULL, url = httr2::url_build(url), headers = build_headers(), filter = filter, - expand = TRUE, arrange = slice_arrange) } class(result) <- "query" diff --git a/R/build_predicates.R b/R/build_predicates.R index 336a813e..aaec8629 100644 --- a/R/build_predicates.R +++ b/R/build_predicates.R @@ -101,6 +101,19 @@ parse_predicates_location <- function(location){ } } +#' handle `group_by` in predicates +#' @noRd +#' @keywords Internal +parse_predicates_groupby <- function(groupby){ + if(!is.null(groupby)){ + array(data = gbif_upper_case(groupby$name), + dim = length(groupby$name), + dimnames = NULL) + }else{ + NULL + } +} + #' clean up a list #' @noRd #' @keywords Internal diff --git a/R/check.R b/R/check.R index fd601864..0a5965ed 100644 --- a/R/check.R +++ b/R/check.R @@ -424,19 +424,19 @@ check_identifiers_la <- function(.query, check_login <- function(.query, error_call = caller_env()) { # Check for valid email for occurrences or species queries for all providers - if(.query$type == "data/occurrences" | .query$type == "data/species"){ - switch(potions::pour("atlas", "region"), - "United Kingdom" = {}, - "Global" = {check_email(.query); check_password(.query)}, - check_email(.query)) + if(is_gbif()){ + if(grepl("^data", .query$type)){ + check_email(.query) + check_password(.query) + } + }else{ + if(.query$type %in% c("data/occurrences", "data/species")){ + switch(potions::pour("atlas", "region"), + "United Kingdom" = {}, + check_email(.query)) + } } .query - # ALA requires an API key - # } else if (pour("atlas", "acronym") == "ALA") { - # if (is.null(.query$headers$`x-api-key`) | .query$headers$`x-api-key` == "") { - # abort_api_key_missing() - # } - # } } #' Internal function to convert multi-value media fields to list-columns diff --git a/R/collapse_checks.R b/R/collapse_checks.R index bc4be73c..aed3a994 100644 --- a/R/collapse_checks.R +++ b/R/collapse_checks.R @@ -47,11 +47,11 @@ collapse_run_checks <- function(.query){ grepl("-unnest$", .query$type)){ # some checks should happen regardless of `run_checks` .query <- .query |> + check_login() |> check_identifiers() |> check_select() if(potions::pour("package", "run_checks")) { .query <- .query |> - check_login() |> check_reason() |> check_fields() |> check_profiles() diff --git a/R/collapse_occurrences_count.R b/R/collapse_occurrences_count_atlas.R similarity index 65% rename from R/collapse_occurrences_count.R rename to R/collapse_occurrences_count_atlas.R index 32990924..ceea8bd0 100644 --- a/R/collapse_occurrences_count.R +++ b/R/collapse_occurrences_count_atlas.R @@ -1,80 +1,7 @@ -#' Compute counts -#' -#' This is a little different from other `compute` functions; it converts a -#' `data_query` object (from `collapse(type = "occurrences-count")`) into one or more urls -#' that can be passed verbatim to `query_API()`. This is important when `group_by` -#' has `expand = TRUE`, because it requires an initial call to the atlas to -#' calculate the requisite URLs. It makes very little difference under other -#' circumstances -#' @param .query An object of class `data_query` -#' @keywords Internal -#' @noRd -collapse_occurrences_count <- function(.query){ - if(is_gbif()){ - if(.query$expand){ - abort("Grouped counts haven't been (re)implemented for GBIF yet") - # compute_grouped_counts_GBIF(.query) - }else{ - collapse_occurrences_count_gbif(.query) - } - }else{ - if(.query$expand){ - collapse_occurrences_count_groupby(.query) - }else{ - collapse_occurrences_count_nogroupby(.query) - } - } -} -# Note: the above handles types `data/occurrences-count-groupby` -# and `data/occurrences-count`. This is probably inefficient. - - -#' Function to construct `body` arg for GBIF -#' predicates are JSON scripts for passing to GBIF offline downloads API. -#' https://www.gbif.org/developer/occurrence -#' @param x A list with slots relevant to building predicates -#' @noRd -#' @keywords Internal -collapse_occurrences_count_gbif <- function(x){ - # GBIF predicates: - if(any(names(x) == "body")){ - result <- switch(x$type, - "data/occurrences-count" = { - list( - predicate = build_predicates(x$body), - limit = 0, - facets = parse_predicates_groupby(x$body$group_by)) |> - remove_nulls_from_list() - }, - "data/occurrences-count-groupby" = { - browser() - # note that facets currently handled above, but could be split here if needed - } - ) |> - jsonlite::toJSON(auto_unbox = TRUE, - pretty = TRUE) - x$body <- result - } - x -} - -#' handle `group_by` in predicates -#' @noRd -#' @keywords Internal -parse_predicates_groupby <- function(groupby){ - if(!is.null(groupby)){ - array(data = gbif_upper_case(groupby$name), - dim = 1, - dimnames = NULL) - }else{ - NULL - } -} - #' Internal function to handle facet counting, adjustment etc. #' @noRd #' @keywords Internal -collapse_occurrences_count_nogroupby <- function(.query){ +collapse_occurrences_count_atlas <- function(.query){ url <- httr2::url_parse(.query$url) # check if a limit has been set @@ -116,11 +43,11 @@ collapse_occurrences_count_nogroupby <- function(.query){ } } -#' Determine set of queries when expand = TRUE +#' Determine set of queries when group_by() is set #' @noRd #' @keywords Internal -collapse_occurrences_count_groupby <- function(.query, - error_call = caller_env()){ +collapse_occurrences_count_atlas_groupby <- function(.query, + error_call = caller_env()){ data_cached <- .query # get url url <- httr2::url_parse(.query$url) @@ -129,24 +56,15 @@ collapse_occurrences_count_groupby <- function(.query, facet_list <- url$query[names(url$query) == "facets"] facet_names <- unlist(facet_list) names(facet_names) <- NULL - - # Error when Atlas facet max is reached - if (pour("atlas", "region") == "Estonia" & - length(facet_list) > 1) { - atlas <- pour("atlas", "region") - n_fields <- length(facet_list) - c( - "Too many fields passed to `group_by()`.", - x = "Selected atlas ({atlas}) accepts a maximum of 1 field, not {n_fields}.") |> - cli::cli_abort(call = error_call) - } # save out facet limits to add back later saved_facet_queries <- list( flimit = url$query$flimit, foffset = url$query$foffset) saved_facet_queries <- saved_facet_queries[ - unlist(lapply(saved_facet_queries, function(a){!is.null(a)}))] + purrr::map(saved_facet_queries, + \(a){!is.null(a)}) |> + unlist()] # rebuild url with only first facet argument url$query <- c( @@ -172,7 +90,7 @@ collapse_occurrences_count_groupby <- function(.query, # expand to a tibble that gives all combinations kept_facets <- facet_names[-length(facet_names)] - result_list <- lapply(names(result), + result_list <- purrr::map(names(result), function(a){ x <- result[[a]][c(1, 4)] names(x)[1] <- a @@ -211,7 +129,9 @@ collapse_occurrences_count_groupby <- function(.query, # glue `fq` statements together result_df <- result_df |> dplyr::rowwise() |> - dplyr::mutate(query = glue::glue_collapse(dplyr::c_across(dplyr::starts_with("fq")), sep = " AND ")) |> + dplyr::mutate(query = dplyr::starts_with("fq") |> + dplyr::c_across() |> + glue_collapse( sep = " AND ")) |> dplyr::select(-dplyr::starts_with("fq")) |> dplyr::ungroup() if(!is.null(fqs)){ @@ -247,7 +167,9 @@ collapse_occurrences_count_groupby <- function(.query, #' It is called exclusively by `compute_counts()` #' @noRd #' @keywords Internal -check_facet_count <- function(.query, warn = TRUE, error_call = caller_env()){ +check_facet_count <- function(.query, + warn = TRUE, + error_call = caller_env()){ url <- httr2::url_parse(.query$url) current_limit <- url$query$flimit diff --git a/R/collapse_occurrences_count_gbif.R b/R/collapse_occurrences_count_gbif.R new file mode 100644 index 00000000..f2382e41 --- /dev/null +++ b/R/collapse_occurrences_count_gbif.R @@ -0,0 +1,103 @@ +#' Function to construct `body` arg for GBIF +#' predicates are JSON scripts for passing to GBIF offline downloads API. +#' https://www.gbif.org/developer/occurrence +#' @param x A list with slots relevant to building predicates +#' @noRd +#' @keywords Internal +collapse_occurrences_count_gbif <- function(x){ + x$body <- list(predicate = build_predicates(x$body), + limit = 0) |> + remove_nulls_from_list() |> + jsonlite::toJSON(auto_unbox = TRUE, + pretty = TRUE) + x +} + +#' Internal function to handle count queries with 'basic' group_by queries +#' In practice, this means just passing arguments to the `facets` arg +#' @noRd +#' @keywords Internal +collapse_occurrences_count_gbif_groupby_basic <- function(x){ + x$body <- list(predicate = build_predicates(x$body), + limit = 0, + facets = parse_predicates_groupby(x$body$group_by)) |> + remove_nulls_from_list() |> + jsonlite::toJSON(auto_unbox = TRUE, + pretty = TRUE) + x +} + +#' Internal function to handle 'crossed' count queries +#' @noRd +#' @keywords Internal +collapse_occurrences_count_gbif_groupby_crossed <- function(x){ + + # get a 'basic' query, showing facets for each variable separately + facets <- collapse_occurrences_count_gbif_groupby_basic(x) |> + collect() + + # then order queries by decreasing number of levels + facet_order <- facets |> + select(-"count") |> + purrr::map(.f = \(a){length(which(!is.na(a)))}) |> + unlist() |> + sort() + + # build filters for our next round of queries + # for 3 facets, we need to expand our crossed variables + if(length(facet_order) > 2){ + filters <- facets |> + select(!!!names(facet_order[1:2])) |> + purrr::map(.f = \(a){a[!is.na(a)]}) |> + expand.grid() + facet <- names(facet_order)[3] + # for 2 facets, we just select the levels we need + }else{ + variable <- names(facet_order)[1] + filters <- facets |> + select(!!!(names(facet_order)[1])) |> + tidyr::drop_na() + facet <- names(facet_order)[2] + } + + # convert our tibble of new filters into predicate entries + predicate_list <- tibble_to_predicate(filters) + + # create separate `body` (JSON) files for each query, accounting for `filter` + # and `facet` + body_list <- purrr::map(predicate_list, .f = \(a){ + temp_obj <- x$body + temp_obj$filter <- c(temp_obj$filter, a) + temp_obj$group_by <- NULL + list(predicate = build_predicates(temp_obj), + limit = 0, + facets = tibble::tibble(name = facet) |> + parse_predicates_groupby()) |> + remove_nulls_from_list() |> + jsonlite::toJSON(auto_unbox = TRUE, + pretty = TRUE) + }) + + # add predicates to tibble; tibble to `body`; return + filters$predicate <- body_list + x$body <- filters + x +} + +#' Internal function to convert a facet df into a predicate +#' @noRd +#' @keywords Internal +tibble_to_predicate <- function(df){ + # iterate over rows + purrr::map( + split(df, seq_len(nrow(df))), + .f = \(a){ + # for each row, convert each column (cell) into a list + cols_vector <- seq_len(ncol(a)) + purrr::map(cols_vector, \(b){ + list(type = "equals", + key = gbif_upper_case(names(a)[b]), + value = a[[b]][[1]]) + }) + }) +} \ No newline at end of file diff --git a/R/collapse_query.R b/R/collapse_query.R index c48665b7..576b76b6 100644 --- a/R/collapse_query.R +++ b/R/collapse_query.R @@ -5,9 +5,25 @@ collapse_query <- function(x){ switch(x$type, "data/occurrences" = collapse_occurrences(x), - "data/species" = collapse_occurrences(x), # again, optimised for GBIF - "data/occurrences-count-groupby" = collapse_occurrences_count(x), - "data/occurrences-count" = collapse_occurrences_count(x), + "data/occurrences-count" = { + if(is_gbif()){ + collapse_occurrences_count_gbif(x) + }else{ + collapse_occurrences_count_atlas(x) + } + }, + "data/occurrences-count-groupby" = { + if(is_gbif()){ + if(nrow(x$body$group_by) > 1){ + collapse_occurrences_count_gbif_groupby_crossed(x) + }else{ + collapse_occurrences_count_gbif_groupby_basic(x) + } + }else{ + collapse_occurrences_count_atlas_groupby(x) + } + }, + "data/species" = collapse_occurrences(x), # optimised for GBIF "data/species-count" = collapse_species_count(x), # "-unnest" functions require some checks "metadata/profiles-unnest" = collapse_profile_values(x), # check this diff --git a/R/collapse_species_count.R b/R/collapse_species_count.R index 5239ac2c..4259a0aa 100644 --- a/R/collapse_species_count.R +++ b/R/collapse_species_count.R @@ -2,6 +2,8 @@ #' @noRd #' @keywords Internal collapse_species_count <- function(.query){ + browser() + # `expand` argument has been removed from query objects; need to refactor this if(.query$expand){ .query <- collapse_species_query_list(.query) }else{ @@ -29,15 +31,14 @@ collapse_species_query_list <- function(.query){ data_temp <- .query data_temp$type <- "data/occurrences-count" data_temp$url <- httr2::url_build(url) - data_temp$expand <- ifelse(n_facet_terms > 1, TRUE, FALSE) # collect using `occurrences-count` code (to parse expand correctly) df <- collect(data_temp) # create new set of fq args - fq_args <- lapply( + fq_args <- purrr::map( split(df, seq_len(nrow(df))), - function(a){ + \(a){ x <- a[, - ncol(a)] glue::glue_collapse( glue::glue("{names(x)}:{x}"), @@ -58,7 +59,8 @@ collapse_species_query_list <- function(.query){ urls <- purrr::map(new_fqs, function(a, x){ x$query$fq <- a httr2::url_build(x) - }, x = url) |> unlist() + }, x = url) |> + unlist() # convert to a tibble to pass back to .query .query$url <- dplyr::bind_cols( diff --git a/R/collect_occurrences_count.R b/R/collect_occurrences_count.R index 5d6f3874..d3731a15 100644 --- a/R/collect_occurrences_count.R +++ b/R/collect_occurrences_count.R @@ -17,23 +17,52 @@ collect_occurrences_count_gbif <- function(.query, # get response from GBIF result <- query_API(.query) + # first handle case when there are multiple queries in a tibble + if(inherits(result, "data.frame")){ + split(result, seq_len(nrow(result))) |> + purrr::map(.f = \(a){ + tibble::tibble( + dplyr::select(a, -"predicate", -"result"), + collect_occurrences_count_gbif_single(a$result[[1]], + error_on_null = FALSE)) + }) |> + dplyr::bind_rows() + # then handle 'simple' queries + }else{ + collect_occurrences_count_gbif_single(result, + error_on_null = TRUE) + } +} + +#' collect a single count query +#' @noRd +#' @keywords Internal +collect_occurrences_count_gbif_single <- function(result, + error_on_null = TRUE, + error_call = rlang::caller_env()){ # handle obvious errors - if(is.null(result$count)){ - cli::cli_abort("API returned a NULL result", call = error_call) + if(is.null(result$count) & error_on_null){ + cli::cli_abort("API returned a NULL result", + call = error_call) } # parse results if(length(result$facets) < 1){ # first handle single values tibble::tibble(count = result$count) }else{ - # note: this only works for length(facets) == 1 - result_df <- result |> - purrr::pluck(!!!list("facets", 1, "counts")) |> - dplyr::bind_rows() - names(result_df)[1] <- result |> - purrr::pluck(!!!list("facets", 1, "field")) |> - tolower() - result_df + purrr::map( + purrr::pluck(result, "facets"), + \(a){ + df <- a |> + purrr::pluck("counts") |> + dplyr::bind_rows() + names(df)[1] <- purrr::pluck(a, "field") |> + snake_to_camel_case() + df + }) |> + dplyr::bind_rows() |> + dplyr::relocate("count", + .after = dplyr::last_col()) } } diff --git a/R/galah_group_by.R b/R/galah_group_by.R index 6d7168cc..232779c6 100644 --- a/R/galah_group_by.R +++ b/R/galah_group_by.R @@ -68,34 +68,21 @@ parse_group_by <- function(dot_names){ cli::cli_abort(call = caller_env()) } if(length(dot_names) > 0){ - df <- tibble::tibble(name = dot_names) + names(dot_names) <- NULL # needed to avoid empty strings added as names + df <- tibble::tibble(name = dot_names) df$type <- ifelse(stringr::str_detect(df$name, "[[:lower:]]"), "field", "assertions") }else{ - df <- tibble::tibble(name = "name", type = "type", .rows = 0) + df <- tibble::tibble(name = "name", + type = "type", + .rows = 0) } }else{ - df <- tibble::tibble(name = "name", type = "type", .rows = 0) + df <- tibble::tibble(name = "name", + type = "type", + .rows = 0) } return(df) -} - -# for passing to atlas_counts, see rgbif::count_facet -# in practice, the only fields allowable by `path <- /occurrence/counts` -# are `year` (with optional year range); -# https://api.gbif.org/v1/occurrence/counts/year?year=1981,2012 -# NOTE: range query is optional - -# galah_call() |> -# galah_group_by(year) |> -# galah_filter(year >= 1981 & year <= 2012) |> -# atlas_counts() - -# ...and `basisOfRecord` (no filters) -# https://api.gbif.org/v1/occurrence/counts/basisOfRecord - -# galah_call() |> -# galah_group_by(basisOfRecord) |> -# atlas_counts() +} \ No newline at end of file diff --git a/R/handle_quosures_GBIF.R b/R/handle_quosures_GBIF.R index 48ea7cc1..6b477b71 100644 --- a/R/handle_quosures_GBIF.R +++ b/R/handle_quosures_GBIF.R @@ -135,14 +135,6 @@ parse_relational_pred <- function(x){ } } -#' Internal function to handle variable naming -#' @noRd -#' @keywords internal -gbif_upper_case <- function(string){ - gsub("(?=[[:upper:]])", "_", string, perl = TRUE) |> - toupper() -} - #' Handle & and | statements #' @noRd #' @keywords internal diff --git a/R/messages.R b/R/messages.R index ca7ce9da..3542fff5 100644 --- a/R/messages.R +++ b/R/messages.R @@ -2,15 +2,13 @@ #' @noRd #' @keywords Internal abort_email_missing <- function(error_call = caller_env()){ - bullets <- c( + c( "No user email was found.", - i = glue( - "To download occurrence records or species lists you must provide a valid email \\ - address registered with the selected atlas." - ), - i = glue("Provide your email address using `galah_config(email = )`.") - ) - abort(bullets, call = error_call) + i = "To download occurrence records, species lists, or (for GBIF) occurrence + counts, \\ + you must provide a valid email address registered with the selected atlas.", + i = "Provide your email address using `galah_config(email = )`.") |> + cli::cli_abort(call = error_call) } # Internal function called by `check_login()` @@ -28,10 +26,10 @@ abort_email_missing <- function(error_call = caller_env()){ #' @noRd #' @keywords Internal system_down_message <- function(function_name){ - bullets <- c( - glue("Calling the API failed for `{function_name}`."), + c( + "Calling the API failed for `{function_name}`.", i = "This might mean that the API is down, or that you are not connected to the internet.", i = "Double check that your query is correct, or try again later." - ) - inform(bullets) + ) |> + cli::cli_inform(bullets) } \ No newline at end of file diff --git a/R/query_API.R b/R/query_API.R index 626504ed..2f6df4e8 100644 --- a/R/query_API.R +++ b/R/query_API.R @@ -5,16 +5,12 @@ #' column named `url`. #' @noRd #' @keywords Internal -query_API <- function(.query, error_call = caller_env()) { +query_API <- function(.query, + error_call = caller_env()) { + # first try situation when many urls are supplied + # this is common for living atlases, where many urls are generated for + # e.g. paginated queries, grouped counts etc if(inherits(.query$url, "data.frame")){ - verbose <- potions::pour("package", "verbose", .pkg = "galah") & - nrow(.query$url) > 1 - if(verbose){ - progress_bar <- list(name = "Querying API", - clear = TRUE) - }else{ - progress_bar <- FALSE - } purrr::map(.x = seq_len(nrow(.query$url)), .f = function(a){ data_tr <- .query @@ -25,12 +21,42 @@ query_API <- function(.query, error_call = caller_env()) { } query_API_internal(data_tr) }, - .progress = progress_bar) + .progress = set_progress_bar_behaviour(nrow(.query$url) > 1)) + # next handle multiple `body` arguments + # this is currently limited to GBIF count requests with > 1 `group_by` args + }else if(inherits(.query$body, "data.frame")){ + purrr::map(.x = split(.query$body, + seq_len(nrow(.query$body))), + .f = function(a){ + data_tr <- .query + data_tr$body <- a$predicate[[1]] + a$result <- list(query_API_internal(data_tr)) + a + }, + .progress = set_progress_bar_behaviour(length(.query$body) > 1)) |> + dplyr::bind_rows() + # finally, some queries are 'simple'; one `url`, one or no `body` args + # these we just run without any looping. }else{ query_API_internal(.query) } } +#' Internal function to run an API call using httr2 +#' @param criteria length-1 logical statement as to whether to proceed or not +#' @noRd +#' @keywords Internal +set_progress_bar_behaviour <- function(criteria){ + verbose <- potions::pour("package", "verbose", .pkg = "galah") & + criteria + if(verbose){ + progress_bar <- list(name = "Querying API", + clear = TRUE) + }else{ + progress_bar <- FALSE + } +} + #' Internal function to run an API call using httr2 #' @noRd #' @keywords Internal diff --git a/R/sysdata.rda b/R/sysdata.rda index 64ccec244d0844743d246246df7b7e54a0893f47..dbaec2d8f009ee50bc8d675e7269c7a225b8fef9 100644 GIT binary patch literal 17853 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 Xj71SbDbR|C|BJaIoG3^?XKHW&I2*~~ diff --git a/R/utilities_internal.R b/R/utilities_internal.R index 80aec8ab..5fc69c23 100644 --- a/R/utilities_internal.R +++ b/R/utilities_internal.R @@ -76,17 +76,51 @@ rename_columns <- function(varnames, type) { varnames } +##--------------------------------------------------------------- +## Cases -- +##--------------------------------------------------------------- + #' Internal function to make text to snake case #' @noRd #' @keywords Internal -camel_to_snake_case <- function(x){ - x |> +camel_to_snake_case <- function(string){ + string |> gsub("([a-z])([A-Z])", "\\1_\\L\\2", x = _, perl = TRUE) |> trimws(which = "both") |> # end spaces gsub("\\.+|\\s+", "_", x = _) |> # internal dots or spaces tolower() } +#' Internal function to handle conversion from camelCase to upper snake case +#' @noRd +#' @keywords internal +gbif_upper_case <- function(string){ + gsub("(?=[[:upper:]])", "_", string, perl = TRUE) |> + toupper() +} + +#' Internal function to handle conversion from upper snake case to camelCase +#' Primarily for reversing the action of [gbif_upper_case()] above +#' @noRd +#' @keywords internal +snake_to_camel_case <- function(string){ + # first split into words + split_string <- string |> + tolower() |> + strsplit("_") |> + purrr::pluck(!!!list(1)) + + # then amend only multi-word strings + word_count <- length(split_string) + if(word_count > 1){ + c(split_string[1], + stringr::str_to_title(split_string[seq(2, word_count)])) |> + glue::glue_collapse() + }else{ + split_string + } +} + ##--------------------------------------------------------------- ## Other helpful functions -- ##--------------------------------------------------------------- diff --git a/tests/testthat/test-international-GBIF.R b/tests/testthat/test-international-GBIF.R index 4b529f30..5632f548 100644 --- a/tests/testthat/test-international-GBIF.R +++ b/tests/testthat/test-international-GBIF.R @@ -167,10 +167,10 @@ test_that("`count()` works with `filter()` for GBIF", { count() |> collapse() expect_s3_class(x, "query") - expect_equal(length(x), 7) + expect_equal(length(x), 6) expect_equal(names(x), c("type", "url", "headers", "options", - "body", "slot_name", "expand")) + "body", "slot_name")) expect_equal(x$type, "data/occurrences-count") # compute y <- compute(x) @@ -191,10 +191,10 @@ test_that("`count` works with `identify` for GBIF", { count() |> collapse() expect_s3_class(x, "query") - expect_equal(length(x), 7) + expect_equal(length(x), 6) expect_equal(names(x), c("type", "url", "headers", "options", - "body", "slot_name", "expand")) + "body", "slot_name")) expect_equal(x$type, "data/occurrences-count") # compute y <- compute(x) @@ -209,17 +209,19 @@ test_that("`count` works with `identify` for GBIF", { test_that("`count` works with `group_by` for GBIF", { skip_if_offline(); skip_on_ci() x <- galah_call() |> - identify("Litoria") |> + # identify("Litoria") |> filter(year >= 2020) |> group_by(year) |> count() |> collapse() expect_s3_class(x, "query") - expect_equal(length(x), 4) + expect_equal(length(x), 6) expect_equal(names(x), c("type", "url", - "expand", - "headers")) + "headers", + "options", + "body", + "slot_name")) expect_equal(x$type, "data/occurrences-count-groupby") # compute y <- compute(x) @@ -230,6 +232,7 @@ test_that("`count` works with `group_by` for GBIF", { expect_gt(nrow(z), 1) expect_equal(names(z), c("year", "count")) # group_by fails when an invalid field is given + # NOTE: fails: no checks run at present expect_error({ galah_call() |> identify("Crinia") |> @@ -239,6 +242,42 @@ test_that("`count` works with `group_by` for GBIF", { }) }) +test_that("`count` works with 2 `group_by` args for GBIF", { + skip_if_offline(); skip_on_ci() + x <- galah_call() |> + filter(year >= 2020) |> + group_by(year, basisOfRecord) |> + count() |> + collapse() + expect_s3_class(x, "query") + expect_equal(length(x), 6) + expect_equal(names(x), c("type", + "url", + "headers", + "options", + "body", + "slot_name")) + expect_equal(x$type, "data/occurrences-count-groupby") + # compute + y <- compute(x) + expect_s3_class(y, "computed_query") + # collect + z <- collect(y) + expect_s3_class(z, c("tbl_df", "tbl", "data.frame")) + expect_gt(nrow(z), 1) + expect_equal(names(z), c("year", "count")) +}) + +## group_by fails when an invalid field is given +## NOTE: fails: no checks run at present +# expect_error({ +# galah_call() |> +# identify("Crinia") |> +# group_by(species) |> +# count() |> +# collect() +# }) + # FIXME: GBIF grouped counts only work for n = 1 - expand this or add warning # FIXME: `slice_head()` not tested for GBIF # FIXME: `check_fields()` not tested for GBIF - try sending invalid fields to `filter()` @@ -302,12 +341,15 @@ test_that("`count` works with `identify` for GBIF when `run_checks` = TRUE", { # collapse x <- request_data() |> identify("Litoria peronii") |> - filter(year >= 2020, basisOfRecord == "HUMAN_OBSERVATION") |> - collapse() # ERRORS HERE + filter(year >= 2020, + basisOfRecord == "HUMAN_OBSERVATION") |> + count() |> + collapse() expect_s3_class(x, "query") - expect_equal(length(x), 5) + expect_equal(length(x), 6) expect_equal(names(x), - c("type", "url", "slot_name", "expand", "headers")) + c("type", "url", "headers", + "options", "body", "slot_name")) expect_equal(x$type, "data/occurrences-count") # compute y <- compute(x) From 024dc772694ea4fa6b15686cd4c4cf6f5b54d217 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Tue, 12 Aug 2025 17:49:56 +1000 Subject: [PATCH 16/94] Clean up code as per R style guide - fix bug in atlas_counts() for atlases - continue switching from importFrom to pkg::function() syntax - minor systematic updates: lapply() to map(); rlang::abort() to cli::cli_abort() etc --- NAMESPACE | 16 -- R/arrange.R | 7 +- R/as_query-media.R | 4 +- R/as_query-occurrences_count.R | 3 +- R/atlas_media.R | 62 ++++---- R/collapse_occurrences_count_atlas.R | 14 +- R/collapse_query.R | 11 +- R/collect_media.R | 49 +++---- R/collect_metadata.R | 210 ++++++++++++--------------- R/collect_species_count.R | 17 +-- R/collect_taxa.R | 55 +++---- R/collect_unnest.R | 46 +++--- R/galah_group_by.R | 2 +- R/galah_identify.R | 10 +- R/handle_request_objects.R | 3 - R/search_all.R | 33 ++--- R/show_all.R | 8 +- data-raw/node_config.csv | 2 +- tests/testthat/test-galah_filter.R | 5 +- 19 files changed, 235 insertions(+), 322 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 2177ba6e..89e8d78b 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -109,35 +109,22 @@ export(show_values) export(slice_head) export(st_crop) export(unnest) -importFrom(cli,cli_div) -importFrom(cli,cli_end) -importFrom(cli,cli_text) importFrom(cli,col_magenta) importFrom(cli,col_yellow) importFrom(crayon,make_style) importFrom(crayon,silver) -importFrom(dplyr,all_of) -importFrom(dplyr,any_of) importFrom(dplyr,arrange) -importFrom(dplyr,bind_cols) importFrom(dplyr,bind_rows) -importFrom(dplyr,case_match) importFrom(dplyr,collapse) importFrom(dplyr,collect) importFrom(dplyr,compute) importFrom(dplyr,count) -importFrom(dplyr,desc) importFrom(dplyr,filter) importFrom(dplyr,group_by) -importFrom(dplyr,join_by) -importFrom(dplyr,last_col) importFrom(dplyr,mutate) -importFrom(dplyr,pull) importFrom(dplyr,relocate) importFrom(dplyr,rename) -importFrom(dplyr,right_join) importFrom(dplyr,select) -importFrom(dplyr,slice) importFrom(dplyr,slice_head) importFrom(glue,glue) importFrom(glue,glue_collapse) @@ -157,7 +144,6 @@ importFrom(purrr,pluck_depth) importFrom(rlang,.data) importFrom(rlang,abort) importFrom(rlang,as_label) -importFrom(rlang,as_name) importFrom(rlang,caller_env) importFrom(rlang,enquos) importFrom(rlang,format_error_bullets) @@ -181,8 +167,6 @@ importFrom(sf,st_is_empty) importFrom(sf,st_is_simple) importFrom(sf,st_is_valid) importFrom(stringr,str_detect) -importFrom(stringr,str_extract) -importFrom(stringr,str_remove) importFrom(stringr,str_replace) importFrom(stringr,str_to_title) importFrom(stringr,str_trim) diff --git a/R/arrange.R b/R/arrange.R index cb4fc34e..b5578b77 100644 --- a/R/arrange.R +++ b/R/arrange.R @@ -44,7 +44,6 @@ #' count() |> #' collect() #' } -#' @importFrom dplyr bind_cols #' @name arrange.data_request #' @export arrange.data_request <- function(.data, ...){ @@ -54,10 +53,10 @@ arrange.data_request <- function(.data, ...){ all(names(parsed_dots) %in% c("variable", "direction"))){ .data$arrange <- as.list(parsed_dots) |> as.data.frame() |> - tibble() + tibble::tibble() }else{ - .data$arrange <- tibble(variable = parsed_dots, - direction = "ascending") + .data$arrange <- tibble::tibble(variable = parsed_dots, + direction = "ascending") } return(.data) } diff --git a/R/as_query-media.R b/R/as_query-media.R index 7f27c167..0eb84029 100644 --- a/R/as_query-media.R +++ b/R/as_query-media.R @@ -106,11 +106,11 @@ build_media_id <- function(df){ build_file_path <- function(ids, types){ path <- potions::pour("package", "directory", .pkg = "galah") ext <- build_file_extension(types) - glue::glue("{path}/{ids}.{ext}") |> as.character() + glue::glue("{path}/{ids}.{ext}") |> + as.character() } #' get extensions for media files -#' @importFrom dplyr case_match #' @noRd #' @keywords Internal build_file_extension <- function(x){ diff --git a/R/as_query-occurrences_count.R b/R/as_query-occurrences_count.R index 11dfabde..4853a434 100644 --- a/R/as_query-occurrences_count.R +++ b/R/as_query-occurrences_count.R @@ -53,7 +53,8 @@ as_query_occurrences_count_atlas <- function(identify = NULL, slice <- tibble::tibble(slice_n = 1e4, slice_called = FALSE) } if(is.null(arrange)){ - arrange <- tibble::tibble(variable = "count", direction = "descending") + arrange <- tibble::tibble(variable = "count", + direction = "descending") } slice_arrange <- dplyr::bind_cols(slice, arrange) arrange_list <- check_slice_arrange(slice_arrange) diff --git a/R/atlas_media.R b/R/atlas_media.R index c5556fba..8b4670e5 100644 --- a/R/atlas_media.R +++ b/R/atlas_media.R @@ -1,16 +1,5 @@ #' @rdname atlas_ #' @order 4 -#' @importFrom dplyr any_of -#' @importFrom dplyr relocate -#' @importFrom dplyr right_join -#' @importFrom dplyr join_by -#' @importFrom glue glue -#' @importFrom httr2 url_build -#' @importFrom httr2 url_parse -#' @importFrom potions pour -#' @importFrom purrr pluck -#' @importFrom rlang abort -#' @importFrom stringr str_remove #' @export atlas_media <- function(request = NULL, identify = NULL, @@ -20,7 +9,8 @@ atlas_media <- function(request = NULL, data_profile = NULL ) { - atlas <- pour("atlas", "region", .pkg = "galah") + atlas <- potions::pour("atlas", "region", + .pkg = "galah") supported_atlases <- c("Australia", "Austria", # not currently working "Brazil", @@ -30,7 +20,7 @@ atlas_media <- function(request = NULL, "Spain", "United Kingdom") if(!(atlas %in% supported_atlases)){ - abort(glue("`atlas_media` is not supported for atlas = {atlas}")) + cli::cli_abort("`atlas_media` is not supported for atlas = {atlas}") } # capture supplied arguments @@ -57,10 +47,10 @@ atlas_media <- function(request = NULL, # now check whether valid fields are present selected_fields <- x$url |> - url_parse() |> - pluck("query", "fields") |> + httr2::url_parse() |> + purrr::pluck("query", "fields") |> strsplit(split = ",") |> - pluck(!!!list(1)) + purrr::pluck(!!!list(1)) # `multimedia` should be in `select`, but not `filter` image_select <- image_fields() @@ -68,10 +58,10 @@ atlas_media <- function(request = NULL, # abort if no fields are given to `select` if(!any(selected_fields %in% image_select)){ - selected_text <- paste(selected_fields, collapse = ", ") - bullets <- c("No media fields requested by `select()`", - i = glue("try `galah_select({selected_text}, group = 'media')` instead")) - abort(bullets) + selected_text <- glue::glue_collapse(selected_fields, sep = ", ") + c("No media fields requested by `select()`", + i = "try `galah_select({selected_text}, group = 'media')` instead") |> + cli::cli_abort() }else{ present_fields <- selected_fields[selected_fields %in% image_select] query_collapse <- x @@ -84,11 +74,11 @@ atlas_media <- function(request = NULL, media_fq <- parse_regional_media_filters(present_fields) # add back to source object if(length(media_fq) > 1){ - media_fq <- glue("({glue_collapse(media_fq, ' OR ')})") + media_fq <- glue::glue("({glue_collapse(media_fq, ' OR ')})") } - url <- url_parse(query_collapse$url) - url$query$fq <- paste0(url$query$fq, " AND ", media_fq) - query_collapse$url <- url_build(url) + url <- httr2::url_parse(query_collapse$url) + url$query$fq <- glue::glue("{url$query$fq} AND {media_fq}") + query_collapse$url <- httr2::url_build(url) } # get occurrences @@ -107,22 +97,20 @@ atlas_media <- function(request = NULL, # join and return if(any(colnames(occ) == "all_image_url")){ - occ <- rename(occ, "media_id" = "all_image_url") + occ <- dplyr::rename(occ, "media_id" = "all_image_url") } - occ_media <- right_join(occ, - media, - by = join_by("media_id" == "image_id")) - relocate(occ_media, "media_id", 1) + occ_media <- dplyr::right_join(occ, + media, + by = dplyr::join_by("media_id" == "image_id")) + dplyr::relocate(occ_media, "media_id", 1) } #' Set filters that work for media in each atlas -#' @importFrom glue glue -#' @importFrom stringr str_remove #' @noRd #' @keywords Internal parse_regional_media_filters <- function(present_fields){ - atlas <- pour("atlas", "region") + atlas <- potions::pour("atlas", "region") switch(atlas, "Austria" = "(all_image_url:*)", "Australia" = glue("({present_fields}:*)"), @@ -131,14 +119,14 @@ parse_regional_media_filters <- function(present_fields){ "Kew" = "(all_image_url:*)", "Portugal" = "(all_image_url:*)", "Spain" = {filter_fields <- present_fields |> - str_remove("s$") |> + stringr::str_remove("s$") |> paste0("IDsCount") - glue("{filter_fields}:[1 TO *]")}, + glue::glue("{filter_fields}:[1 TO *]")}, "Sweden" = {filter_fields <- present_fields |> - str_remove("s$") |> + stringr::str_remove("s$") |> paste0("IDsCount") - glue("{filter_fields}:[1 TO *]")}, + glue::glue("{filter_fields}:[1 TO *]")}, "United Kingdom" = "(all_image_url:*)", # !is.na(all_image_url), - abort(glue("`atlas_media` is not supported for atlas = {atlas}")) + cli::cli_abort("`atlas_media` is not supported for atlas = {atlas}") ) } \ No newline at end of file diff --git a/R/collapse_occurrences_count_atlas.R b/R/collapse_occurrences_count_atlas.R index ceea8bd0..83dfa575 100644 --- a/R/collapse_occurrences_count_atlas.R +++ b/R/collapse_occurrences_count_atlas.R @@ -1,7 +1,7 @@ #' Internal function to handle facet counting, adjustment etc. #' @noRd #' @keywords Internal -collapse_occurrences_count_atlas <- function(.query){ +collapse_occurrences_count_atlas_basic <- function(.query){ url <- httr2::url_parse(.query$url) # check if a limit has been set @@ -22,7 +22,7 @@ collapse_occurrences_count_atlas <- function(.query){ # message when limit is hit }else{ if(as.integer(url$query$flimit) < n_facets){ - limit <- url$query$flimit |> + limit <- url$query$flimit |> prettyNum(big.mark = ",", preserve.width = "none") n_total_facets <- n_facets |> @@ -46,8 +46,8 @@ collapse_occurrences_count_atlas <- function(.query){ #' Determine set of queries when group_by() is set #' @noRd #' @keywords Internal -collapse_occurrences_count_atlas_groupby <- function(.query, - error_call = caller_env()){ +collapse_occurrences_count_atlas_groupby_crossed <- function(.query, + error_call = caller_env()){ data_cached <- .query # get url url <- httr2::url_parse(.query$url) @@ -91,10 +91,11 @@ collapse_occurrences_count_atlas_groupby <- function(.query, # expand to a tibble that gives all combinations kept_facets <- facet_names[-length(facet_names)] result_list <- purrr::map(names(result), - function(a){ + \(a){ x <- result[[a]][c(1, 4)] names(x)[1] <- a - x}) + x}) + # browser() # convert to all combinations of levels if(length(result_list) > 1){ @@ -109,6 +110,7 @@ collapse_occurrences_count_atlas_groupby <- function(.query, by = kept_facets[i]) } }else{ + # FIXME: This fails if `result_list` is empty result_df <- result_list[[1]] } diff --git a/R/collapse_query.R b/R/collapse_query.R index 576b76b6..f28636ec 100644 --- a/R/collapse_query.R +++ b/R/collapse_query.R @@ -9,7 +9,7 @@ collapse_query <- function(x){ if(is_gbif()){ collapse_occurrences_count_gbif(x) }else{ - collapse_occurrences_count_atlas(x) + collapse_occurrences_count_atlas_basic(x) } }, "data/occurrences-count-groupby" = { @@ -20,7 +20,14 @@ collapse_query <- function(x){ collapse_occurrences_count_gbif_groupby_basic(x) } }else{ - collapse_occurrences_count_atlas_groupby(x) + query_names <- httr2::url_parse(x$url) |> + purrr::pluck("query") |> + names() + if(length(which(query_names == "facets")) > 1){ + collapse_occurrences_count_atlas_groupby_crossed(x) + }else{ + collapse_occurrences_count_atlas_basic(x) + } } }, "data/species" = collapse_occurrences(x), # optimised for GBIF diff --git a/R/collect_media.R b/R/collect_media.R index 699f78f2..78dd8531 100644 --- a/R/collect_media.R +++ b/R/collect_media.R @@ -1,48 +1,40 @@ #' Internal version of `collect()` for `request_data(type = "media")` #' @param object of class `data_response`, from `compute()` -#' @importFrom dplyr any_of -#' @importFrom dplyr filter -#' @importFrom dplyr mutate -#' @importFrom dplyr select -#' @importFrom purrr pluck -#' @importFrom rlang abort #' @noRd #' @keywords Internal collect_media_metadata <- function(.query){ result <- query_API(.query) |> - pluck("results") |> - bind_rows() + purrr::pluck("results") |> + dplyr::bind_rows() if(nrow(result) < 1){ # case where no data returned - if(pour("package", "verbose")){ - warn("No data returned from `metadata/media` API") + if(potions::pour("package", "verbose")){ + cli::cli_warn("No data returned from `metadata/media` API") } ids <- .query$body |> - fromJSON() |> + jsonlite::fromJSON() |> unlist() - result <- tibble(image_id = ids) + result <- tibble::tibble(image_id = ids) }else{ colnames(result) <- rename_columns(names(result), type = "media") } # Select only the rows and columns we want result |> - filter(!is.na(result$image_id)) |> - select(any_of(wanted_columns("media"))) + dplyr::filter(!is.na(result$image_id)) |> + dplyr::select(dplyr::any_of(wanted_columns("media"))) } #' Internal version of `collect()` for `request_files(type = "media")` #' @param object of class `files_response`, from `compute()` -#' @importFrom dplyr group_by -#' @importFrom dplyr filter -#' @importFrom dplyr count #' @importFrom rlang .data #' @noRd #' @keywords Internal collect_media_files <- function(.query){ result <- query_API(.query) - result_summary <- tibble( - status_code = unlist(lapply(result, function(a){a$status_code}))) |> - group_by(.data$status_code) |> - count() + result_summary <- tibble::tibble( + status_code = purrr::map(result,\(a){a$status_code})) |> + unlist() |> + dplyr::group_by(.data$status_code) |> + dplyr::count() # successful downloads success <- result_summary |> @@ -58,16 +50,16 @@ collect_media_files <- function(.query){ n_failed <- fail[["n"]] } } - user_directory <- pour("package", "directory") + user_directory <- potions::pour("package", "directory") bullets <- c( - "v" = glue("Downloaded {n_downloaded} files successfully (status 200)."), - ">" = glue("Files saved in local directory: \"{user_directory}\".") + "v" = "Downloaded {n_downloaded} files successfully (status 200).", + ">" = "Files saved in local directory: \"{user_directory}\"." ) if(!is.null(fail)) { bullets <- c(bullets, - "x" = glue("Failed {n_failed} downloads due to missing images (status 403)")) + "x" = "Failed {n_failed} downloads due to missing images (status 403)") } - inform(bullets) + cli::cli_inform(bullets) invisible(result_summary) } @@ -120,9 +112,6 @@ collect_media_files <- function(.query){ #' collect(thumbnail = TRUE) #' # step three is synonymous with `collect_media()` #' } -#' @importFrom cli col_magenta -#' @importFrom cli cli_text -#' @importFrom rlang .data #' @export collect_media <- function(df, thumbnail = FALSE, @@ -137,7 +126,7 @@ collect_media <- function(df, } # suggest option to set directory in galah_config() - user_directory <- pour("package", "directory") + user_directory <- potions::pour("package", "directory") if (stringr::str_detect(user_directory, "Temp")) { inform( cli::cli_text("{cli::col_magenta('To change which file directory media files are saved to, use `galah_config(directory = )`.')}") diff --git a/R/collect_metadata.R b/R/collect_metadata.R index 6648697d..f320b35c 100644 --- a/R/collect_metadata.R +++ b/R/collect_metadata.R @@ -11,7 +11,6 @@ collect_apis <- function(.query){ } #' Internal function to `collect()` assertions -#' @importFrom dplyr bind_rows #' @noRd #' @keywords Internal collect_assertions <- function(.query){ @@ -20,11 +19,11 @@ collect_assertions <- function(.query){ parse(text = _) |> eval() attr(result, "call") <- "assertions" # needed for `show_values()` to work - attr(result, "region") <- pour("atlas", "region") # needed for caching to work + attr(result, "region") <- potions::pour("atlas", "region") # needed for caching to work }else{ - result <- lapply(query_API(.query), - function(a){a[names(a) != "termsRequiredToTest"]}) |> - bind_rows() + result <- purrr::map(query_API(.query), + \(a){a[names(a) != "termsRequiredToTest"]}) |> + dplyr::bind_rows() names(result) <- rename_columns(names(result), type = "assertions") result <- result[wanted_columns("assertions")] result$type <- "assertions" @@ -43,15 +42,11 @@ collect_atlases <- function(.query){ parse(text = _) |> eval() attr(result, "call") <- "atlases" - attr(result, "region") <- pour("atlas", "region") + attr(result, "region") <- potions::pour("atlas", "region") result } #' Internal function to `collect()` collections -#' @importFrom dplyr bind_rows -#' @importFrom dplyr relocate -#' @importFrom dplyr rename -#' @importFrom purrr pluck #' @noRd #' @keywords Internal collect_collections <- function(.query){ @@ -59,23 +54,24 @@ collect_collections <- function(.query){ result <- query_API(.query) if(any(names(result) == "results")){ # happens when `filter()` not specified # Note: This assumes only one API call; will need more potentially - result <- pluck(result, "results") + result <- purrr::pluck(result, "results") } result <- flat_lists_only(result) |> - bind_rows() + dplyr::bind_rows() }else if(pour("atlas", "region", .pkg = "galah") == "France"){ result <- query_API(.query) |> - pluck("_embedded", "producers") |> + purrr::pluck("_embedded", "producers") |> unlist() - result <- tibble(name = result) + result <- tibble::tibble(name = result) }else{ result <- query_API(.query) |> - bind_rows() - result_reordered <- relocate(result, "uid") - result <- result_reordered |> rename("id" = "uid") + dplyr::bind_rows() + result_reordered <- dplyr::relocate(result, "uid") + result <- result_reordered |> + dplyr::rename("id" = "uid") } attr(result, "call") <- "collections" - attr(result, "region") <- pour("atlas", "region") + attr(result, "region") <- potions::pour("atlas", "region") result } @@ -84,16 +80,16 @@ collect_collections <- function(.query){ #' @noRd #' @keywords Internal flat_lists_only <- function(x){ - lapply(x, - function(a){ - lapply(a, function(b){ - if(is.list(b)){ - NULL - }else{ - b - } - }) - }) + purrr::map(x, + \(a){ + purrr::map(a, \(b){ + if(is.list(b)){ + NULL + }else{ + b + } + }) + }) } #' Internal function to `collect()` datasets @@ -107,63 +103,57 @@ collect_datasets <- function(.query){ if(is_gbif()){ if(any(names(result) == "results")){ # happens when `filter()` not specified # Note: This assumes only one API call; will need more potentially - result <- pluck(result, "results") + result <- purrr::pluck(result, "results") } result <- result |> flat_lists_only() |> - bind_rows() - }else if(pour("atlas", "region", .pkg = "galah") == "France"){ + dplyr::bind_rows() + }else if(potions::pour("atlas", "region", .pkg = "galah") == "France"){ result <- result |> - pluck("_embedded", "datasets") |> - bind_rows() + purrr::pluck("_embedded", "datasets") |> + dplyr::bind_rows() }else{ result <- result |> - bind_rows() |> - relocate("uid") |> - rename("id" = "uid") + dplyr::bind_rows() |> + dplyr::relocate("uid") |> + dplyr::rename("id" = "uid") } attr(result, "call") <- "datasets" - attr(result, "region") <- pour("atlas", "region") + attr(result, "region") <- potions::pour("atlas", "region") result } #' Internal function to `collect()` distributions -#' @importFrom dplyr any_of -#' @importFrom dplyr rename -#' @importFrom dplyr select #' @noRd #' @keywords Internal collect_distributions_metadata <- function(.query){ result <- query_API(.query) result <- result |> - bind_rows() |> - select("spcode", - "family", - "genus_name", - "scientific", - "common_nam", - "lsid", - "area_name", - "area_km", - "data_resource_uid") |> - rename( + dplyr::bind_rows() |> + dplyr::select( + "spcode", + "family", + "genus_name", + "scientific", + "common_nam", + "lsid", + "area_name", + "area_km", + "data_resource_uid") |> + dplyr::rename( "id" = "spcode", # this is chosen as ID because it is called by later APIs "genus" = "genus_name", "species" = "scientific", "taxon_concept_id" = "lsid", "label" = "area_name", "common_name" = "common_nam") |> - mutate("common_name" = trimws(.data$common_name)) # remove leading or trailing spaces + dplyr::mutate("common_name" = trimws(.data$common_name)) # remove leading or trailing spaces attr(result, "call") <- "distributions" - attr(result, "region") <- pour("atlas", "region") + attr(result, "region") <- potions::pour("atlas", "region") result } #' Internal function to `collect()` fields -#' @importFrom dplyr all_of -#' @importFrom dplyr bind_rows -#' @importFrom dplyr mutate -#' @importFrom dplyr select #' @noRd #' @keywords Internal collect_fields <- function(.query){ @@ -172,25 +162,26 @@ collect_fields <- function(.query){ parse(text = _) |> eval() attr(result, "call") <- "fields" - attr(result, "region") <- pour("atlas", "region") + attr(result, "region") <- potions::pour("atlas", "region") result }else{ if(!is.null(.query$url)){ # i.e. there is no cached `tibble` result <- query_API(.query) |> - bind_rows() + dplyr::bind_rows() # if there is a 'stored' field, use it to filter results if(any(colnames(result) == "stored")){ - result <- result |> dplyr::filter(.data$stored == TRUE) + result <- result |> + dplyr::filter(.data$stored == TRUE) } # now mutate to required format result <- result |> - mutate(id = result$name) |> - select(all_of(wanted_columns("fields"))) |> - mutate(type = "fields") |> - bind_rows(galah_internal_archived$media, - galah_internal_archived$other) + dplyr::mutate(id = result$name) |> + dplyr::select(dplyr::all_of(wanted_columns("fields"))) |> + dplyr::mutate(type = "fields") |> + dplyr::bind_rows(galah_internal_archived$media, + galah_internal_archived$other) attr(result, "call") <- "fields" - attr(result, "region") <- pour("atlas", "region") + attr(result, "region") <- potions::pour("atlas", "region") check_internal_cache(fields = result) result }else{ # this should only happen when `data` slot is present in place of `url` @@ -200,76 +191,64 @@ collect_fields <- function(.query){ } #' Internal function to `collect()` licences -#' @importFrom dplyr all_of -#' @importFrom dplyr arrange -#' @importFrom dplyr bind_rows -#' @importFrom dplyr select #' @noRd #' @keywords Internal collect_licences <- function(.query){ result <- query_API(.query) if(length(result) > 0){ if (any(duplicated(names(result[[1]])))) { # remove duplicate columns (i.e. Spain atlas) - result <- lapply(result, function(x) x[unique(names(x))]) + result <- purrr::map(result, \(x) x[unique(names(x))]) } result <- result |> - bind_rows() - result <- result |> - select(all_of(c("id", "name", "acronym", "url"))) |> - arrange(result$id) + dplyr::bind_rows() |> + dplyr::select(dplyr::all_of(c("id", "name", "acronym", "url"))) |> + dplyr::arrange(result$id) }else{ - result <- tibble(id = character(), - name = character(), - acronym = character(), - url = character()) + result <- tibble::tibble(id = character(), + name = character(), + acronym = character(), + url = character()) } attr(result, "call") <- "licences" - attr(result, "region") <- pour("atlas", "region") + attr(result, "region") <- potions::pour("atlas", "region") result } #' Internal function to `collect()` lists -#' @importFrom dplyr bind_rows -#' @importFrom purrr pluck #' @noRd #' @keywords Internal collect_lists <- function(.query){ if(inherits(.query$url, "data.frame")){ - result <- lapply(query_API(.query), - function(a){a$lists}) |> - bind_rows() + result <- purrr:map(query_API(.query), + \(a){a$lists}) |> + dplyr::bind_rows() }else{ result <- query_API(.query) |> - pluck("lists") |> - bind_rows() + purrr::pluck("lists") |> + dplyr::bind_rows() } if(any(colnames(result) == "dataResourceUid")){ result <- result |> - rename("species_list_uid" = "dataResourceUid") + dplyr::rename("species_list_uid" = "dataResourceUid") } attr(result, "call") <- "lists" - attr(result, "region") <- pour("atlas", "region") + attr(result, "region") <- potions::pour("atlas", "region") result } #' Internal function to `collect()` profiles -#' @importFrom dplyr all_of -#' @importFrom dplyr arrange -#' @importFrom dplyr bind_rows -#' @importFrom dplyr filter -#' @importFrom dplyr select #' @noRd #' @keywords Internal collect_profiles <- function(.query){ if(!is.null(.query$url)){ result <- query_API(.query) |> - bind_rows() + dplyr::bind_rows() result <- result |> - filter(!duplicated(result$id)) |> - arrange("id") |> - select(all_of(wanted_columns(type = "profile"))) + dplyr::filter(!duplicated(result$id)) |> + dplyr::arrange("id") |> + dplyr::select(dplyr::all_of(wanted_columns(type = "profile"))) attr(result, "call") <- "profiles" - attr(result, "region") <- pour("atlas", "region") + attr(result, "region") <- potions::pour("atlas", "region") check_internal_cache(show_all_profiles = result) result }else{ @@ -287,27 +266,27 @@ collect_providers <- function(.query){ if(is_gbif()){ if(any(names(result) == "results")){ # happens when `filter()` not specified # Note: This assumes only one API call; will need more potentially - result <- pluck(result, "results") + result <- purrr::pluck(result, "results") } result <- result |> flat_lists_only() |> - bind_rows() - }else if(pour("atlas", "region", .pkg = "galah") == "France"){ - result <- tibble(name = { - pluck(result, "_embedded", "providers") |> + dplyr::bind_rows() + }else if(potions::pour("atlas", "region", .pkg = "galah") == "France"){ + result <- tibble::tibble(name = { + purrr::pluck(result, "_embedded", "providers") |> unlist() }) }else{ result <- result |> - bind_rows() + dplyr::bind_rows() if(nrow(result) > 0){ # exception added because this API isn't always populated (e.g. France) result <- result |> - relocate("uid") |> - rename("id" = "uid") + dplyr::relocate("uid") |> + dplyr::rename("id" = "uid") } } attr(result, "call") <- "providers" - attr(result, "region") <- pour("atlas", "region") + attr(result, "region") <- potions::pour("atlas", "region") result } @@ -319,28 +298,23 @@ collect_ranks <- function(.query){ parse(text = _) |> eval() attr(result, "call") <- "ranks" - attr(result, "region") <- pour("atlas", "region") + attr(result, "region") <- potions::pour("atlas", "region") result } #' Internal function to `collect()` reasons -#' @importFrom dplyr all_of -#' @importFrom dplyr arrange -#' @importFrom dplyr bind_rows -#' @importFrom dplyr filter -#' @importFrom dplyr select #' @noRd #' @keywords Internal collect_reasons <- function(.query){ if(!is.null(.query$url)){ result <- query_API(.query) |> - bind_rows() + dplyr::bind_rows() result <- result |> - filter(!result$deprecated) |> - select(all_of(wanted_columns("reasons"))) |> + dplyr::filter(!result$deprecated) |> + dplyr::select(dplyr::all_of(wanted_columns("reasons"))) |> arrange("id") attr(result, "call") <- "reasons" - attr(result, "region") <- pour("atlas", "region") + attr(result, "region") <- potions::pour("atlas", "region") check_internal_cache(reasons = result) result }else{ diff --git a/R/collect_species_count.R b/R/collect_species_count.R index 4badc615..46a5d5d3 100644 --- a/R/collect_species_count.R +++ b/R/collect_species_count.R @@ -1,24 +1,23 @@ #' Internal function to collect counts for species -#' @importFrom dplyr bind_cols -#' @importFrom dplyr select -#' @importFrom tibble tibble #' @noRd #' @keywords Internal collect_species_count <- function(.query){ result <- query_API(.query) - counts <- lapply(result, extract_species_count) |> unlist() + counts <- purrr::map(result, extract_species_count) |> + unlist() if(nrow(.query$url) > 1){ - select(.query$url, -url) |> - bind_cols(tibble(count = counts)) + .query |> + purrr::pluck("url") |> + dplyr::select(-url) |> + dplyr::bind_cols(tibble::tibble(count = counts)) }else{ - tibble(count = counts) + tibble::tibble(count = counts) } } #' Internal function to extract counts for species -#' @importFrom purrr pluck #' @noRd #' @keywords Internal extract_species_count <- function(x){ - pluck(x, !!!list(1, "count")) + purrr::pluck(x, !!!list(1, "count")) } diff --git a/R/collect_taxa.R b/R/collect_taxa.R index ef501476..9ce67714 100644 --- a/R/collect_taxa.R +++ b/R/collect_taxa.R @@ -1,8 +1,4 @@ #' Internal function to `collect()` taxa -#' @importFrom dplyr any_of -#' @importFrom dplyr bind_rows -#' @importFrom dplyr select -#' @importFrom tibble as_tibble #' @noRd #' @keywords Internal collect_taxa <- function(.query){ @@ -22,9 +18,9 @@ collect_taxa <- function(.query){ #' @keywords Internal collect_taxa_namematching <- function(.query){ search_terms <- .query$url$search_term - result <- lapply(query_API(.query), - build_tibble_from_nested_list) |> - bind_rows() + result <- purrr::map(query_API(.query), + build_tibble_from_nested_list) |> + dplyr::bind_rows() # break pipe for use case where all search terms are dubious (i.e. no taxonConceptID) # if(any(colnames(result) == "taxonConceptID")){ # NOTE: This code was meant to remove duplicates, but also removes all rows with NAs (which we don't want) @@ -36,15 +32,16 @@ collect_taxa_namematching <- function(.query){ issues <- unlist(result$issues) if(length(issues) > 1) { - issues_c <- paste(issues, collapse = ", ") + issues_c <- glue::glue_collapse(issues, sep = ", ") } else { issues_c <- issues } # add issues to result result <- result |> - mutate("search_term" = search_terms, .before = "success", - issues = issues_c) + dplyr::mutate("search_term" = search_terms, + .before = "success", + issues = issues_c) # Check for homonyms if(any(colnames(result) == "issues")){ @@ -57,40 +54,31 @@ collect_taxa_namematching <- function(.query){ } names(result) <- rename_columns(names(result), type = "taxa") # old code - result |> select(any_of(wanted_columns("taxa"))) + result |> dplyr::select(dplyr::any_of(wanted_columns("taxa"))) } #' Internal function to `collect()` taxa for other living atlases -#' @importFrom dplyr any_of -#' @importFrom dplyr bind_rows -#' @importFrom dplyr filter -#' @importFrom dplyr mutate -#' @importFrom dplyr select #' @noRd #' @keywords Internal collect_taxa_la <- function(.query){ search_terms <- .query$url$search_term result <- query_API(.query) |> clean_la_taxa(search_terms = search_terms) |> - bind_rows() + dplyr::bind_rows() if(ncol(result) > 1){ - name <- switch(pour("atlas", "region"), + name <- switch(potions::pour("atlas", "region"), "France" = "referenceID", "Portugal" = "usageKey", "guid") result <- result |> - filter(!duplicated({{name}})) |> - mutate("search_term" = search_terms) + dplyr::filter(!duplicated({{name}})) |> + dplyr::mutate("search_term" = search_terms) } names(result) <- rename_columns(names(result), type = "taxa") # old code - result |> select(any_of(wanted_columns("taxa"))) + result |> dplyr::select(dplyr::any_of(wanted_columns("taxa"))) } #' Internal function to `collect()` taxa for GBIF -#' @importFrom dplyr any_of -#' @importFrom dplyr bind_rows -#' @importFrom dplyr mutate -#' @importFrom dplyr select #' @noRd #' @keywords Internal collect_taxa_gbif <- function(.query){ @@ -98,7 +86,8 @@ collect_taxa_gbif <- function(.query){ result <- query_API(.query) |> clean_gbif_taxa() |> dplyr::bind_rows() |> - mutate("search_term" = search_terms, .before = 1) + dplyr::mutate("search_term" = search_terms, + .before = 1) names(result) <- rename_columns(names(result), type = "taxa") # old code result |> dplyr::select(dplyr::any_of(wanted_columns("taxa"))) @@ -135,7 +124,7 @@ clean_la_taxa <- function(result, search_terms){ # capture results if("_embedded" %in% names(a)) { # e.g. France list_of_results <- a |> - pluck("_embedded", "taxa") + purrr::pluck("_embedded", "taxa") } else { if("searchResults" %in% names(a)) { list_of_results <- a |> @@ -218,20 +207,16 @@ collect_identifiers <- function(.query){ result <- result |> dplyr::select(dplyr::any_of(wanted_columns("taxa"))) attr(result, "call") <- "identifiers" - attr(result, "region") <- pour("atlas", "region") + attr(result, "region") <- potions::pour("atlas", "region") result } #' Internal function to check search terms provided to `search_taxa()` -#' @importFrom glue glue_collapse -#' @importFrom cli cli_div -#' @importFrom cli cli_text -#' @importFrom cli cli_end #' @noRd #' @keywords Internal check_search_terms <- function(result, atlas) { if (!all(result$success)) { - atlas <- pour("atlas", "region") + atlas <- potions::pour("atlas", "region") d <- cli::cli_div(theme = list(span.bold = list("font-weight" = "bold"), span.yellow = list(color = "yellow"))) @@ -263,7 +248,7 @@ check_search_terms <- function(result, atlas) { ) } - inform(bullets) + cli::cli_inform(bullets) cli::cli_end(d) } } @@ -283,6 +268,6 @@ check_homonyms <- function(result) { i = "Use a `tibble` to clarify taxa, see `?search_taxa`.", x = glue("Homonym issue with \"{list_homonym_taxa}\".") ) - rlang::warn(bullets) + cli::cli_warn(bullets) } } diff --git a/R/collect_unnest.R b/R/collect_unnest.R index b9374384..28624674 100644 --- a/R/collect_unnest.R +++ b/R/collect_unnest.R @@ -1,50 +1,44 @@ #' Internal function to run `compute()` for #' `request_metadata(type = "fields") |> unnest()` -#' @importFrom httr2 url_parse -#' @importFrom dplyr bind_rows -#' @importFrom dplyr mutate -#' @importFrom dplyr select -#' @importFrom purrr pluck -#' @importFrom stringr str_extract #' @noRd #' @keywords Internal collect_fields_unnest <- function(.query, error_call = caller_env()){ if(is_gbif()){ facet <- .query |> - pluck("url") |> - url_parse() |> - pluck("query", "facet") + purrr::pluck("url") |> + httr2::url_parse() |> + purrr::pluck("query", "facet") if (facet == "NA") { - abort("No `field` passed to `show_values()`/`search_values()`.") + cli::cli_abort("No `field` passed to `show_values()`/`search_values()`.") } result <- .query |> query_API() |> - pluck(!!!list("facets", 1, "counts")) |> - bind_rows() + purrr::pluck(!!!list("facets", 1, "counts")) |> + dplyr::bind_rows() colnames(result)[which(colnames(result) == "name")[1]] <- facet select(result, {{facet}}) }else{ facet <- .query |> - pluck("url") |> - url_parse() |> - pluck("query", "facets") + purrr::pluck("url") |> + httr2::url_parse() |> + purrr::pluck("query", "facets") if (facet == "NA") { - abort("No `field` passed to `show_values()`/`search_values()`.") + cli::cli_abort("No `field` passed to `show_values()`/`search_values()`.") } result <- .query |> query_API() |> - pluck(!!!list(1, "fieldResult")) |> - bind_rows() + purrr::pluck(!!!list(1, "fieldResult")) |> + dplyr::bind_rows() # extract unformatted facet values if(nrow(result) > 0){ result <- result |> - mutate( + dplyr::mutate( field_value = stringr::str_extract( result$i18nCode, "(?<=\\.).*" # everything after . @@ -52,7 +46,7 @@ collect_fields_unnest <- function(.query, error_call = caller_env()){ ) colnames(result)[which(colnames(result) == "field_value")[1]] <- facet - select(result, {{facet}}) + dplyr::select(result, {{facet}}) }else{ # i.e. catch empty results result } @@ -65,7 +59,7 @@ collect_fields_unnest <- function(.query, error_call = caller_env()){ #' @keywords Internal collect_lists_unnest <- function(.query){ result <- query_API(.query) |> - bind_rows() + dplyr::bind_rows() # extract additional raw fields columns if (any(colnames(result) %in% "kvpValues")) { @@ -85,11 +79,11 @@ collect_lists_unnest <- function(.query){ #' @keywords Internal collect_profiles_unnest <- function(.query){ result <- query_API(.query) |> - pluck("categories") |> - bind_rows() + purrr::pluck("categories") |> + dplyr::bind_rows() result <- result |> - pull("qualityFilters") |> - bind_rows() + dplyr::pull("qualityFilters") |> + dplyr::bind_rows() result } @@ -99,5 +93,5 @@ collect_profiles_unnest <- function(.query){ #' @keywords Internal collect_taxa_unnest <- function(.query){ query_API(.query) |> - bind_rows() + dplyr::bind_rows() } diff --git a/R/galah_group_by.R b/R/galah_group_by.R index 232779c6..b3311d32 100644 --- a/R/galah_group_by.R +++ b/R/galah_group_by.R @@ -65,7 +65,7 @@ parse_group_by <- function(dot_names){ c( "Too many fields supplied.", i = "`group_by.data_request` accepts a maximum of 3 fields.") |> - cli::cli_abort(call = caller_env()) + cli::cli_abort(call = rlang::caller_env()) } if(length(dot_names) > 0){ names(dot_names) <- NULL # needed to avoid empty strings added as names diff --git a/R/galah_identify.R b/R/galah_identify.R index fa43fcf4..d07a4229 100644 --- a/R/galah_identify.R +++ b/R/galah_identify.R @@ -23,8 +23,6 @@ #' other ways to filter a query. You can also use [search_taxa()] to check that #' supplied names are being matched correctly on the server-side; see #' [taxonomic_searches] for a detailed overview. -#' @importFrom dplyr rename -#' @importFrom dplyr select #' @examples \dontrun{ #' # Use `galah_identify()` to narrow your queries #' galah_call() |> @@ -47,14 +45,14 @@ identify.data_request <- function(x, ...){ dots_initial <- list(...) if (length(dots_initial) < 1) { - warn("No query passed to `identify()`.") + cli::cli_warn("No query passed to `identify()`.") result <- NULL }else{ if(inherits(dots_initial[[1]], "data.frame") & length(dots_initial) == 1){ result <- dots_initial[[1]] }else{ - result <- tibble("search_term" = unlist(dots_initial)) + result <- tibble::tibble("search_term" = unlist(dots_initial)) } } update_data_request(x, identify = result) @@ -81,8 +79,8 @@ identify.metadata_request <- function(x, ...){ galah_identify <- function(..., search = NULL) { dots_initial <- list(...) if (length(dots_initial) < 1) { - warn("No query passed to `identify()`.") - tibble("search_term" = character()) + cli::cli_warn("No query passed to `identify()`.") + tibble::tibble("search_term" = character()) }else{ dots_initial <- check_search_arg(dots_initial, search) if(inherits(dots_initial[[1]], "data_request")){ diff --git a/R/handle_request_objects.R b/R/handle_request_objects.R index b454c62f..32e10b41 100644 --- a/R/handle_request_objects.R +++ b/R/handle_request_objects.R @@ -103,9 +103,6 @@ update_select <- function(x, y){ } #' Internal function to join tibbles by row -#' @importFrom dplyr bind_rows -#' @importFrom dplyr filter -#' @importFrom tibble tibble #' @noRd #' @keywords Internal bind_unique_rows <- function(x, y, column){ diff --git a/R/search_all.R b/R/search_all.R index f47e4470..b8926652 100644 --- a/R/search_all.R +++ b/R/search_all.R @@ -86,8 +86,6 @@ #' collect() |> #' dplyr::filter(grepl("date", id)) #' } -#' @importFrom rlang as_name -#' @importFrom rlang .data #' @export search_all <- function(type, query){ @@ -114,7 +112,7 @@ search_all <- function(type, query){ }else{ type <- parse_quosures_basic(enquos(type)) if(!inherits(type, "character") | length(type) > 1){ - abort("`type` must be a length-1 vector of class 'character'") + cli::cli_abort("`type` must be a length-1 vector of class 'character'") } check_type_valid(type, valid_types) } @@ -145,8 +143,6 @@ search_all <- function(type, query){ } #' Internal function to run a query over a tibble -#' @importFrom dplyr filter -#' @importFrom purrr list_transpose #' @noRd #' @keywords Internal search_text_cols <- function(df, query){ @@ -154,10 +150,12 @@ search_text_cols <- function(df, query){ query <- tolower(query) keep_cols <- unlist(lapply(df, is.character)) & colnames(df) != "type" - check_list <- lapply(df[, keep_cols], - function(a){grepl(query, tolower(a))}) - check_vector <- lapply(list_transpose(check_list), any) |> unlist() - result <- df |> filter({{check_vector}}) + check_list <- purrr::map(df[, keep_cols], + \(a){grepl(query, tolower(a))}) + check_vector <- purrr::list_transpose(check_list) |> + purrr::map(any) |> + unlist() + result <- df |> dplyr::filter({{check_vector}}) # order search_all() results if ("id" %in% colnames(result)) { @@ -172,16 +170,14 @@ search_text_cols <- function(df, query){ #' Internal function to check for missingness #' @noRd #' @keywords Internal -#' @importFrom rlang abort check_if_missing <- function(query, parent_function, - error_call = caller_env()) { + error_call = rlang::caller_env()) { if (missing(query)) { - bullets <- c( + c( "We didn't detect a search query.", - i = "Try entering a string to search for matching values." - ) - abort(bullets, call = error_call) + i = "Try entering a string to search for matching values.") |> + cli::cli_abort(call = error_call) } } @@ -193,11 +189,10 @@ check_if_in_pipe <- function(..., error_call = caller_env()) { dots <- list(...) col_type_present <- grepl("type", names(unlist(dots))) if (any(col_type_present)) { - bullets <- c( + c( "Can't pipe `search_taxa()` in a `galah_call()`.", - i = "Did you mean to use `galah_identify()`?" - ) - abort(bullets, call = error_call) + i = "Did you mean to use `galah_identify()`?") |> + cli::cli_abort(bullets, call = error_call) } } diff --git a/R/show_all.R b/R/show_all.R index 8cc1e4a8..27faa4d4 100644 --- a/R/show_all.R +++ b/R/show_all.R @@ -59,16 +59,18 @@ #' request_metadata(type = "fields") |> #' collect() #' } -#' @importFrom rlang as_label #' @export show_all <- function(..., limit = NULL){ dots <- enquos(..., .ignore_empty = "all") if(length(dots) < 1){ type_text <- "fields" }else{ - type_text <- gsub("\"", "", as_label(dots[[1]])) # handle case where type is quoted + type_text <- gsub("\"", + "", + rlang::as_label(dots[[1]])) # handle case where type is quoted } - show_all_generic(type = type_text, limit = limit) + show_all_generic(type = type_text, + limit = limit) } #' Internal function to handle `show_all` calls diff --git a/data-raw/node_config.csv b/data-raw/node_config.csv index e8caf499..58623fe8 100644 --- a/data-raw/node_config.csv +++ b/data-raw/node_config.csv @@ -117,7 +117,7 @@ Global,data/occurrences-count-groupby,https://api.gbif.org/v1/occurrence/search/ Global,metadata/collections,https://api.gbif.org/v1/grscicoll/collection,TRUE Global,metadata/datasets,https://api.gbif.org/v1/dataset,TRUE Global,metadata/fields,https://api.gbif.org/v1/occurrence/term,TRUE -Global,metadata/fields-unnest,https://api.gbif.org/v1/occurrence/download/request,TRUE +Global,metadata/fields-unnest,https://api.gbif.org/v1/occurrence/search,TRUE Global,metadata/identifiers,https://api.gbif.org/v1/species/{id},TRUE Global,metadata/providers,https://api.gbif.org/v1/organization,TRUE Global,metadata/taxa-single,https://api.gbif.org/v2/species/match?verbose=FALSE&scientificName={name},TRUE diff --git a/tests/testthat/test-galah_filter.R b/tests/testthat/test-galah_filter.R index e00a9d45..f35d45f4 100644 --- a/tests/testthat/test-galah_filter.R +++ b/tests/testthat/test-galah_filter.R @@ -255,13 +255,12 @@ test_that("galah_filter handles lsid as an input", { collapse() # number of taxa searches is 3, not 4 expect_s3_class(query, "query") - expect_equal(length(query), 6) + expect_equal(length(query), 5) expect_equal(names(query), c("type", "url", "headers", "filter", - "slot_name", - "expand")) + "slot_name")) }) test_that("galah_filter handles different fields separated by OR", { From 514ee31d5d3852202a6ebbe4dc633cbbd5346776 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Thu, 14 Aug 2025 16:28:44 +1000 Subject: [PATCH 17/94] More effectively document and link new constructors and object classes (#278) Also remove dots argument from `galah_call()`, which appears to be unnecessary --- R/as_query.R | 55 +++++++++++++++++++++---------- R/coalesce.R | 22 +++++++++---- R/collapse.R | 35 +++++++++++++------- R/collect.R | 10 ++++-- R/compute.R | 18 +++++++--- R/galah_call.R | 64 ++++++++++++++++++------------------ man/as_query.data_request.Rd | 59 +++++++++++++++++++++++---------- man/coalesce.Rd | 22 ++++++++++--- man/collapse.data_request.Rd | 36 ++++++++++++++------ man/collect.data_request.Rd | 10 ++++-- man/compute.data_request.Rd | 17 ++++++++-- man/galah_call.Rd | 46 +++++++++++++++----------- 12 files changed, 262 insertions(+), 132 deletions(-) diff --git a/R/as_query.R b/R/as_query.R index a3f44188..8be160a7 100644 --- a/R/as_query.R +++ b/R/as_query.R @@ -1,32 +1,51 @@ #' Convert an object to class `query` #' -#' Functionally similar to [collapse()], but without passing through -#' [coalesce()] first. Primarily an internal function, but exported for -#' clarity and debugging purposes. +#' Functionally similar to \code{\link[=collapse.data_request]{collapse()}}, but +#' without passing through [coalesce()] first. Primarily an internal function, +#' but exported for clarity and debugging purposes. #' @details -#' Typically, queries in galah are piped using `galah_call()`, which builds -#' an object of class `"data_request"`, `"metadata_request"` or `"files_request"`. -#' This parses to an object of class `"query"` via [collapse()]. However, -#' [collapse()] first calls [coalesce()], which expands to a `query_set` -#' _before_ evaluating [collapse()]. In this context, `as_query()` serves two -#' functions: externally, it can be called to convert directly to a `query` -#' without running checks; and internally it allows a query to be appended -#' to a `query_set` without calling [collapse()], which would begin an -#' infinite loop (because `collapse()` calls `coalesce()`). +#' Typically, queries in galah are piped using [galah_call()], which builds +#' an object of class `"data_request"`, `"metadata_request"` or +#' `"files_request"`. All these objects can be converted to class `"query"` +#' using \code{\link[=collapse.data_request]{collapse()}}. However, +#' \code{\link[=collapse.data_request]{collapse()}} first calls +#' \code{\link[=coalesce.data_request]{coalesce()}}, which expands to an +#' object of class `"query_set"` _before_ evaluating +#' \code{\link[=collapse.data_request]{collapse()}}. In this context, +#' [as_query()] serves two purposes: externally, it can be called to convert +#' directly to class `"query"` without running checks; and internally it allows +#' a query to be appended to a `"query_set"` without calling causing an +#' infinite loop. #' #' For simple cases, this gives the same result as running -#' [collapse()] while the `run_checks` argument of [galah_config()] is set to -#' `FALSE`, but is slightly faster. For complex cases, however, it is likely -#' to generate irresolvable API calls, because e.g. taxonomic queries are not -#' parsed before the URL is built. It should therefore be used with care. +#' \code{\link[=collapse.data_request]{collapse()}} while the `run_checks` +#' argument of [galah_config()] is set to `FALSE`, but is slightly faster. For +#' complex cases, however, it is likely to generate irresolvable API calls, +#' because e.g. taxonomic queries are not parsed before the URL is built. It +#' should therefore be used with care. #' @name as_query.data_request #' @param x An object to convert to a `query`. Supported classes are the same #' as those produced by [galah_call()], namely `data_request`, #' `metadata_request` or `files_request`. #' @param ... Other arguments, currently ignored #' @order 1 -#' @return An object of class `query`, which is a list-like object containing at -#' least the slots `type` and `url`. +#' @return An object of class `query`, which is a list-like object containing +#' two or more of the following slots: +#' +#' - `type`: The type of query, serves as a lookup to the corresponding field in `show_all(apis)` +#' - `url`: Either: +#' - a length-1 character giving the API to be queried; or +#' - a `tibble()` containing at least the field `url` and optionally others +#' - `headers`: headers to be sent with the API call +#' - `body`: body section of the API call +#' - `options`: options section of the API call +#' - Any other information retained from the preceeding `_request` object (see [galah_call()]) +#' +#' @seealso To open a piped query, see [galah_call()]. For alternative +#' operations on `_request` objects, see [coalesce()], +#' \code{\link[=collapse.data_request]{collapse()}}, +#' \code{\link[=compute.data_request]{compute()}} or +#' \code{\link[=collect.data_request]{collect()}}. #' @export as_query <- function(x, ...){ UseMethod("as_query") diff --git a/R/coalesce.R b/R/coalesce.R index 0c9a1162..41f00dbe 100644 --- a/R/coalesce.R +++ b/R/coalesce.R @@ -1,17 +1,27 @@ #' Force evaluation of a database query #' -#' `coalesce` is an S3 generic function intended to be called before +#' [coalesce()] is an S3 generic function intended to be called before #' [collapse()]. It is important as it shows the full set of queries #' required to properly evaluate the user's request. This is often broader -#' than the single query returned by [collapse()]. It returns a `query_set` -#' object +#' than the single query returned by [collapse()]. If, for example, +#' the user's query includes a call to +#' \code{\link[=identify.data_request]{identify()}}, then a taxonomic query +#' is required to run _before_ the 'final' query is attempted. In relation to +#' other functions that manipulate `_request` objects, [coalesce()] is called +#' within \code{\link[=collapse.data_request]{collapse()}}, and itself +#' calls [as_query()] internally. #' @rdname coalesce #' @param x An object to be coalesced. Works for `data_request`, #' `metadata_request` and `file_request`. -#' @param ... Other arguments +#' @param ... Other arguments; not currently used. #' @order 1 -#' @return An object of class `query_set`, which is a list of all `query` +#' @return An object of class `query_set`, which is simply a list of all `query` #' objects required to properly evaluate the specified request. +#' @seealso To open a piped query, see [galah_call()]. For alternative +#' operations on `_request` objects, see [as_query()], +#' \code{\link[=collapse.data_request]{collapse()}}, +#' \code{\link[=compute.data_request]{compute()}} or +#' \code{\link[=collect.data_request]{collect()}}. #' @export coalesce <- function(x, ...){ UseMethod("coalesce") @@ -49,7 +59,7 @@ coalesce.metadata_request <- function(x, ...){ result[[(length(result) + 1)]] <- as_query_taxa(x) # best syntax for this?? } if(is.null(x$identify) & is.null(x$filter)){ - abort("Requests of type `taxa-unnest` must also supply one of `filter()` or `identify()`.") + cli::cli_abort("Requests of type `taxa-unnest` must also supply one of `filter()` or `identify()`.") } }else if(is.null(x$filter)){ current_type <- x$type diff --git a/R/collapse.R b/R/collapse.R index ce90321a..271631b8 100644 --- a/R/collapse.R +++ b/R/collapse.R @@ -1,12 +1,11 @@ -#' @title Generate a query -#' @description `collapse()` constructs a valid query so it can be -#' inspected before being sent. It typically occurs at the end of a pipe, -#' traditionally begun with `galah_call()`, that is used to define a query. -#' As of version 2.0, objects of class `data_request` (created using -#' `request_data()`), `metadata_request` (from `request_metadata()`) or -#' `files_request` (from `request_files()`) are all supported by `collapse()`. -#' Any of these objects can be created using `galah_call()` via the `method` -#' argument. +#' Generate a query +#' +#' This function constructs a query so it can be inspected before being sent. It +#' is typically called at the end of a pipe begun with [galah_call()]. Objects +#' of class `data_request` (created using [request_data()]), `metadata_request` +#' (from [request_metadata()]) or `files_request` (from [request_files()]) are +#' all supported. Any of these objects can be created using [galah_call()] via +#' the `method` argument. #' @name collapse.data_request #' @order 1 #' @param x An object to run `collapse()` on. Classes supported by `galah` @@ -16,8 +15,22 @@ #' @param ... Arguments passed on to other methods #' @param mint_doi Logical: should a DOI be minted for this download? Only #' applies to `type = "occurrences"` when atlas chosen is "ALA". -#' @return An object of class `query`, which is a list-like object containing at -#' least the slots `type` and `url`. +#' @return An object of class `query`, which is a list-like object containing +#' two or more of the following slots: +#' +#' - `type`: The type of query, serves as a lookup to the corresponding field in `show_all(apis)` +#' - `url`: Either: +#' - a length-1 character giving the API to be queried; or +#' - a `tibble` containing at least the field `url` and optionally others +#' - `headers`: headers to be sent with the API call +#' - `body`: body section of the API call +#' - `options`: options section of the API call +#' - Any other information retained from the preceeding `_request` object (see [galah_call()]) +#' +#' @seealso To open a piped query, see [galah_call()]. For alternative +#' operations on `_request` objects, see [as_query()], [coalesce()], +#' \code{\link[=compute.data_request]{compute()}} or +#' \code{\link[=collect.data_request]{collect()}}. #' @export collapse.data_request <- function(x, ..., mint_doi){ coalesce(x, mint_doi, ...) |> diff --git a/R/collect.R b/R/collect.R index dc8d2b95..c9aa13be 100644 --- a/R/collect.R +++ b/R/collect.R @@ -1,7 +1,7 @@ -#' @title Retrieve a database query +#' Retrieve a database query #' -#' @description `collect()` attempts to retrieve the result of a query from the -#' selected API. +#' This function retrieves the specified query from the server. It is the +#' default way to end a piped query begun with [galah_call()]. #' @name collect.data_request #' @order 1 #' @param x An object of class `data_request`, `metadata_request` or @@ -17,6 +17,10 @@ #' data. Where the requested data are not yet ready (i.e. for occurrences when #' `wait` is set to `FALSE`), this function returns an object of class `query` #' that can be used to recheck the download at a later time. +#' @seealso To open a piped query, see [galah_call()]. For alternative +#' operations on `_request` objects, see [as_query()], [coalesce()], +#' \code{\link[=collapse.data_request]{collapse()}} or +#' \code{\link[=compute.data_request]{compute()}}. #' @export collect.data_request <- function(x, ..., wait = TRUE, file = NULL){ collapse(x, ...) |> diff --git a/R/compute.R b/R/compute.R index 60b73e47..fd794909 100644 --- a/R/compute.R +++ b/R/compute.R @@ -1,7 +1,13 @@ -#' @title Compute a query -#' @description `compute()` is useful for several purposes. It's original -#' purpose is to send a request for data, which can then be processed by the -#' server and retrieved at a later time (via `collect()`). +#' Compute a query +#' +#' This function sends a request for information to a server. This is only +#' useful for processes that run a server-side process, as it separates the +#' submission of the request from its' retrieval. Within galah, this is used +#' exclusively for generating occurrence queries, where calling +#' \code{\link[=compute.data_request]{compute()}} and then passing +#' the resulting `query` object to \code{\link[=collect.data_request]{collect()}} +#' at a later time can be preferable to calling [atlas_occurrences()], which +#' prevents execution of new code until the server-side process is complete. #' @name compute.data_request #' @order 1 #' @param x An object of class `data_request`, `metadata_request` or @@ -11,6 +17,10 @@ #' @return An object of class `computed_query`, which is identical to class #' `query` except for occurrence data, where it also contains information on the #' status of the request. +#' @seealso To open a piped query, see [galah_call()]. For alternative +#' operations on `_request` objects, see [as_query()], [coalesce()], +#' \code{\link[=collapse.data_request]{collapse()}}, +#' \code{\link[=collect.data_request]{collect()}}. #' @export compute.data_request <- function(x, ...){ # x$type <- check_type(x$type) # possibly still needed; unclear diff --git a/R/galah_call.R b/R/galah_call.R index 1b4d1617..a5829e24 100644 --- a/R/galah_call.R +++ b/R/galah_call.R @@ -3,23 +3,20 @@ #' @description #' To download data from the selected atlas, one must construct a query. This #' query tells the atlas API what data to download and return, as well as how it -#' should be filtered. Using `galah_call()` allows you to build a piped query to +#' should be filtered. Using [galah_call()] allows you to build a piped query to #' download data, in the same way that you would wrangle data with `dplyr` and #' the `tidyverse`. #' @param method string: what `request` function should be called. Should be one #' of `"data"` (default), `"metadata"` or `"files"` #' @param type string: what form of data should be returned? Acceptable values #' are specified by the corresponding `request` function -#' @param ... Zero or more arguments passed to -#' \code{\link[=collapse.data_request]{collapse()}} to alter a query. Currently -#' only `mint.doi` (for occurrences) and `thumbnail` (for media downloads) are -#' supported. Both are logical. #' @details -#' In practice, `galah_call()` is a wrapper to a group of underlying +#' In practice, [galah_call()] is a wrapper to a group of underlying #' `request_` functions, selected using the `method` argument. -#' Each of these functions can begin a piped query and end with `collapse()`, -#' `compute()` or `collect()`, or optionally one of the `atlas_` family of -#' functions. For more details see the object-oriented programming vignette: +#' Each of these functions can begin a piped query, which is then actioned using +#' \code{\link[=collect.data_request]{collect()}}, or optionally one of the +#' \code{\link[=atlas_occurrences]{atlas_}} family of functions. For more +#' details see the object-oriented programming vignette: #' \code{vignette("object_oriented_programming", package = "galah")} #' #' Accepted values of the `type` argument are set by the underlying `request_` @@ -31,12 +28,18 @@ #' #' The underlying `request_` functions are useful because they allow `galah` #' to separate different types of requests to perform better. For example, -#' `filter.data_request` translates filters in R to `solr`, whereas -#' `filter.metadata_request` searches using a search term. -#' @return Each sub-function returns a different object class: `request_data()` -#' returns `data_request`. `request_metadata` returns `metadata_request`, -#' `request_files()` returns `files_request`. These objects are list-like and -#' contain the following slots: +#' \code{\link[=filter.data_request]{filter.data_request()}} translates filters +#' to `solr` syntax for the living atlases, or to predicates for GBIF, whereas +#' \code{\link[=filter.metadata_request]{filter.metadata_request()}} adds a +#' search term to your query. +#' @return Each sub-function returns a different object class: +#' +#' - [request_data()] returns class `"data_request"` +#' - [request_metadata()] returns class `"metadata_request"` +#' - [request_files()] returns class `"files_request"` +#' +#' +#' These objects are list-like and contain the following slots: #' #' - `filter`: edit by piping \code{\link[=filter.data_request]{filter()}} or [galah_filter()]. #' - `select`: edit by piping \code{\link[=filter.data_request]{select}} or [galah_select()]. @@ -47,7 +50,12 @@ #' - `limit`: edit by piping \code{\link[=slice_head.data_request]{slice_head()}}. #' - `doi`: edit by piping \code{\link[=filter.data_request]{filter(doi == "my-doi-here")}}. #' -#' @seealso [collapse.data_request()], [compute.data_request()], [collect.data_request()] +#' @seealso For operations on `_request` objects, see +#' \code{\link[=as_query.data_request]{as_query()}}, +#' [coalesce()], +#' \code{\link[=collapse.data_request]{collapse()}}, +#' \code{\link[=compute.data_request]{compute()}} or +#' \code{\link[=collect.data_request]{collect()}}. #' @rdname galah_call #' @examples \dontrun{ #' # Begin your query with `galah_call()`, then pipe using `%>%` or `|>` @@ -98,8 +106,7 @@ galah_call <- function(method = c("data", "metadata", "files"), - type, - ...){ + type){ method <- match.arg(method) if(missing(type)){ type <- switch(method, @@ -108,7 +115,7 @@ galah_call <- function(method = c("data", "files" = "media") } switch(method, - "data" = request_data(type = type, ...), + "data" = request_data(type = type), "metadata" = request_metadata(type = type), "files" = request_files(type = type)) } @@ -121,8 +128,7 @@ request_data <- function(type = c("occurrences", # "distributions", "species", "species-count" - ), - ...){ + )){ if(!missing(type)){ type <- match.arg(type) }else{ @@ -145,12 +151,7 @@ request_data <- function(type = c("occurrences", # set default for limit? # default_call$limit <- 100 ? class(default_call) <- "data_request" - # update - if(length(list(...)) > 0){ - update_data_request(default_call, ...) - }else{ - default_call - } + default_call } #' @rdname galah_call @@ -176,12 +177,11 @@ request_metadata <- function(type = c("fields", type_checked <- try(match.arg(type), silent = TRUE) if(inherits(type_checked, "try-error")){ - bullets <- c( - glue("Unrecognised metadata requested."), + c( + "Unrecognised metadata requested.", i = "See `?show_all()` for a list of valid metadata types.", - x = glue("Can't find metadata type `{type}`.") - ) - abort(bullets) + x = "Can't find metadata type `{type}`.") |> + cli::cli_abort() } x <- list(type = type_checked) class(x) <- "metadata_request" diff --git a/man/as_query.data_request.Rd b/man/as_query.data_request.Rd index f1f128c1..be688152 100644 --- a/man/as_query.data_request.Rd +++ b/man/as_query.data_request.Rd @@ -26,28 +26,51 @@ as those produced by \code{\link[=galah_call]{galah_call()}}, namely \code{data_ to \code{FALSE}, indicating full-size images are required.} } \value{ -An object of class \code{query}, which is a list-like object containing at -least the slots \code{type} and \code{url}. +An object of class \code{query}, which is a list-like object containing +two or more of the following slots: +\itemize{ +\item \code{type}: The type of query, serves as a lookup to the corresponding field in \code{show_all(apis)} +\item \code{url}: Either: +\itemize{ +\item a length-1 character giving the API to be queried; or +\item a \code{tibble()} containing at least the field \code{url} and optionally others +} +\item \code{headers}: headers to be sent with the API call +\item \code{body}: body section of the API call +\item \code{options}: options section of the API call +\item Any other information retained from the preceeding \verb{_request} object (see \code{\link[=galah_call]{galah_call()}}) +} } \description{ -Functionally similar to \code{\link[=collapse]{collapse()}}, but without passing through -\code{\link[=coalesce]{coalesce()}} first. Primarily an internal function, but exported for -clarity and debugging purposes. +Functionally similar to \code{\link[=collapse.data_request]{collapse()}}, but +without passing through \code{\link[=coalesce]{coalesce()}} first. Primarily an internal function, +but exported for clarity and debugging purposes. } \details{ -Typically, queries in galah are piped using \code{galah_call()}, which builds -an object of class \code{"data_request"}, \code{"metadata_request"} or \code{"files_request"}. -This parses to an object of class \code{"query"} via \code{\link[=collapse]{collapse()}}. However, -\code{\link[=collapse]{collapse()}} first calls \code{\link[=coalesce]{coalesce()}}, which expands to a \code{query_set} -\emph{before} evaluating \code{\link[=collapse]{collapse()}}. In this context, \code{as_query()} serves two -functions: externally, it can be called to convert directly to a \code{query} -without running checks; and internally it allows a query to be appended -to a \code{query_set} without calling \code{\link[=collapse]{collapse()}}, which would begin an -infinite loop (because \code{collapse()} calls \code{coalesce()}). +Typically, queries in galah are piped using \code{\link[=galah_call]{galah_call()}}, which builds +an object of class \code{"data_request"}, \code{"metadata_request"} or +\code{"files_request"}. All these objects can be converted to class \code{"query"} +using \code{\link[=collapse.data_request]{collapse()}}. However, +\code{\link[=collapse.data_request]{collapse()}} first calls +\code{\link[=coalesce.data_request]{coalesce()}}, which expands to an +object of class \code{"query_set"} \emph{before} evaluating +\code{\link[=collapse.data_request]{collapse()}}. In this context, +\code{\link[=as_query]{as_query()}} serves two purposes: externally, it can be called to convert +directly to class \code{"query"} without running checks; and internally it allows +a query to be appended to a \code{"query_set"} without calling causing an +infinite loop. For simple cases, this gives the same result as running -\code{\link[=collapse]{collapse()}} while the \code{run_checks} argument of \code{\link[=galah_config]{galah_config()}} is set to -\code{FALSE}, but is slightly faster. For complex cases, however, it is likely -to generate irresolvable API calls, because e.g. taxonomic queries are not -parsed before the URL is built. It should therefore be used with care. +\code{\link[=collapse.data_request]{collapse()}} while the \code{run_checks} +argument of \code{\link[=galah_config]{galah_config()}} is set to \code{FALSE}, but is slightly faster. For +complex cases, however, it is likely to generate irresolvable API calls, +because e.g. taxonomic queries are not parsed before the URL is built. It +should therefore be used with care. +} +\seealso{ +To open a piped query, see \code{\link[=galah_call]{galah_call()}}. For alternative +operations on \verb{_request} objects, see \code{\link[=coalesce]{coalesce()}}, +\code{\link[=collapse.data_request]{collapse()}}, +\code{\link[=compute.data_request]{compute()}} or +\code{\link[=collect.data_request]{collect()}}. } diff --git a/man/coalesce.Rd b/man/coalesce.Rd index 8a194dd4..f1f3edc1 100644 --- a/man/coalesce.Rd +++ b/man/coalesce.Rd @@ -19,7 +19,7 @@ coalesce(x, ...) \item{x}{An object to be coalesced. Works for \code{data_request}, \code{metadata_request} and \code{file_request}.} -\item{...}{Other arguments} +\item{...}{Other arguments; not currently used.} \item{mint_doi}{Logical: should a DOI be minted for this download? Only applies to \code{type = "occurrences"} when atlas chosen is "ALA".} @@ -28,13 +28,25 @@ applies to \code{type = "occurrences"} when atlas chosen is "ALA".} to \code{FALSE}, indicating full-size images are required.} } \value{ -An object of class \code{query_set}, which is a list of all \code{query} +An object of class \code{query_set}, which is simply a list of all \code{query} objects required to properly evaluate the specified request. } \description{ -\code{coalesce} is an S3 generic function intended to be called before +\code{\link[=coalesce]{coalesce()}} is an S3 generic function intended to be called before \code{\link[=collapse]{collapse()}}. It is important as it shows the full set of queries required to properly evaluate the user's request. This is often broader -than the single query returned by \code{\link[=collapse]{collapse()}}. It returns a \code{query_set} -object +than the single query returned by \code{\link[=collapse]{collapse()}}. If, for example, +the user's query includes a call to +\code{\link[=identify.data_request]{identify()}}, then a taxonomic query +is required to run \emph{before} the 'final' query is attempted. In relation to +other functions that manipulate \verb{_request} objects, \code{\link[=coalesce]{coalesce()}} is called +within \code{\link[=collapse.data_request]{collapse()}}, and itself +calls \code{\link[=as_query]{as_query()}} internally. +} +\seealso{ +To open a piped query, see \code{\link[=galah_call]{galah_call()}}. For alternative +operations on \verb{_request} objects, see \code{\link[=as_query]{as_query()}}, +\code{\link[=collapse.data_request]{collapse()}}, +\code{\link[=compute.data_request]{compute()}} or +\code{\link[=collect.data_request]{collect()}}. } diff --git a/man/collapse.data_request.Rd b/man/collapse.data_request.Rd index 2cc0457d..c2be74e0 100644 --- a/man/collapse.data_request.Rd +++ b/man/collapse.data_request.Rd @@ -33,16 +33,32 @@ applies to \code{type = "occurrences"} when atlas chosen is "ALA".} to \code{FALSE}, indicating full-size images are required.} } \value{ -An object of class \code{query}, which is a list-like object containing at -least the slots \code{type} and \code{url}. +An object of class \code{query}, which is a list-like object containing +two or more of the following slots: +\itemize{ +\item \code{type}: The type of query, serves as a lookup to the corresponding field in \code{show_all(apis)} +\item \code{url}: Either: +\itemize{ +\item a length-1 character giving the API to be queried; or +\item a \code{tibble} containing at least the field \code{url} and optionally others +} +\item \code{headers}: headers to be sent with the API call +\item \code{body}: body section of the API call +\item \code{options}: options section of the API call +\item Any other information retained from the preceeding \verb{_request} object (see \code{\link[=galah_call]{galah_call()}}) +} } \description{ -\code{collapse()} constructs a valid query so it can be -inspected before being sent. It typically occurs at the end of a pipe, -traditionally begun with \code{galah_call()}, that is used to define a query. -As of version 2.0, objects of class \code{data_request} (created using -\code{request_data()}), \code{metadata_request} (from \code{request_metadata()}) or -\code{files_request} (from \code{request_files()}) are all supported by \code{collapse()}. -Any of these objects can be created using \code{galah_call()} via the \code{method} -argument. +This function constructs a query so it can be inspected before being sent. It +is typically called at the end of a pipe begun with \code{\link[=galah_call]{galah_call()}}. Objects +of class \code{data_request} (created using \code{\link[=request_data]{request_data()}}), \code{metadata_request} +(from \code{\link[=request_metadata]{request_metadata()}}) or \code{files_request} (from \code{\link[=request_files]{request_files()}}) are +all supported. Any of these objects can be created using \code{\link[=galah_call]{galah_call()}} via +the \code{method} argument. +} +\seealso{ +To open a piped query, see \code{\link[=galah_call]{galah_call()}}. For alternative +operations on \verb{_request} objects, see \code{\link[=as_query]{as_query()}}, \code{\link[=coalesce]{coalesce()}}, +\code{\link[=compute.data_request]{compute()}} or +\code{\link[=collect.data_request]{collect()}}. } diff --git a/man/collect.data_request.Rd b/man/collect.data_request.Rd index 9b6faf07..83794709 100644 --- a/man/collect.data_request.Rd +++ b/man/collect.data_request.Rd @@ -39,6 +39,12 @@ data. Where the requested data are not yet ready (i.e. for occurrences when that can be used to recheck the download at a later time. } \description{ -\code{collect()} attempts to retrieve the result of a query from the -selected API. +This function retrieves the specified query from the server. It is the +default way to end a piped query begun with \code{\link[=galah_call]{galah_call()}}. +} +\seealso{ +To open a piped query, see \code{\link[=galah_call]{galah_call()}}. For alternative +operations on \verb{_request} objects, see \code{\link[=as_query]{as_query()}}, \code{\link[=coalesce]{coalesce()}}, +\code{\link[=collapse.data_request]{collapse()}} or +\code{\link[=compute.data_request]{compute()}}. } diff --git a/man/compute.data_request.Rd b/man/compute.data_request.Rd index 324ecba9..2e6dc0bb 100644 --- a/man/compute.data_request.Rd +++ b/man/compute.data_request.Rd @@ -28,7 +28,18 @@ An object of class \code{computed_query}, which is identical to class status of the request. } \description{ -\code{compute()} is useful for several purposes. It's original -purpose is to send a request for data, which can then be processed by the -server and retrieved at a later time (via \code{collect()}). +This function sends a request for information to a server. This is only +useful for processes that run a server-side process, as it separates the +submission of the request from its' retrieval. Within galah, this is used +exclusively for generating occurrence queries, where calling +\code{\link[=compute.data_request]{compute()}} and then passing +the resulting \code{query} object to \code{\link[=collect.data_request]{collect()}} +at a later time can be preferable to calling \code{\link[=atlas_occurrences]{atlas_occurrences()}}, which +prevents execution of new code until the server-side process is complete. +} +\seealso{ +To open a piped query, see \code{\link[=galah_call]{galah_call()}}. For alternative +operations on \verb{_request} objects, see \code{\link[=as_query]{as_query()}}, \code{\link[=coalesce]{coalesce()}}, +\code{\link[=collapse.data_request]{collapse()}}, +\code{\link[=collect.data_request]{collect()}}. } diff --git a/man/galah_call.Rd b/man/galah_call.Rd index 19d2ee24..7922dc0b 100644 --- a/man/galah_call.Rd +++ b/man/galah_call.Rd @@ -7,12 +7,11 @@ \alias{request_files} \title{Start building a query} \usage{ -galah_call(method = c("data", "metadata", "files"), type, ...) +galah_call(method = c("data", "metadata", "files"), type) request_data( type = c("occurrences", "occurrences-count", "occurrences-doi", "species", - "species-count"), - ... + "species-count") ) request_metadata( @@ -29,17 +28,16 @@ of \code{"data"} (default), \code{"metadata"} or \code{"files"}} \item{type}{string: what form of data should be returned? Acceptable values are specified by the corresponding \code{request} function} - -\item{...}{Zero or more arguments passed to -\code{\link[=collapse.data_request]{collapse()}} to alter a query. Currently -only \code{mint.doi} (for occurrences) and \code{thumbnail} (for media downloads) are -supported. Both are logical.} } \value{ -Each sub-function returns a different object class: \code{request_data()} -returns \code{data_request}. \code{request_metadata} returns \code{metadata_request}, -\code{request_files()} returns \code{files_request}. These objects are list-like and -contain the following slots: +Each sub-function returns a different object class: +\itemize{ +\item \code{\link[=request_data]{request_data()}} returns class \code{"data_request"} +\item \code{\link[=request_metadata]{request_metadata()}} returns class \code{"metadata_request"} +\item \code{\link[=request_files]{request_files()}} returns class \code{"files_request"} +} + +These objects are list-like and contain the following slots: \itemize{ \item \code{filter}: edit by piping \code{\link[=filter.data_request]{filter()}} or \code{\link[=galah_filter]{galah_filter()}}. \item \code{select}: edit by piping \code{\link[=filter.data_request]{select}} or \code{\link[=galah_select]{galah_select()}}. @@ -54,16 +52,17 @@ contain the following slots: \description{ To download data from the selected atlas, one must construct a query. This query tells the atlas API what data to download and return, as well as how it -should be filtered. Using \code{galah_call()} allows you to build a piped query to +should be filtered. Using \code{\link[=galah_call]{galah_call()}} allows you to build a piped query to download data, in the same way that you would wrangle data with \code{dplyr} and the \code{tidyverse}. } \details{ -In practice, \code{galah_call()} is a wrapper to a group of underlying +In practice, \code{\link[=galah_call]{galah_call()}} is a wrapper to a group of underlying \code{request_} functions, selected using the \code{method} argument. -Each of these functions can begin a piped query and end with \code{collapse()}, -\code{compute()} or \code{collect()}, or optionally one of the \code{atlas_} family of -functions. For more details see the object-oriented programming vignette: +Each of these functions can begin a piped query, which is then actioned using +\code{\link[=collect.data_request]{collect()}}, or optionally one of the +\code{\link[=atlas_occurrences]{atlas_}} family of functions. For more +details see the object-oriented programming vignette: \code{vignette("object_oriented_programming", package = "galah")} Accepted values of the \code{type} argument are set by the underlying \code{request_} @@ -75,8 +74,10 @@ for \code{type = "species"}). The underlying \code{request_} functions are useful because they allow \code{galah} to separate different types of requests to perform better. For example, -\code{filter.data_request} translates filters in R to \code{solr}, whereas -\code{filter.metadata_request} searches using a search term. +\code{\link[=filter.data_request]{filter.data_request()}} translates filters +to \code{solr} syntax for the living atlases, or to predicates for GBIF, whereas +\code{\link[=filter.metadata_request]{filter.metadata_request()}} adds a +search term to your query. } \examples{ \dontrun{ @@ -126,5 +127,10 @@ request_metadata(type = "atlases") |> } } \seealso{ -\code{\link[=collapse.data_request]{collapse.data_request()}}, \code{\link[=compute.data_request]{compute.data_request()}}, \code{\link[=collect.data_request]{collect.data_request()}} +For operations on \verb{_request} objects, see +\code{\link[=as_query.data_request]{as_query()}}, +\code{\link[=coalesce]{coalesce()}}, +\code{\link[=collapse.data_request]{collapse()}}, +\code{\link[=compute.data_request]{compute()}} or +\code{\link[=collect.data_request]{collect()}}. } From 10bad4eb5201489f94f8b7ef14294181fcc220f3 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Wed, 3 Sep 2025 16:31:17 +1000 Subject: [PATCH 18/94] Fix bugs identified using `test()` - remove obsolete args in `atlas_counts()` - `check()` now uses `galah_call()` instead of `do.call()` - remove `expand` slot from query objects - minor refactoring in namematching - fix bug where `search_taxa()` not accepting `data.frame` as input - many minor adjustments --- R/atlas_counts.R | 10 +- R/check.R | 5 +- R/collapse_profile_values.R | 6 +- R/collapse_species_count.R | 4 +- R/collect.R | 9 +- R/collect_media.R | 7 +- R/collect_metadata.R | 2 +- R/collect_taxa.R | 67 ++++++++------ R/galah_config.R | 141 +++++++++++++---------------- R/search_all.R | 23 +++-- man/atlas_.Rd | 3 +- tests/testthat/test-atlas_counts.R | 2 +- tests/testthat/test-atlas_media.R | 15 +-- tests/testthat/test-galah_filter.R | 16 ++-- tests/testthat/test-search_all.R | 4 +- tests/testthat/test-search_taxa.R | 19 ++-- tests/testthat/test-show_values.R | 2 +- 17 files changed, 165 insertions(+), 170 deletions(-) diff --git a/R/atlas_counts.R b/R/atlas_counts.R index e4f4c424..495db85d 100644 --- a/R/atlas_counts.R +++ b/R/atlas_counts.R @@ -7,8 +7,7 @@ #' @param type `string`: one of `"occurrences"` or `"species"`. #' Defaults to `"occurrences"`, which returns the number of records #' that match the selected criteria; alternatively returns the number of -#' species. Formerly accepted arguments (`"records"` or `"species"`) are -#' deprecated but remain functional. +#' species. #' @export atlas_counts <- function(request = NULL, identify = NULL, @@ -22,11 +21,8 @@ atlas_counts <- function(request = NULL, # capture supplied arguments args <- as.list(environment()) args$type <- match.arg(type) - dr <- check_atlas_inputs(args) # convert to `data_request` object - # check for outdated naming conventions - if(dr$type == "record"){dr$type <- "occurrences"} - # pass to collect etc - dr |> + # convert to `data_request` object, collect + check_atlas_inputs(args) |> count() |> slice_head(n = limit) |> collect() diff --git a/R/check.R b/R/check.R index 0a5965ed..7350898d 100644 --- a/R/check.R +++ b/R/check.R @@ -7,7 +7,8 @@ check_atlas_inputs <- function(args){ check_data_request(args$request) update_data_request(args$request, args[-1]) }else{ - do.call(galah_call, args) + galah_call() |> + update_data_request(args[-1]) } } @@ -15,7 +16,7 @@ check_atlas_inputs <- function(args){ #' @noRd #' @keywords Internal check_data_request <- function(request, - error_call = caller_env()){ + error_call = rlang::caller_env()){ if(!inherits(request, "data_request")){ cli::cli_abort(c( "Argument `.query` requires an object of type `data_request`.", diff --git a/R/collapse_profile_values.R b/R/collapse_profile_values.R index de03d0c1..4088ef2e 100644 --- a/R/collapse_profile_values.R +++ b/R/collapse_profile_values.R @@ -7,9 +7,9 @@ collapse_profile_values <- function(.query){ httr2::url_parse() profile_name <- extract_profile_name(url) short_name <- profile_short_name(profile_name) - if (!pour("atlas", "region") == "Spain") { + if (!potions::pour("atlas", "region") == "Spain") { path_name <- url |> - httr2::pluck("path") |> + purrr::pluck("path") |> dirname() url$path <- glue::glue("{path_name}/{short_name}") } @@ -46,7 +46,7 @@ profile_short_name <- function(profile) { c( "Unknown profile detected.", i = "See a listing of valid data quality profiles with `show_all_profiles()`.") |> - cli::cli_abort(call = caller_env()) + cli::cli_abort(call = rlang::caller_env()) }else{ short_name } diff --git a/R/collapse_species_count.R b/R/collapse_species_count.R index 4259a0aa..e8310cae 100644 --- a/R/collapse_species_count.R +++ b/R/collapse_species_count.R @@ -2,9 +2,7 @@ #' @noRd #' @keywords Internal collapse_species_count <- function(.query){ - browser() - # `expand` argument has been removed from query objects; need to refactor this - if(.query$expand){ + if(!is.null(.query$arrange)){ .query <- collapse_species_query_list(.query) }else{ .query$url <- tibble::tibble(url = .query$url) diff --git a/R/collect.R b/R/collect.R index c9aa13be..f36beec9 100644 --- a/R/collect.R +++ b/R/collect.R @@ -56,11 +56,6 @@ collect.query <- function(x, ..., wait = TRUE, file = NULL){ #' @rdname collect.data_request #' @order 5 -#' @importFrom glue glue -#' @importFrom potions pour -#' @importFrom rlang abort -#' @importFrom rlang inform -#' @importFrom tibble tibble #' @export collect.computed_query <- function(x, ..., @@ -72,7 +67,7 @@ collect.computed_query <- function(x, is.null(x$data) & # some cached metadata queries have `data` instead is.null(x$status) # finally, after `compute()`, occurrences have `status` ){ - tibble() + tibble::tibble() }else{ switch(x$type, "data/distributions" = collect_distributions(x), @@ -105,6 +100,6 @@ collect.computed_query <- function(x, "metadata/taxa-multiple" = collect_taxa(x), "metadata/taxa-unnest" = collect_taxa_unnest(x), "metadata/identifiers" = collect_identifiers(x), - abort("unrecognised `type`")) + cli::cli_abort("unrecognised `type`")) } } diff --git a/R/collect_media.R b/R/collect_media.R index 78dd8531..67d78125 100644 --- a/R/collect_media.R +++ b/R/collect_media.R @@ -30,9 +30,10 @@ collect_media_metadata <- function(.query){ #' @keywords Internal collect_media_files <- function(.query){ result <- query_API(.query) - result_summary <- tibble::tibble( - status_code = purrr::map(result,\(a){a$status_code})) |> - unlist() |> + status_values <- purrr::map(result, + \(a){a$status_code}) |> + unlist() + result_summary <- tibble::tibble(status_code = status_values) |> dplyr::group_by(.data$status_code) |> dplyr::count() diff --git a/R/collect_metadata.R b/R/collect_metadata.R index f320b35c..5e85a1ae 100644 --- a/R/collect_metadata.R +++ b/R/collect_metadata.R @@ -219,7 +219,7 @@ collect_licences <- function(.query){ #' @keywords Internal collect_lists <- function(.query){ if(inherits(.query$url, "data.frame")){ - result <- purrr:map(query_API(.query), + result <- purrr::map(query_API(.query), \(a){a$lists}) |> dplyr::bind_rows() }else{ diff --git a/R/collect_taxa.R b/R/collect_taxa.R index 9ce67714..a5a48496 100644 --- a/R/collect_taxa.R +++ b/R/collect_taxa.R @@ -16,7 +16,8 @@ collect_taxa <- function(.query){ #' Internal function to `collect()` taxa for Atlas of Living Australia #' @noRd #' @keywords Internal -collect_taxa_namematching <- function(.query){ +collect_taxa_namematching <- function(.query, + error_call = rlang::caller_env()){ search_terms <- .query$url$search_term result <- purrr::map(query_API(.query), build_tibble_from_nested_list) |> @@ -27,27 +28,30 @@ collect_taxa_namematching <- function(.query){ # Might be worth returning to if this functionality is needed # result <- filter(result, !duplicated(taxonConceptID)) # } - + # handle one or more returned issues values - issues <- unlist(result$issues) - - if(length(issues) > 1) { - issues_c <- glue::glue_collapse(issues, sep = ", ") - } else { - issues_c <- issues - } + issues_vec <- purrr::map(result$issues, + \(a){ + b <- a[[1]] + if(length(b) <= 1){ + b + }else{ + glue::glue_collapse(b, sep = ", ") + } + }) |> + unlist() # add issues to result - result <- result |> + result <- result |> + dplyr::select(-"issues") |> dplyr::mutate("search_term" = search_terms, .before = "success", - issues = issues_c) + issues = issues_vec) # Check for homonyms - if(any(colnames(result) == "issues")){ - check_homonyms(result) - } - + check_homonyms(result, + error_call = error_call) + # Check for invalid search terms if (galah_config()$package$verbose) { check_search_terms(result) @@ -74,7 +78,8 @@ collect_taxa_la <- function(.query){ dplyr::filter(!duplicated({{name}})) |> dplyr::mutate("search_term" = search_terms) } - names(result) <- rename_columns(names(result), type = "taxa") # old code + names(result) <- rename_columns(names(result), + type = "taxa") # old code result |> dplyr::select(dplyr::any_of(wanted_columns("taxa"))) } @@ -166,7 +171,8 @@ clean_la_taxa <- function(result, search_terms){ # unlist if necessary atlas <- potions::pour("atlas", "region") if (any(atlas %in% c("France", "Portugal"))) { - list_of_results <- list_of_results |> unlist() + list_of_results <- list_of_results |> + unlist() } list_of_results }) @@ -255,19 +261,22 @@ check_search_terms <- function(result, atlas) { #' Internal function to check for homonyms in search term provided to #' `search_taxa()` +#' @importFrom rlang .data #' @noRd #' @keywords Internal -check_homonyms <- function(result) { - if ("homonym" %in% result$issues) { - homonym_taxa <- result[result$issues %in% "homonym",]$search_term - list_homonym_taxa <- glue::glue_collapse(homonym_taxa, - sep = ", ") - bullets <- c( - "Search returned multiple taxa due to a homonym issue.", - i = "Please provide another rank in your search to clarify taxa.", - i = "Use a `tibble` to clarify taxa, see `?search_taxa`.", - x = glue("Homonym issue with \"{list_homonym_taxa}\".") - ) - cli::cli_warn(bullets) +check_homonyms <- function(result, + error_call = rlang::caller_env()) { + homonym_check <- grepl("homonym", result$issues) + if(any(homonym_check)){ + homonym_taxa <- result |> + dplyr::filter(homonym_check == TRUE) |> + dplyr::pull("search_term") + list_homonym_taxa <- glue::glue_collapse(homonym_taxa, + sep = ", ") + c("Search returned multiple taxa due to a homonym issue.", + i = "Please provide another rank in your search to clarify taxa.", + i = "Use a `tibble` to clarify taxa, see `?search_taxa`.", + x = "Homonym issue with \"{list_homonym_taxa}\".") |> + cli::cli_warn(call = error_call) } } diff --git a/R/galah_config.R b/R/galah_config.R index d439b767..3f5eac92 100644 --- a/R/galah_config.R +++ b/R/galah_config.R @@ -63,42 +63,35 @@ #' # Make debugging in your session easier by setting `verbose = TRUE` #' galah_config(verbose = TRUE) #' } -#' @importFrom lifecycle deprecate_warn -#' @importFrom glue glue -#' @importFrom rlang abort -#' @importFrom potions brew -#' @importFrom potions pour -#' @export galah_config - +#' @export galah_config <- function(...) { # make sure dots are captured properly dots <- list(...) # set defaults, if this has not happened already - if(length(pour()) == 0) { - brew(default_config()) + if(length(potions::pour()) == 0) { + potions::brew(default_config()) } # add user-provided information if(length(dots) > 0){ - # check for deprecated `cache_directory` if(any(names(dots) == "cache_directory")){ dots_location <- which(names(dots) == "cache_directory") value <- dots$cache_directory - deprecate_warn(when = "2.0.0", - what = "galah_config(cache_directory)", - details = glue("Use `galah_config(directory = \"{value}\")` instead.") + lifecycle::deprecate_warn(when = "2.0.0", + what = "galah_config(cache_directory)", + details = glue::glue("Use `galah_config(directory = \"{value}\")` instead.") ) names(dots)[dots_location] <- "directory" } # check all values in dots to ensure they are named if(length(dots) != length(names(dots))){ - bullets <- c("All arguments to `galah_config() must be named.", - i = "Did you use `==` instead of `=`?") - abort(bullets) + c("All arguments to `galah_config() must be named.", + i = "Did you use `==` instead of `=`?") |> + cli::cli_abort() } # check all values in dots to ensure they are valid @@ -106,17 +99,17 @@ galah_config <- function(...) { # add to `potions` object if(any(names(result) == "atlas")){ - brew(atlas = list(atlas = result$atlas)) + potions::brew(atlas = list(atlas = result$atlas)) result <- result[names(result) != "atlas"] result$atlas_config_called_by_user <- TRUE } if(length(result) > 0){ - brew(result, method = "leaves") + potions::brew(result, method = "leaves") } }else{ - x <- pour() + x <- potions::pour() class(x) <- c("galah_config", "list") return(x) } @@ -151,25 +144,24 @@ default_config <- function(){ #' @noRd #' @keywords Internal restructure_config <- function(dots){ - result <- lapply(names(dots), - function(a){validate_config(a, dots[[a]])}) + result <- purrr::map(names(dots), + \(a){validate_config(a, dots[[a]])}) names(result) <- names(dots) result } #' Catch errors in user-provided config -#' @importFrom rlang abort -#' @importFrom glue glue -#' @importFrom potions pour #' @noRd #' @keywords Internal -validate_config <- function(name, value, error_call = caller_env()) { +validate_config <- function(name, + value, + error_call = rlang::caller_env()) { result <- switch(name, - "api_key" = enforce_character(value), + "api_key" = enforce_character(value), "atlas" = { value <- configure_atlas(value) # see whether atlases have changed, and if so, give a message - check_atlas(pour("atlas"), value) + check_atlas(potions::pour("atlas"), value) }, "caching" = enforce_logical(value), "directory" = check_directory(value), @@ -185,52 +177,51 @@ validate_config <- function(name, value, error_call = caller_env()) { } #' Ensure some arguments are logical -#' @importFrom rlang abort #' @noRd #' @keywords Internal -enforce_logical <- function(value, error_call = caller_env()){ +enforce_logical <- function(value, + error_call = rlang::caller_env()){ if (!is.logical(value)) { - abort("Supplied value must be TRUE or FALSE.", call = error_call) + cli::cli_abort("Supplied value must be TRUE or FALSE.", + call = error_call) }else{ value } } #' Ensure a file exists -#' @importFrom rlang abort #' @noRd #' @keywords Internal -enforce_exists <- function(value, error_call = caller_env()){ +enforce_exists <- function(value, + error_call = rlang::caller_env()){ if (!dir.exists(value)) { - bullets <- c("Cache directory does not exist.", - i = "Does the directory entered exist?") - abort(bullets, call = error_call) + c("Cache directory does not exist.", + i = "Does the directory entered exist?") |> + cli::cli_abort(call = error_call) }else{ value } } #' Ensure provided value is a string -#' @importFrom rlang abort #' @noRd #' @keywords Internal -enforce_character <- function(value, error_call = caller_env()){ +enforce_character <- function(value, + error_call = rlang::caller_env()){ if (!is.character(value)) { - bullets <- c( - glue("Invalid type"), - i = "Value must be entered as a string." - ) - abort(bullets, call = error_call) + c("Invalid type", + i = "Value must be entered as a string.") |> + cli::cli_abort(call = error_call) }else{ value } } #' Ensure download reason is valid -#' @importFrom rlang abort #' @noRd #' @keywords Internal -enforce_download_reason <- function(value, error_call = caller_env()){ +enforce_download_reason <- function(value, + error_call = rlang::caller_env()){ # first ensure API is available. Currently missing for Brazil, for example. reasons_api_available <- url_lookup("metadata/reasons") |> @@ -239,27 +230,25 @@ enforce_download_reason <- function(value, error_call = caller_env()){ return(1) }else{ if (is.numeric(value) & !(value %in% show_all_reasons()$id)) { - bullets <- c( - "Invalid download reason ID.", + c("Invalid download reason ID.", i = "Use `show_all(reasons)` to see all valid reasons.", - x = glue("{value} does not match an existing reason ID.") - ) - abort(bullets, call = error_call) + x = "{value} does not match an existing reason ID.") |> + cli::cli_abort(call = error_call) } else if(is.character(value) & !(value %in% show_all_reasons()$name)) { bullets <- c( "Invalid download reason name.", i = "Use `show_all(reasons)` to see all valid reasons.", - x = glue("\"{value}\" does not match an existing reason name.") - ) - abort(bullets, call = error_call) + x = "\"{value}\" does not match an existing reason name.") |> + cli::cli_abort(call = error_call) } if (is.character(value) & (value %in% show_all_reasons()$name)) { valid_reasons <- show_all_reasons() value_id <- valid_reasons |> - filter(valid_reasons$name == value) |> - select("id") |> - pull("id") - inform(c("v" = glue("Matched \"{value}\" to valid download reason ID {value_id}."))) + dplyr::filter(valid_reasons$name == value) |> + dplyr::select("id") |> + dplyr::pull("id") + c("v" = "Matched \"{value}\" to valid download reason ID {value_id}.") |> + cli::cli_inform(call = error_call) value_id }else{ value @@ -268,36 +257,34 @@ enforce_download_reason <- function(value, error_call = caller_env()){ } #' catch all failure for unknown names -#' @importFrom rlang abort #' @noRd #' @keywords Internal -enforce_invalid_name <- function(name, error_call = caller_env()){ - bullets <- c( - "Invalid option name.", +enforce_invalid_name <- function(name, + error_call = rlang::caller_env()){ + c("Invalid option name.", i = "See `?galah_config()` for valid options.", - x = glue("\"{name}\" is not a valid option name.") - ) - abort(bullets, call = error_call) + x = "\"{name}\" is not a valid option name.") |> + cli::cli_abort(call = error_call) } #' Set behaviour for deriving correct atlas information -#' @importFrom rlang abort -#' @importFrom glue glue #' @noRd #' @keywords Internal -configure_atlas <- function(query){ +configure_atlas <- function(query, + error_call = rlang::caller_env()){ comparison <- do.call(c, node_metadata) - comparison <- comparison[!is.na(comparison)] |> as.character() - lookup <- utils::adist(query, comparison, ignore.case = TRUE)[1, ] + comparison <- comparison[!is.na(comparison)] |> + as.character() + lookup <- utils::adist(query, + comparison, + ignore.case = TRUE)[1, ] if(all(lookup > 2)){ - bullets <- c( - "Unsupported atlas provided.", - i = glue("Use `show_all(atlases)` to see supported atlases."), - x = glue("\"{query}\" is not a valid atlas.") - ) - abort(bullets, call = caller_env()) + c("Unsupported atlas provided.", + i = "Use `show_all(atlases)` to see supported atlases.", + x = "\"{query}\" is not a valid atlas.") |> + cli::cli_abort(call = error_call) }else{ selected_entry <- comparison[which(lookup == min(lookup))][[1]] @@ -315,14 +302,12 @@ configure_atlas <- function(query){ } #' Provide a message if atlas is changed -#' @importFrom glue glue -#' @importFrom rlang inform #' @noRd #' @keywords Internal check_atlas <- function(current_data, new_data){ if(new_data$region != current_data$region){ - inform(glue( - "Atlas selected: {new_data$organisation} ({new_data$acronym}) [{new_data$region}]")) + cli::cli_inform( + "Atlas selected: {new_data$organisation} ({new_data$acronym}) [{new_data$region}]") } new_data } \ No newline at end of file diff --git a/R/search_all.R b/R/search_all.R index b8926652..a219f5e1 100644 --- a/R/search_all.R +++ b/R/search_all.R @@ -185,14 +185,14 @@ check_if_missing <- function(query, #' `galah_call()` instead of `galah_identify()` #' @noRd #' @keywords Internal -check_if_in_pipe <- function(..., error_call = caller_env()) { +check_if_in_pipe <- function(..., + error_call = rlang::caller_env()){ dots <- list(...) col_type_present <- grepl("type", names(unlist(dots))) - if (any(col_type_present)) { - c( - "Can't pipe `search_taxa()` in a `galah_call()`.", + if(any(col_type_present)){ + c("Can't pipe `search_taxa()` in a `galah_call()`.", i = "Did you mean to use `galah_identify()`?") |> - cli::cli_abort(bullets, call = error_call) + cli::cli_abort(call = error_call) } } @@ -250,4 +250,15 @@ search_reasons <- function(query){search_all("reasons", query)} #' @rdname search_all #' @export -search_taxa <- function(...){search_all("taxa", list(...))} \ No newline at end of file +search_taxa <- function(...){ + dots <- list(...) + if(length(dots) == 1L){ + if(inherits(dots[[1]], "data.frame")){ + search_all("taxa", dots[[1]]) + }else{ + search_all("taxa", dots) + } + }else{ + search_all("taxa", dots) + } +} \ No newline at end of file diff --git a/man/atlas_.Rd b/man/atlas_.Rd index 9d69aca6..84f92b74 100644 --- a/man/atlas_.Rd +++ b/man/atlas_.Rd @@ -90,8 +90,7 @@ take a while.} \item{type}{\code{string}: one of \code{"occurrences"} or \code{"species"}. Defaults to \code{"occurrences"}, which returns the number of records that match the selected criteria; alternatively returns the number of -species. Formerly accepted arguments (\code{"records"} or \code{"species"}) are -deprecated but remain functional.} +species.} \item{constrain_ids}{\code{string}: Optional string to limit which \code{taxon_concept_id}'s are returned. This is useful for restricting taxonomy to particular diff --git a/tests/testthat/test-atlas_counts.R b/tests/testthat/test-atlas_counts.R index 1540f98c..175d7688 100644 --- a/tests/testthat/test-atlas_counts.R +++ b/tests/testthat/test-atlas_counts.R @@ -211,7 +211,7 @@ test_that("species counts work with group_by()", { expect_type(count_species$count, "integer") expect_gte(nrow(count_species), 4) expect_true(all(count_species$count > 0)) - expect_true(all(count_species$count < 100)) + expect_true(all(count_species$count < 50)) expect_true(all(count_records$year == count_species$year)) expect_true(all(count_records$count >= count_species$count)) }) diff --git a/tests/testthat/test-atlas_media.R b/tests/testthat/test-atlas_media.R index a09dedde..c3e3aa98 100644 --- a/tests/testthat/test-atlas_media.R +++ b/tests/testthat/test-atlas_media.R @@ -16,9 +16,10 @@ test_that("`atlas_media()` works", { test_that("collect_media suggests `galah_config(directory =)` when a temp folder is set as the directory", { skip_if_offline(); skip_on_ci() - atlas_query <- atlas_media( - identify = galah_identify("Regent Honeyeater"), - filter = galah_filter(year == 2012)) + atlas_query <- galah_call() |> + identify("Anthochaera (Xanthomyza) phrygia") |> # Regent Honeyeater + filter(year == 2012) |> + atlas_media() media_dir <- "Temp" unlink(media_dir, recursive = TRUE) dir.create(media_dir) @@ -143,8 +144,8 @@ test_that("collect_media handles different file formats", { galah_config(email = "ala4r@ala.org.au", directory = media_dir) media_data <- galah_call() |> - galah_identify("Regent Honeyeater") |> - galah_filter(year == 2024) |> + identify("Regent Honeyeater") |> + filter(year == 2024) |> atlas_media() # sample one of each multimedia type to shorten testing time media_data <- media_data |> @@ -167,8 +168,8 @@ test_that("collect_media handles thumbnails", { galah_config(email = "ala4r@ala.org.au", directory = media_dir) z <- galah_call() |> - galah_identify("Candovia aberrata") |> - galah_filter(year == 2023) |> + identify("Candovia aberrata") |> + filter(year == 2023) |> atlas_media() # successfully downloads, messages number of failed downloads expect_message(collect_media(z, thumbnail = TRUE), diff --git a/tests/testthat/test-galah_filter.R b/tests/testthat/test-galah_filter.R index f35d45f4..c1c2cd96 100644 --- a/tests/testthat/test-galah_filter.R +++ b/tests/testthat/test-galah_filter.R @@ -11,17 +11,17 @@ test_that("galah_filter gives an error for single equals sign", { test_that("galah_filter works with assertions", { skip_if_offline(); skip_on_ci() count_all <- atlas_counts() |> - pull(count) + dplyr::pull(count) count_invalid_spp <- galah_call() |> filter(assertions == "INVALID_SCIENTIFIC_NAME") |> count() |> collect() |> - pull(count) + dplyr::pull(count) count_valid_spp <- galah_call() |> filter(assertions != "INVALID_SCIENTIFIC_NAME") |> count() |> collect() |> - pull(count) + dplyr::pull(count) expect_lt(count_invalid_spp, count_all) expect_lt(count_valid_spp, count_all) expect_lt(count_invalid_spp, count_valid_spp) @@ -33,17 +33,17 @@ test_that("galah_filter handles multiple assertions", { skip_if_offline(); skip_on_ci() # OR statements all_records <- atlas_counts() |> - pull(count) + dplyr::pull(count) either_valid <- galah_call() |> galah_filter(assertions != c("INVALID_SCIENTIFIC_NAME", "COORDINATE_INVALID")) |> count() |> collect() |> - pull(count) + dplyr::pull(count) either_invalid <- galah_call() |> filter(assertions == c("INVALID_SCIENTIFIC_NAME", "COORDINATE_INVALID")) |> count() |> collect() |> - pull(count) + dplyr::pull(count) expect_lt(either_valid, all_records) expect_lt(either_invalid, all_records) expect_lt(either_invalid, either_valid) @@ -55,13 +55,13 @@ test_that("galah_filter handles multiple assertions", { assertions == "COORDINATE_INVALID") |> count() |> collect() |> - pull(count) + dplyr::pull(count) both_valid <- galah_call() |> filter(assertions != "INVALID_SCIENTIFIC_NAME", assertions != "COORDINATE_INVALID") |> count() |> collect() |> - pull(count) + dplyr::pull(count) expect_lt(both_valid, all_records) expect_lt(both_invalid, all_records) expect_lt(both_invalid, both_valid) diff --git a/tests/testthat/test-search_all.R b/tests/testthat/test-search_all.R index e65f0bd1..6c6948be 100644 --- a/tests/testthat/test-search_all.R +++ b/tests/testthat/test-search_all.R @@ -29,7 +29,7 @@ test_that("search_all returns correct output for type", { test_that("search_all returns error when missing query", { skip_if_offline(); skip_on_ci() - expect_error(search_all(profiles), "We didn't detect a search query") + expect_error(search_all(profiles), "We didn't detect a search") expect_error(search_all(fields, blah)) }) @@ -126,7 +126,7 @@ test_that("search_fields returns a filtered result", { test_that("search_fields helpful warning with blank argument", { skip_if_offline(); skip_on_ci() - expect_error(search_fields(), "We didn't detect a search query.") + expect_error(search_fields(), "We didn't detect a search") }) test_that("search_licenses returns a filtered result", { diff --git a/tests/testthat/test-search_taxa.R b/tests/testthat/test-search_taxa.R index d73ab68a..2fcef28f 100644 --- a/tests/testthat/test-search_taxa.R +++ b/tests/testthat/test-search_taxa.R @@ -16,8 +16,13 @@ test_that("search_taxa works for multiple queries", { test_that("search_taxa handles data.frame input", { skip_if_offline(); skip_on_ci() - taxa <- search_taxa( - data.frame(genus = c("Banksia", "Microseris"), kingdom = "Plantae")) + test_df <- data.frame(genus = c("Banksia", "Microseris"), + kingdom = "Plantae") + x <- request_metadata() |> + identify(test_df) |> + collect() + taxa <- search_taxa(test_df) + expect_true(identical(taxa, x)) expect_equal(nrow(taxa), 2) }) @@ -95,15 +100,9 @@ test_that("search_taxa handles name issues", { expect_warning(search_taxa("Microseris")) }) -test_that("search_taxa handles multiple issues", { +test_that("search_taxa give an error when homonym is returned with other issues", { skip_if_offline(); skip_on_ci() - expected <- c("homonym, parentChildSynonym") - result <- search_taxa("Gallinago sp.") - - expect_no_error(search_taxa("Gallinago sp.")) - expect_equal(length(result$issues), 1) - expect_equal(result$issues, expected) # NOTE: This test might be too rigid - + expect_warning(search_taxa("Gallinago sp.")) }) test_that("search_taxa errors nicely when piped in galah_call", { diff --git a/tests/testthat/test-show_values.R b/tests/testthat/test-show_values.R index 88086319..7ef23bdb 100644 --- a/tests/testthat/test-show_values.R +++ b/tests/testthat/test-show_values.R @@ -49,7 +49,7 @@ test_that("show_values accepts search & show_all inputs from lists", { test_that("search_values returns helpful error when missing query", { skip_if_offline(); skip_on_ci() expect_error(search_values(), "Missing information for values lookup") - expect_error(search_all(fields, "cl22") |> search_values(), "didn't detect a search query") + expect_error(search_all(fields, "cl22") |> search_values(), "didn't detect a search") }) test_that("search_values returns filtered results for fields", { From f8996c017e0e73416731f7bafdf7fbc1d77394cd Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Fri, 5 Sep 2025 16:01:56 +1000 Subject: [PATCH 19/94] Clean up code as per R style guide, commit 2 - remove all unnecessary importFrom statements - specify package for imported functions - switch to cli_abort() and friends --- NAMESPACE | 49 ----------- R/arrange.R | 2 +- R/as_query-distributions.R | 6 +- R/as_query-media.R | 15 ++-- R/as_query-occurrences.R | 11 ++- R/as_query-occurrences_count.R | 2 - R/as_query-occurrences_doi.R | 2 +- R/as_query-species.R | 9 +- R/as_query-species_count.R | 6 +- R/as_query-taxa.R | 8 +- R/as_query-unnest.R | 1 - R/as_query.R | 4 +- R/atlas_media.R | 8 +- R/atlas_species.R | 1 - R/atlas_taxonomy.R | 103 ++++++++++------------ R/build_query.R | 50 +++++------ R/check.R | 85 +++++++++--------- R/check_internal_cache.R | 5 +- R/coalesce.R | 3 +- R/collapse.R | 1 - R/collapse_occurrences_count_atlas.R | 4 +- R/collapse_profile_values.R | 5 +- R/collect.R | 2 +- R/collect_distributions.R | 6 +- R/collect_media.R | 1 - R/collect_metadata.R | 11 +-- R/collect_occurrences.R | 55 ++++++------ R/collect_taxa.R | 16 ++-- R/collect_unnest.R | 51 +++++------ R/compute_occurrences.R | 19 ++-- R/count-data_request.R | 5 +- R/galah-package.R | 2 + R/galah_apply_profile.R | 21 ++--- R/galah_bbox.R | 70 +++++++-------- R/galah_call.R | 2 - R/galah_filter.R | 1 - R/galah_group_by.R | 10 +-- R/galah_identify.R | 50 +---------- R/galah_polygon.R | 126 +++++++++++++-------------- R/galah_radius.R | 99 +++++++++------------ R/handle_quosures.R | 22 +++-- R/handle_quosures_GBIF.R | 3 +- R/handle_request_objects.R | 1 - R/messages.R | 7 +- R/print.R | 83 +++++++++--------- R/query_API.R | 1 - R/read_zip.R | 26 +++--- R/search_all.R | 3 +- R/show_all.R | 7 +- R/show_values.R | 47 ++++------ R/slice_head.R | 7 +- R/tidyverse.R | 2 +- R/utilities_occurrences.R | 2 +- man/identify.data_request.Rd | 8 +- man/select.data_request.Rd | 9 +- tests/testthat/test-galah_bbox.R | 55 +++++++----- tests/testthat/test-galah_filter.R | 2 +- tests/testthat/test-galah_identify.R | 14 +-- tests/testthat/test-galah_polygon.R | 12 +-- tests/testthat/test-galah_select.R | 24 ++--- tests/testthat/test-show_all.R | 13 ++- 61 files changed, 548 insertions(+), 727 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 89e8d78b..fc1be97c 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -109,66 +109,17 @@ export(show_values) export(slice_head) export(st_crop) export(unnest) -importFrom(cli,col_magenta) -importFrom(cli,col_yellow) -importFrom(crayon,make_style) -importFrom(crayon,silver) importFrom(dplyr,arrange) -importFrom(dplyr,bind_rows) importFrom(dplyr,collapse) importFrom(dplyr,collect) importFrom(dplyr,compute) importFrom(dplyr,count) importFrom(dplyr,filter) importFrom(dplyr,group_by) -importFrom(dplyr,mutate) -importFrom(dplyr,relocate) -importFrom(dplyr,rename) importFrom(dplyr,select) importFrom(dplyr,slice_head) -importFrom(glue,glue) -importFrom(glue,glue_collapse) -importFrom(glue,glue_data) importFrom(graphics,identify) -importFrom(httr2,url_build) -importFrom(httr2,url_parse) importFrom(lifecycle,badge) -importFrom(lifecycle,deprecate_stop) -importFrom(lifecycle,deprecate_warn) -importFrom(potions,brew) -importFrom(potions,pour) -importFrom(purrr,list_flatten) -importFrom(purrr,list_transpose) -importFrom(purrr,pluck) -importFrom(purrr,pluck_depth) importFrom(rlang,.data) -importFrom(rlang,abort) -importFrom(rlang,as_label) importFrom(rlang,caller_env) -importFrom(rlang,enquos) -importFrom(rlang,format_error_bullets) -importFrom(rlang,inform) -importFrom(rlang,is_list) -importFrom(rlang,is_quosure) -importFrom(rlang,is_string) -importFrom(rlang,try_fetch) -importFrom(rlang,warn) -importFrom(sf,st_as_sf) -importFrom(sf,st_as_sfc) -importFrom(sf,st_as_text) -importFrom(sf,st_bbox) -importFrom(sf,st_cast) -importFrom(sf,st_coordinates) importFrom(sf,st_crop) -importFrom(sf,st_crs) -importFrom(sf,st_geometry) -importFrom(sf,st_geometry_type) -importFrom(sf,st_is_empty) -importFrom(sf,st_is_simple) -importFrom(sf,st_is_valid) -importFrom(stringr,str_detect) -importFrom(stringr,str_replace) -importFrom(stringr,str_to_title) -importFrom(stringr,str_trim) -importFrom(tibble,as_tibble) -importFrom(tibble,tibble) diff --git a/R/arrange.R b/R/arrange.R index b5578b77..6278da4c 100644 --- a/R/arrange.R +++ b/R/arrange.R @@ -47,7 +47,7 @@ #' @name arrange.data_request #' @export arrange.data_request <- function(.data, ...){ - dots <- enquos(..., .ignore_empty = "all") + dots <- rlang::enquos(..., .ignore_empty = "all") parsed_dots <- parse_quosures_basic(dots) if(length(parsed_dots) == 2 & all(names(parsed_dots) %in% c("variable", "direction"))){ diff --git a/R/as_query-distributions.R b/R/as_query-distributions.R index 94d80ae2..dfa47efb 100644 --- a/R/as_query-distributions.R +++ b/R/as_query-distributions.R @@ -1,11 +1,13 @@ #' Internal function to run `as_query()` for type `data/distributions` #' @noRd #' @keywords Internal -as_query_distributions_data <- function(.query){ +as_query_distributions_data <- function(.query, + error_call = rlang::caller_env()){ identify_supplied <- !is.null(.query$identify) filter_supplied <- !is.null(.query$filter) if(identify_supplied & filter_supplied){ - cli::cli_abort("`collapse(type = 'distributions')` only accepts one of `filter()` or `identify()`, not both") + cli::cli_abort("`collapse(type = 'distributions')` only accepts one of `filter()` or `identify()`, not both", + call = error_call) } if(identify_supplied){ url <- url_lookup("data/distributions-taxa", diff --git a/R/as_query-media.R b/R/as_query-media.R index 0eb84029..f88dc4bc 100644 --- a/R/as_query-media.R +++ b/R/as_query-media.R @@ -2,7 +2,8 @@ #' @param .query An object of class `metadata_request` (from `request_metadata()`) #' @noRd #' @keywords Internal -as_query_media_metadata <- function(.query){ +as_query_media_metadata <- function(.query, + error_call = rlang::caller_env()){ # NOTE: # this function currently assumes that the user has passed an occurrence # tibble verbatim to filter, i.e. @@ -24,7 +25,8 @@ as_query_media_metadata <- function(.query){ media_ids <- media_ids[!is.na(media_ids)] names(media_ids) <- NULL }else{ - cli::cli_abort("Media metadata not found in supplied tibble") + cli::cli_abort("Media metadata not found in supplied tibble", + call = error_call) } result <- list( @@ -42,11 +44,13 @@ as_query_media_metadata <- function(.query){ #' @noRd #' @keywords Internal as_query_media_files <- function(.query, - thumbnail = FALSE + thumbnail = FALSE, + error_call = rlang::caller_env() ){ # handle filters if(is.null(.query$filter)){ - cli::cli_abort("`collapse()` requires a `filter()` argument to function.") + cli::cli_abort("`collapse()` requires a `filter()` argument to function.", + call = error_call) } df <- .query$filter if(any(colnames(df) == "media_id")){ @@ -54,7 +58,8 @@ as_query_media_files <- function(.query, }else if(any(colnames(df) == "image_id")){ identifiers <- df$image_id }else{ - cli::cli_abort("No valid identifiers found in supplied data.") + cli::cli_abort("No valid identifiers found in supplied data.", + call = error_call) } path <- build_file_path(ids = identifiers, types = df$mimetype) if(any(colnames(df) == "image_url")){ diff --git a/R/as_query-occurrences.R b/R/as_query-occurrences.R index 779d329c..c9f75a0a 100644 --- a/R/as_query-occurrences.R +++ b/R/as_query-occurrences.R @@ -1,11 +1,13 @@ #' Internal function to convert `data_request` with `type = "occurrences"` to a `query` #' @noRd #' @keywords Internal -as_query_occurrences <- function(.query){ +as_query_occurrences <- function(.query, + error_call = rlang::caller_env()){ if(is.null(.query$filter) & is.null(.query$identify) & is.null(.query$geolocate)){ - cli::cli_abort("No filters supplied to `collapse()` with `type = \"occurrences\"`") + cli::cli_abort("No filters supplied to `collapse()` with `type = \"occurrences\"`", + call = error_call) } switch(potions::pour("atlas", "region"), "United Kingdom" = as_query_occurrences_uk(.query), @@ -93,14 +95,15 @@ as_query_occurrences_la <- function(.query){ qa = "`ASSERTIONS_PLACEHOLDER`", facet = "false", # not tested emailNotify = email_notify(), - sourceTypeId = {pour("atlas", "region") |> + sourceTypeId = {potions::pour("atlas", "region") |> source_type_id_lookup()}, reasonTypeId = potions::pour("user", "download_reason_id"), email = potions::pour("user", "email"), dwcHeaders = "true") # DOI conditional on this service being offered if (!is.null(.query$mint_doi) & - pour("atlas", "region") == "Australia") { + potions::pour("atlas", "region") == "Australia" + ) { query$mintDoi <- .query$mint_doi } # build url diff --git a/R/as_query-occurrences_count.R b/R/as_query-occurrences_count.R index 4853a434..9cfbc0cf 100644 --- a/R/as_query-occurrences_count.R +++ b/R/as_query-occurrences_count.R @@ -70,8 +70,6 @@ as_query_occurrences_count_atlas <- function(identify = NULL, } #' collapse for counts on GBIF -#' @importFrom httr2 url_build -#' @importFrom httr2 url_parse #' @keywords Internal #' @noRd as_query_occurrences_count_gbif <- function(identify = NULL, diff --git a/R/as_query-occurrences_doi.R b/R/as_query-occurrences_doi.R index 5fd9ca03..54dc240d 100644 --- a/R/as_query-occurrences_doi.R +++ b/R/as_query-occurrences_doi.R @@ -2,7 +2,7 @@ #' @noRd #' @keywords Internal as_query_occurrences_doi <- function(.query, - error_call = caller_env()){ + error_call = rlang::caller_env()){ if(is.null(.query$filter)){ cli::cli_abort("A DOI must be specified using `filter(doi == \"my-doi-here\")`.", call = error_call) diff --git a/R/as_query-species.R b/R/as_query-species.R index 452fa5e7..5fde8b33 100644 --- a/R/as_query-species.R +++ b/R/as_query-species.R @@ -35,7 +35,7 @@ as_query_species_atlas <- function(.query){ .query$data_profile), emailNotify = email_notify(), sourceTypeId = 2004, - reasonTypeId = pour("user", "download_reason_id"), + reasonTypeId = potions::pour("user", "download_reason_id"), email = potions::pour("user", "email"), facets = .query$group_by$name, parse_select_species(.query$select) @@ -61,10 +61,11 @@ as_query_species_atlas <- function(.query){ #' @keywords Internal parse_select_species <- function(.select){ # parse labels for supplied field names - quosure_check <- purrr::map(.select, is_quosure) |> + quosure_check <- purrr::map(.select, rlang::is_quosure) |> unlist() if(any(quosure_check)){ - named_fields <- purrr::map(.select[quosure_check], as_label) |> + named_fields <- purrr::map(.select[quosure_check], + rlang::as_label) |> unlist() }else{ named_fields <- NULL @@ -87,7 +88,7 @@ parse_select_species <- function(.select){ if(any(name_check)){ unexpected_names <- glue::glue_collapse(named_fields[name_check], last = " and ") c("When type = 'species', `select()` only accepts 'counts', 'synonyms' or 'lists' as valid fields.", - i = glue("Unexpected fields: {unexpected_names}")) |> + i = glue::glue("Unexpected fields: {unexpected_names}")) |> cli::cli_warn() } # parse 'correct' names diff --git a/R/as_query-species_count.R b/R/as_query-species_count.R index 13c87269..8eb37b0b 100644 --- a/R/as_query-species_count.R +++ b/R/as_query-species_count.R @@ -2,9 +2,11 @@ #' @keywords Internal #' @param .query an object of class `data_request` #' @noRd -as_query_species_count <- function(.query){ +as_query_species_count <- function(.query, + error_call = rlang::caller_env()){ if(is_gbif()){ - cli::cli_abort("`count()` is not supported for GBIF with type = 'species'") + cli::cli_abort("`count()` is not supported for GBIF with type = 'species'", + call = error_call) }else{ function_name <- "as_query_species_count_atlas" arg_names <- names(formals(as_query_species_count_atlas)) diff --git a/R/as_query-taxa.R b/R/as_query-taxa.R index 10cf8d12..cdaaab62 100644 --- a/R/as_query-taxa.R +++ b/R/as_query-taxa.R @@ -63,8 +63,8 @@ as_query_taxa_multiple <- function(.query){ # build object and return result <- list(type = "metadata/taxa-multiple", - url = tibble(url = urls, - search_term = search_terms), + url = tibble::tibble(url = urls, + search_term = search_terms), headers = build_headers()) class(result) <- "query" return(result) @@ -100,8 +100,8 @@ as_query_identifiers <- function(.query){ } # build object and return result <- list(type = "metadata/identifiers", - url = tibble(url = urls, - search_term = search_terms), + url = tibble::tibble(url = urls, + search_term = search_terms), headers = build_headers()) class(result) <- "query" return(result) diff --git a/R/as_query-unnest.R b/R/as_query-unnest.R index 7bb58725..8d89b24f 100644 --- a/R/as_query-unnest.R +++ b/R/as_query-unnest.R @@ -66,7 +66,6 @@ as_query_profiles_unnest <- function(.query){ #' Internal function to `as_query()` for #' `request_metadata(type = "taxa") |> unnest()` -#' @importFrom rlang abort #' @noRd #' @keywords Internal as_query_taxa_unnest <- function(.query){ diff --git a/R/as_query.R b/R/as_query.R index 8be160a7..c0e3b1a9 100644 --- a/R/as_query.R +++ b/R/as_query.R @@ -68,7 +68,7 @@ as_query.data_request <- function(x, ...){ "species" = as_query_species(x), "species-count" = as_query_species_count(x), "distributions" = as_query_distributions_data(x), - cli::cli_abort("unrecognised 'type'")) |> + cli::cli_abort("Unrecognised 'type'")) |> structure(class = c("query", "list")) } @@ -97,7 +97,7 @@ as_query.metadata_request <- function(x, ...){ "taxa" = as_query_taxa(x), "taxa-unnest" = as_query_taxa_unnest(x), "identifiers" = as_query_identifiers(x), - cli::cli_abort("unrecognised 'type'") + cli::cli_abort("Unrecognised 'type'") ) |> structure(class = c("query", "list")) } diff --git a/R/atlas_media.R b/R/atlas_media.R index 8b4670e5..33c9e66e 100644 --- a/R/atlas_media.R +++ b/R/atlas_media.R @@ -31,7 +31,7 @@ atlas_media <- function(request = NULL, # ensure a filter is present (somewhat redundant with `collapse`) if(is.null(.query$filter)){ - abort("You must specify a valid `filter()` to use `atlas_media()`") + cli::cli_abort("You must specify a valid `filter()` to use `atlas_media()`") } # ensure media columns are present in `select` @@ -108,7 +108,8 @@ atlas_media <- function(request = NULL, #' Set filters that work for media in each atlas #' @noRd #' @keywords Internal -parse_regional_media_filters <- function(present_fields){ +parse_regional_media_filters <- function(present_fields, + error_call = rlang::caller_env()){ atlas <- potions::pour("atlas", "region") switch(atlas, @@ -127,6 +128,7 @@ parse_regional_media_filters <- function(present_fields){ paste0("IDsCount") glue::glue("{filter_fields}:[1 TO *]")}, "United Kingdom" = "(all_image_url:*)", # !is.na(all_image_url), - cli::cli_abort("`atlas_media` is not supported for atlas = {atlas}") + cli::cli_abort("`atlas_media` is not supported for atlas = {atlas}", + call = error_call) ) } \ No newline at end of file diff --git a/R/atlas_species.R b/R/atlas_species.R index a73a45d3..1ea38e69 100644 --- a/R/atlas_species.R +++ b/R/atlas_species.R @@ -1,6 +1,5 @@ #' @rdname atlas_ #' @order 3 -#' @importFrom potions pour #' @export atlas_species <- function(request = NULL, identify = NULL, diff --git a/R/atlas_taxonomy.R b/R/atlas_taxonomy.R index 02036342..806ba619 100644 --- a/R/atlas_taxonomy.R +++ b/R/atlas_taxonomy.R @@ -5,14 +5,6 @@ #' authoritative sources. Default is `"biodiversity.org.au"` for Australia, #' which is the infix common to National Species List IDs; use #' `NULL` to suppress source filtering. Regular expressions are supported. -#' @importFrom dplyr bind_rows -#' @importFrom dplyr filter -#' @importFrom dplyr mutate -#' @importFrom dplyr select -#' @importFrom potions pour -#' @importFrom purrr list_flatten -#' @importFrom purrr pluck_depth -#' @importFrom stringr str_to_title #' @keywords internal #' @export atlas_taxonomy <- function(request = NULL, @@ -36,51 +28,47 @@ atlas_taxonomy <- function(request = NULL, identify(.query$identify$search_term) |> collect() start_row <- taxa_info |> - mutate(name = str_to_title(taxa_info$scientific_name), - parent_taxon_concept_id = NA) |> - select("name", - "rank", - "taxon_concept_id", - "parent_taxon_concept_id") + dplyr::mutate(name = stringr::str_to_title(taxa_info$scientific_name), + parent_taxon_concept_id = NA) |> + dplyr::select("name", + "rank", + "taxon_concept_id", + "parent_taxon_concept_id") # build then flatten a tree taxonomy_tree <- drill_down_taxonomy(start_row, down_to = .query$filter$value, constrain_ids = constrain_ids) - for(i in seq_len(pluck_depth(taxonomy_tree))){ - taxonomy_tree <- list_flatten(taxonomy_tree) + for(i in seq_len(purrr::pluck_depth(taxonomy_tree))){ + taxonomy_tree <- purrr::list_flatten(taxonomy_tree) } - result <- bind_rows(taxonomy_tree) + result <- dplyr::bind_rows(taxonomy_tree) # remove rows with ranks that are too low index <- rank_index(result$rank) down_to_index <- rank_index(.query$filter$value) result |> - filter({{index}} <= {{down_to_index}} | is.na({{index}})) |> - select("name", - "rank", - "parent_taxon_concept_id", - "taxon_concept_id") + dplyr::filter({{index}} <= {{down_to_index}} | is.na({{index}})) |> + dplyr::select("name", + "rank", + "parent_taxon_concept_id", + "taxon_concept_id") } #' Internal function to check whether constraints have been passed -#' @importFrom potions pour #' @noRd #' @keywords Internal check_constraints <- function(args, call){ if(any(names(call) == "constrain_ids")){ # i.e. if user specifies an argument call$constrain_ids }else{ # if NULL only occurs because no argument is set - if(pour("atlas", "region") == "Australia"){ + if(potions::pour("atlas", "region") == "Australia"){ "biodiversity.org.au" } } } #' Internal recursive function to get child taxa -#' @importFrom dplyr mutate -#' @importFrom dplyr select -#' @importFrom rlang .data #' @noRd #' @keywords Internal drill_down_taxonomy <- function(df, @@ -99,10 +87,13 @@ drill_down_taxonomy <- function(df, return(df) }else{ result <- children |> - mutate(name = str_to_title(children$name), - taxon_concept_id = children$guid, - parent_taxon_concept_id = children$parentGuid) |> - select("name", "rank", "taxon_concept_id", "parent_taxon_concept_id") + dplyr::mutate(name = str_to_title(children$name), + taxon_concept_id = children$guid, + parent_taxon_concept_id = children$parentGuid) |> + dplyr::select("name", + "rank", + "taxon_concept_id", + "parent_taxon_concept_id") if(!is.null(constrain_ids)){ result <- result |> constrain_id(constrain_to = constrain_ids) @@ -122,70 +113,66 @@ drill_down_taxonomy <- function(df, } #' Internal function to check `identify` term is specified correctly -#' @importFrom rlang abort #' @noRd #' @keywords Internal -check_identify <- function(.query, error_call = caller_env()){ +check_identify <- function(.query, + error_call = rlang::caller_env()){ if(is.null(.query$identify)){ - bullets <- c( - "Argument `identify` is missing, with no default.", - i = "Did you forget to specify a taxon?") - abort(bullets, call = error_call) + c("Argument `identify` is missing, with no default.", + i = "Did you forget to specify a taxon?") |> + cli::cli_abort(call = error_call) } if(nrow(.query$identify) > 1){ number_of_taxa <- nrow(.query$identify) - bullets <- c( - "Can't provide tree more than one taxon to start with.", + c("Can't provide tree more than one taxon to start with.", i = "atlas_taxonomy` only accepts a single taxon at a time.", - x = glue("`identify` has length of {number_of_taxa}.") - ) - abort(bullets, call = error_call) + x = "`identify` has length of {number_of_taxa}.") |> + cli::cli_abort(call = error_call) } } #' Internal function to check `identify` term is specified correctly -#' @importFrom rlang abort #' @noRd #' @keywords Internal -check_down_to <- function(.query, error_call = caller_env()){ +check_down_to <- function(.query, + error_call = rlang::caller_env()){ if (is.null(.query$filter$value)) { - bullets <- c( - "Argument `rank` is missing, with no default.", + c("Argument `rank` is missing, with no default.", i = "Use `show_all(ranks)` to display valid ranks", i = "Use `filter(rank == chosen_rank)` to specify a rank" ) - abort(bullets, call = error_call) + cli::cli_abort(call = error_call) } down_to <- tolower(.query$filter$value) if(!any(show_all_ranks()$name == down_to)){ - bullets <- c( - "Invalid taxonomic rank provided.", + c("Invalid taxonomic rank provided.", i = "The rank provided to `rank` must be a valid taxonomic rank.", - x = glue("{down_to} is not a valid rank.") - ) - abort(bullets, call = error_call) + x = "{down_to} is not a valid rank.") |> + cli::cli_abort(call = error_call) } } #' Internal function to only return GUIDs that match particular criteria -#' @importFrom purrr list_transpose -#' @importFrom dplyr filter #' @noRd #' @keywords Internal constrain_id <- function(df, constrain_to){ - check_list <- lapply(constrain_to, function(a){grepl(a, df$taxon_concept_id)}) - check_result <- lapply(list_transpose(check_list), any) |> + check_list <- purrr::map(constrain_to, + function(a){grepl(a, df$taxon_concept_id)}) + check_result <- purrr::map(purrr::list_transpose(check_list), any) |> unlist() - df |> filter(check_result) + df |> + dplyr::filter(check_result) } # Return the index of a taxonomic rank- # lower index corresponds to higher up the tree +#' @noRd +#' @keywords Internal rank_index <- function(x) { all_ranks <- show_all_ranks() - lapply(x, function(a){ + purrr::map(x, function(a){ if (a %in% all_ranks$name) { return(all_ranks$id[all_ranks$name == a]) }else{ diff --git a/R/build_query.R b/R/build_query.R index e60b5479..4bea431e 100644 --- a/R/build_query.R +++ b/R/build_query.R @@ -22,7 +22,6 @@ build_headers <- function(){ #' Build query list from constituent arguments #' @noRd #' @keywords Internal -#' @importFrom potions pour build_query <- function(identify = NULL, filter = NULL, location = NULL, @@ -41,7 +40,7 @@ build_query <- function(identify = NULL, filter_query <- NULL } else { if(!inherits(filter, "data.frame")){ - abort("`filter` must be a `data.frame` or `tibble`") + cli::cli_abort("`filter` must be a `data.frame` or `tibble`") } if (nrow(filter) == 0) { filter_query <- NULL @@ -91,14 +90,14 @@ build_single_fq <- function(query){ !grepl("assertions", fq) & # assertions don't need additional brackets !grepl("^-\\(", fq) # negative query already has brackets if(any(missing_brackets)){ - fq[missing_brackets] <- paste0("(", fq[missing_brackets], ")") + fq[missing_brackets] <- glue::glue("({fq[missing_brackets]})") } # add brackets to non-negative AND statements # (adding additional brackets to negative statements breaks them) if(any(!grepl("^-\\(", fq))) { - fq_single <- glue::glue_collapse(glue("{fq}"), "AND") + fq_single <- glue::glue_collapse(glue::glue("{fq}"), "AND") } else { - fq_single <- glue::glue_collapse(glue("({fq})"), "AND") + fq_single <- glue::glue_collapse(glue::glue("({fq})"), "AND") } c(fq = fq_single, query[names(query) != "fq"]) }else{ @@ -128,8 +127,6 @@ build_filter_query <- function(filters) { } #' Sub-function to `build_query()` for taxa -#' @importFrom glue glue -#' @importFrom glue glue_collapse #' @noRd #' @keywords Internal build_taxa_query <- function(ids) { @@ -139,10 +136,10 @@ build_taxa_query <- function(ids) { }else{ wrapped_ids <- paste0("\"", ids, "\"") id_tag <- "lsid" - glue( + glue::glue( "({id_tag}:", - glue_collapse(wrapped_ids, - sep = glue(" OR {id_tag}:")), + glue::glue_collapse(wrapped_ids, + sep = glue::glue(" OR {id_tag}:")), ")") } } @@ -152,12 +149,11 @@ build_taxa_query <- function(ids) { #' It is pretty messy, as: #' 1. ALA returns empty lists and NULL values in some fields, and #' 2. tibble() and friends don't handle list-columns well -#' @importFrom tibble as_tibble #' @noRd #' @keywords Internal build_tibble_from_nested_list <- function(result){ # handle normal columns - source_tibble <- lapply(result, function(a){ + source_tibble <- purrr::map(result, function(a){ if(is.null(a)){ as.character(NA) }else if(length(a) > 1){ @@ -168,10 +164,10 @@ build_tibble_from_nested_list <- function(result){ a } }) |> - as_tibble() + tibble::as_tibble() # handle nested columns - list_cols <- lapply(result, - function(a){is.list(a) & length(a) > 0}) |> + list_cols <- purrr::map(result, + function(a){is.list(a) & length(a) > 0}) |> unlist() if(any(list_cols)){ list_data <- result[list_cols] @@ -190,24 +186,18 @@ build_tibble_from_nested_list <- function(result){ #' Build a valid wkt string from a spatial polygon #' Internal function to `galah_bbox` and `galah_polygon()` -#' @importFrom sf st_as_text -#' @importFrom sf st_cast -#' @importFrom sf st_geometry -#' @importFrom sf st_geometry_type -#' @importFrom sf st_is_simple #' @noRd #' @keywords Internal -build_wkt <- function(polygon, error_call = caller_env()) { - if (st_geometry_type(polygon) == "POLYGON") { - polygon <- st_cast(polygon, "MULTIPOLYGON") +build_wkt <- function(polygon, + error_call = caller_env()) { + if (sf::st_geometry_type(polygon) == "POLYGON") { + polygon <- sf::st_cast(polygon, "MULTIPOLYGON") } - if (!st_is_simple(polygon)) { - bullets <- c( - "The area provided to `galah_bbox` is too complex. ", - i = "See `?sf::st_simplify` for how to simplify geospatial objects." - ) - abort(bullets, call = caller_env()) + if (!sf::st_is_simple(polygon)) { + c("The area provided to `galah_bbox` is too complex. ", + i = "See `?sf::st_simplify` for how to simplify geospatial objects.") |> + cli::cli_abort(call = error_call) } - wkt <- st_as_text(st_geometry(polygon)) + wkt <- sf::st_as_text(sf::st_geometry(polygon)) wkt } \ No newline at end of file diff --git a/R/check.R b/R/check.R index 7350898d..8c259657 100644 --- a/R/check.R +++ b/R/check.R @@ -80,19 +80,20 @@ check_download_filename <- function(file, #' Subfunction to `check_login()` #' @noRd #' @keywords Internal -check_email <- function(.query){ +check_email <- function(.query, + call = rlang::caller_env()){ if(is_gbif()){ # actually we check the userpwd entry here email_text <- .query$options$userpwd if(email_text == ":"){ - abort_email_missing() + abort_email_missing(call = call) } }else{ email_text <- httr2::url_parse(.query$url)$query$email if(is.null(email_text)) { - abort_email_missing() + abort_email_missing(call = call) }else if(email_text == ""){ - abort_email_missing() + abort_email_missing(call = call) } } .query @@ -113,7 +114,9 @@ check_files_filter <- function(x){ #' check that objects passed within `galah_filter` have correct structure #' @noRd #' @keywords Internal -check_filter_tibbles <- function(x){ # where x is a list of tibbles +check_filter_tibbles <- function(x, # where x is a list of tibbles + error_call = rlang::caller_env() + ){ syntax_valid <- lapply(x, function(a){ if(length(colnames(a)) == 4){ all(colnames(a) %in% c("variable", "logical", "value", "query")) @@ -124,14 +127,16 @@ check_filter_tibbles <- function(x){ # where x is a list of tibbles unlist() |> all() if(!syntax_valid){ - cli::cli_abort("There was a problem with `filter`, did you use correct syntax?") + cli::cli_abort("There was a problem with `filter`, did you use correct syntax?", + call = error_call) } } #' Internal function to check whether fields are valid #' @noRd #' @keywords Internal -check_fields <- function(.query) { +check_fields <- function(.query, + error_call = rlang::caller_env()) { if(potions::pour("package", "run_checks")){ if(is_gbif()){ @@ -158,7 +163,9 @@ check_fields <- function(.query) { "Can't use fields that don't exist.", i = "Use `search_all(fields)` to find a valid field ID.", x = glue("Can't find field(s) in"), - glue::glue(" ", format_error_bullets(invalid_fields_message)) + glue::glue(" ", + format_error_bullets(invalid_fields_message), + call = error_call) ) cli::cli_abort(bullets) } @@ -167,12 +174,11 @@ check_fields <- function(.query) { } #' Check whether fields match those requested, and if not, inform the user -#' @importFrom rlang warn -#' @importFrom rlang caller_env #' @noRd #' @keywords Internal check_field_identities <- function(df, - .query){ + .query, + error_call = rlang::caller_env()){ if(!is.null(.query$fields) & potions::pour("package", "run_checks", .pkg = "galah") & potions::pour("atlas", "region", .pkg = "galah") %in% c("Australia", "Spain", "Sweden") @@ -193,7 +199,8 @@ check_field_identities <- function(df, names(missing_fields) <- rep("*", length(missing_fields)) c("The following fields, requested in your query, were not downloaded:", missing_fields) |> - cli::cli_warn(bullets) + cli::cli_warn(bullets, + call = error_call) } # check for additions added_check <- !(field_names %in% .query$fields) @@ -202,7 +209,7 @@ check_field_identities <- function(df, names(added_fields) <- rep("*", length(added_fields)) c("The following fields were downloaded, but weren't requested in your query:", added_fields) |> - cli::cli_warn() + cli::cli_warn(call = error_call) } } df @@ -370,7 +377,7 @@ check_identifiers <- function(.query){ #' @noRd #' @keywords Internal check_identifiers_la <- function(.query, - error_call = caller_env()){ + error_call = rlang::caller_env()){ # FIXME: test if every >1 urls here if(inherits(.query$url, "data.frame")){ url <- httr2::url_parse(.query$url$url[1]) @@ -397,7 +404,8 @@ check_identifiers_la <- function(.query, .query$url[1] <- httr2::url_build(url) }else{ # this only happens if there is a bug earlier in the code - abort("The query has a taxonomic placeholder, but no taxon search has been run.") + cli::cli_abort("The query has a taxonomic placeholder, but no taxon search has been run.", + call = error_call) } } }else{ @@ -410,7 +418,8 @@ check_identifiers_la <- function(.query, reserved = TRUE) .query$url[1] <- sub("%60TAXON_PLACEHOLDER%60", taxa_id, .query$url[1]) }else{ - rlang::abort("The query has a taxonomic placeholder, but no taxon search has been run.") + cli::cli_abort("The query has a taxonomic placeholder, but no taxon search has been run.", + call = error_call) } } } @@ -421,21 +430,20 @@ check_identifiers_la <- function(.query, #' Called by `compute()` #' @noRd #' @keywords Internal -#' @importFrom rlang caller_env check_login <- function(.query, - error_call = caller_env()) { + error_call = rlang::caller_env()) { # Check for valid email for occurrences or species queries for all providers if(is_gbif()){ if(grepl("^data", .query$type)){ - check_email(.query) - check_password(.query) + check_email(.query, call = error_call) + check_password(.query, call = error_call) } }else{ if(.query$type %in% c("data/occurrences", "data/species")){ switch(potions::pour("atlas", "region"), "United Kingdom" = {}, - check_email(.query)) - } + check_email(.query, call = error_call)) + } } .query } @@ -486,35 +494,31 @@ check_media_cols_present <- function(.query, #' Internal function called by `filter()` et al #' @noRd #' @keywords Internal -check_named_input <- function(dots){ +check_named_input <- function(dots, + error_call = rlang::caller_env()){ name_length <- any(length(names(dots) > 0)) & any(names(dots) != "") if(name_length){ - bullets <- c( - "We detected a named input.", - i = "This usually means that you've used `=` instead of `==`.") - cli::cli_abort(bullets) + c("We detected a named input.", + i = "This usually means that you've used `=` instead of `==`.") |> + cli::cli_abort(call = error_call) } } #' Check whether geolocate functions have >1 argument -#' @importFrom rlang warn #' @noRd #' @keywords Internal check_n_inputs <- function(dots, error_call = rlang::caller_env()) { if(length(dots) > 1){ n_geolocations <- length(dots) - c( - "More than 1 spatial area provided.", - "*" = glue("Using first location, ignoring additional {n_geolocations - 1} location(s).") - ) |> + c("More than 1 spatial area provided.", + "*" = "Using first location, ignoring additional {n_geolocations - 1} location(s).") |> cli::cli_warn(call = error_call) } } #' Internal function to ensure correct data extracted from API for LA/GBIF #' It makes all calls consistent so we only need one queue checking function -#' @importFrom stringr str_trim #' @noRd #' @keywords Internal check_occurrence_response <- function(.query, @@ -524,7 +528,7 @@ check_occurrence_response <- function(.query, if (!is.null(.query$status_code)) { error_type <- sub("\\:.*", "", .query$message) |> - str_trim() + stringr::str_trim() bullets <- c( "There was a problem with your query.", @@ -627,12 +631,11 @@ check_password <- function(.query, check_profiles <- function(.query, error_call = rlang::caller_env()){ if(!inherits(.query$url, "data.frame")){ - query <- url_parse(.query$url[1])$query + query <- httr2::url_parse(.query$url[1])$query if(!is.null(query$qualityProfile)){ profile <- query$qualityProfile if(!profile %in% .query[["metadata/profiles"]]$shortName){ - c( - "Unrecognised profile requested.", + c("Unrecognised profile requested.", i = "See `?show_all(profiles)` for valid profiles.", x = "Can't find profile `{profile}` for specified atlas.") |> cli::cli_abort(call = error_call) @@ -798,11 +801,9 @@ check_type_valid <- function(type, valid, error_call = rlang::caller_env()) { if(!any(valid == type)){ - c( - glue("Unrecognised metadata requested."), + c("Unrecognised metadata requested.", i = "See `?show_all()` for a list of valid metadata types.", - x = glue("Can't find metadata type `{type}`.") - ) |> - cli::cli_abort(call = error_call) + x = "Can't find metadata type `{type}`.") |> + cli::cli_abort(call = error_call) } } diff --git a/R/check_internal_cache.R b/R/check_internal_cache.R index a6eb1fdc..98d63462 100644 --- a/R/check_internal_cache.R +++ b/R/check_internal_cache.R @@ -5,13 +5,12 @@ #' When a named field is given, it stores that field in options("galah_internal") #' @noRd #' @keywords Internal -#' @importFrom potions pour check_internal_cache <- function(...){ # set all options ala_option_name <- "check_internal_cache" current_options <- getOption(ala_option_name) - atlas <- pour("atlas", "region") + atlas <- potions::pour("atlas", "region") user_options <- list(...) # load an archived version as the default @@ -48,7 +47,7 @@ check_internal_cache <- function(...){ internal_cache_update_needed <- function(function_name){ df <- check_internal_cache()[[function_name]] is_local <- !is.null(attr(df, "ARCHIVED")) - is_wrong_atlas <- attr(df, "region") != pour("atlas", "region") + is_wrong_atlas <- attr(df, "region") != potions::pour("atlas", "region") is_too_short <- nrow(df) < 10 result <- is_local | is_wrong_atlas | is_too_short # if any, update is needed if(length(result) < 1){result <- TRUE} # bug catcher diff --git a/R/coalesce.R b/R/coalesce.R index 41f00dbe..d816dd61 100644 --- a/R/coalesce.R +++ b/R/coalesce.R @@ -63,8 +63,7 @@ coalesce.metadata_request <- function(x, ...){ } }else if(is.null(x$filter)){ current_type <- x$type - bullets <- glue::glue("Requests of type `{current_type}` containing `unnest` must supply `filter()`.") - cli::cli_abort(bullets) + cli::cli_abort("Requests of type `{current_type}` containing `unnest` must supply `filter()`.") } } result[[(length(result) + 1)]] <- as_query(x) diff --git a/R/collapse.R b/R/collapse.R index 271631b8..455dadea 100644 --- a/R/collapse.R +++ b/R/collapse.R @@ -51,7 +51,6 @@ collapse.metadata_request <- function(x, ...){ #' @order 3 #' @param thumbnail Logical: should thumbnail-size images be returned? Defaults #' to `FALSE`, indicating full-size images are required. -#' @importFrom purrr pluck #' @export collapse.files_request <- function(x, # prefix? could be useful for file names diff --git a/R/collapse_occurrences_count_atlas.R b/R/collapse_occurrences_count_atlas.R index 83dfa575..3927fd17 100644 --- a/R/collapse_occurrences_count_atlas.R +++ b/R/collapse_occurrences_count_atlas.R @@ -133,7 +133,7 @@ collapse_occurrences_count_atlas_groupby_crossed <- function(.query, dplyr::rowwise() |> dplyr::mutate(query = dplyr::starts_with("fq") |> dplyr::c_across() |> - glue_collapse( sep = " AND ")) |> + glue::glue_collapse( sep = " AND ")) |> dplyr::select(-dplyr::starts_with("fq")) |> dplyr::ungroup() if(!is.null(fqs)){ @@ -148,7 +148,7 @@ collapse_occurrences_count_atlas_groupby_crossed <- function(.query, list(facets = facet_names[length(facet_names)]), saved_facet_queries) - url_list <- lapply(result_df$query, function(a, url){ + url_list <- purrr::map(result_df$query, function(a, url){ url$query <- c(list(fq = a), query_without_fq) httr2::url_build(url) }, url = url_final) diff --git a/R/collapse_profile_values.R b/R/collapse_profile_values.R index 4088ef2e..30161598 100644 --- a/R/collapse_profile_values.R +++ b/R/collapse_profile_values.R @@ -24,7 +24,8 @@ collapse_profile_values <- function(.query){ #' for data profiles. Only used by `collapse_profile_values()` #' @noRd #' @keywords Internal -profile_short_name <- function(profile) { +profile_short_name <- function(profile, + error_call = rlang::caller_env()) { valid_profiles <- show_all_profiles() short_name <- NA if (suppressWarnings(!is.na(as.numeric(profile)))) { @@ -46,7 +47,7 @@ profile_short_name <- function(profile) { c( "Unknown profile detected.", i = "See a listing of valid data quality profiles with `show_all_profiles()`.") |> - cli::cli_abort(call = rlang::caller_env()) + cli::cli_abort(call = error_call) }else{ short_name } diff --git a/R/collect.R b/R/collect.R index f36beec9..27b2d438 100644 --- a/R/collect.R +++ b/R/collect.R @@ -100,6 +100,6 @@ collect.computed_query <- function(x, "metadata/taxa-multiple" = collect_taxa(x), "metadata/taxa-unnest" = collect_taxa_unnest(x), "metadata/identifiers" = collect_identifiers(x), - cli::cli_abort("unrecognised `type`")) + cli::cli_abort("Unrecognised `type`")) } } diff --git a/R/collect_distributions.R b/R/collect_distributions.R index 49e2d4a9..202c1485 100644 --- a/R/collect_distributions.R +++ b/R/collect_distributions.R @@ -1,6 +1,4 @@ #' Internal function to `collect` for type `data/distributions` -#' @importFrom sf st_as_sf -#' @importFrom sf st_as_sfc #' @noRd #' @keywords Internal collect_distributions <- function(.query){ @@ -26,6 +24,6 @@ collect_distributions <- function(.query){ "label" = "area_name", "common_name" = "common_nam") |> mutate("common_name" = trimws(.data$common_name)) - result$geometry <- st_as_sfc(result$geometry, crs=4326) - return(st_as_sf(result)) + result$geometry <- sf::st_as_sfc(result$geometry, crs=4326) + return(sf::st_as_sf(result)) } \ No newline at end of file diff --git a/R/collect_media.R b/R/collect_media.R index 67d78125..be3ff3e0 100644 --- a/R/collect_media.R +++ b/R/collect_media.R @@ -25,7 +25,6 @@ collect_media_metadata <- function(.query){ #' Internal version of `collect()` for `request_files(type = "media")` #' @param object of class `files_response`, from `compute()` -#' @importFrom rlang .data #' @noRd #' @keywords Internal collect_media_files <- function(.query){ diff --git a/R/collect_metadata.R b/R/collect_metadata.R index 5e85a1ae..0ed4b2b8 100644 --- a/R/collect_metadata.R +++ b/R/collect_metadata.R @@ -6,7 +6,7 @@ collect_apis <- function(.query){ parse(text = _) |> eval() attr(result, "call") <- "apis" - attr(result, "region") <- pour("atlas", "region") + attr(result, "region") <- potions::pour("atlas", "region") result } @@ -28,7 +28,7 @@ collect_assertions <- function(.query){ result <- result[wanted_columns("assertions")] result$type <- "assertions" attr(result, "call") <- "assertions" # needed for `show_values()` to work - attr(result, "region") <- pour("atlas", "region") # needed for caching to work + attr(result, "region") <- potions::pour("atlas", "region") # needed for caching to work check_internal_cache(assertions = result) } result @@ -58,7 +58,7 @@ collect_collections <- function(.query){ } result <- flat_lists_only(result) |> dplyr::bind_rows() - }else if(pour("atlas", "region", .pkg = "galah") == "France"){ + }else if(potions::pour("atlas", "region", .pkg = "galah") == "France"){ result <- query_API(.query) |> purrr::pluck("_embedded", "producers") |> unlist() @@ -93,9 +93,6 @@ flat_lists_only <- function(x){ } #' Internal function to `collect()` datasets -#' @importFrom dplyr bind_rows -#' @importFrom dplyr relocate -#' @importFrom dplyr rename #' @noRd #' @keywords Internal collect_datasets <- function(.query){ @@ -257,8 +254,6 @@ collect_profiles <- function(.query){ } #' Internal function to `collect()` providers -#' @importFrom dplyr bind_rows -#' @importFrom dplyr rename #' @noRd #' @keywords Internal collect_providers <- function(.query){ diff --git a/R/collect_occurrences.R b/R/collect_occurrences.R index fd88e119..357ac0d4 100644 --- a/R/collect_occurrences.R +++ b/R/collect_occurrences.R @@ -6,30 +6,36 @@ #' @param file character; optional name for the downloaded file. Defaults to #' `data` followed by the system time in `%Y-%m-%d_%H-%M-%S` format, with a #' `.zip` suffix. -#' @importFrom potions pour -#' @importFrom rlang abort -#' @importFrom rlang inform -#' @importFrom tibble tibble #' @noRd #' @keywords Internal -collect_occurrences <- function(.query, wait, file = NULL){ - switch(pour("atlas", "region"), - "Austria" = collect_occurrences_direct(.query, file = file), - "United Kingdom" = collect_occurrences_direct(.query, file = file), - collect_occurrences_default(.query, wait = wait, file = file)) +collect_occurrences <- function(.query, + wait, + file = NULL, + error_call = rlang::caller_env()){ + switch(potions::pour("atlas", "region"), + "Austria" = collect_occurrences_direct(.query, + file = file, + call = error_call), + "United Kingdom" = collect_occurrences_direct(.query, + file = file, + call = error_call), + collect_occurrences_default(.query, + wait = wait, + file = file, + call = error_call)) } #' Internal function to `collect_occurrences()` for UK #' @noRd #' @keywords Internal -collect_occurrences_direct <- function(.query, file){ +collect_occurrences_direct <- function(.query, file, call){ .query$download <- TRUE .query$file <- check_download_filename(file) query_API(.query) result <- read_zip(.query$file) if(is.null(result)){ - inform("Download failed") - return(tibble()) + cli::cli_inform("Download failed", call = call) + return(tibble::tibble()) }else{ result } @@ -38,16 +44,17 @@ collect_occurrences_direct <- function(.query, file){ #' Internal function to `collect_occurrences()` for living atlases #' @noRd #' @keywords Internal -collect_occurrences_default <- function(.query, wait, file){ +collect_occurrences_default <- function(.query, wait, file, call){ # check queue download_response <- check_queue(.query, wait = wait) if(is.null(download_response)){ - abort("No response from selected atlas") + cli::cli_abort("No response from selected atlas", + call = call) } # get data - if(pour("package", "verbose", .pkg = "galah") & + if(potions::pour("package", "verbose", .pkg = "galah") & download_response$status == "complete") { - inform("Downloading") + cli::cli_inform("Downloading") } # sometimes lookup info critical, but not others - unclear when/why! if(any(names(download_response) == "download_url")){ @@ -61,8 +68,8 @@ collect_occurrences_default <- function(.query, wait, file){ } # handle result if(is.null(result)){ - inform("Download failed") - return(tibble()) + cli::cli_inform("Download failed", call = call) + return(tibble::tibble()) }else{ result <- result |> check_field_identities(.query) |> @@ -72,7 +79,7 @@ collect_occurrences_default <- function(.query, wait, file){ # NOTE: GBIF documents DOIs in download response status url (it used to be automatically appended) # We extract and preserve this info for the user, as of 2025-06-10 doi <- download_response$doi - attr(result, "doi") <- paste0("https://doi.org/", doi) + attr(result, "doi") <- glue::glue("https://doi.org/{doi}") } if(!is.null(.query$search_url)){ attr(result, "search_url") <- .query$search_url @@ -85,19 +92,15 @@ collect_occurrences_default <- function(.query, wait, file){ #' @param .query An object of class `data_request` #' @noRd #' @keywords Internal -#' @importFrom potions pour -#' @importFrom rlang abort -#' @importFrom rlang inform -#' @importFrom tibble tibble collect_occurrences_doi <- function(.query, file = NULL, - error_call = caller_env()) { + call) { .query$file <- check_download_filename(file) query_API(.query) result <- read_zip(.query$file) if(is.null(result)){ - inform("Download failed.") - tibble() + cli::cli_inform("Download failed.", call = call) + tibble::tibble() }else{ result } diff --git a/R/collect_taxa.R b/R/collect_taxa.R index a5a48496..6dc75b2a 100644 --- a/R/collect_taxa.R +++ b/R/collect_taxa.R @@ -237,22 +237,19 @@ check_search_terms <- function(result, atlas) { "!" = cli::cli_text("{.yellow {n_invalid} unmatched search term{?s}:}") ) if (n_invalid > 3) { - invalid_taxa_truncated <- c(invalid_taxa[1:3], glue("+ {n_invalid - 3} more")) + invalid_taxa_truncated <- c(invalid_taxa[1:3], glue::glue("+ {n_invalid - 3} more")) list_invalid_taxa <- glue::glue_collapse(invalid_taxa_truncated, sep = "\", \"", last = "\" ") - bullets <- c( - bullets, - cli::cli_text(format_error_bullets(c("{.yellow \"{list_invalid_taxa}}"))) - ) } else { list_invalid_taxa <- glue::glue_collapse(invalid_taxa, sep = "\", \"") - bullets <- c( - bullets, - cli::cli_text(format_error_bullets(c("{.yellow \"{list_invalid_taxa}\"}"))) - ) } + bullets <- c( + bullets, + c("{.yellow \"{list_invalid_taxa}\"}") |> + rlang::format_error_bullets() |> + cli::cli_text()) cli::cli_inform(bullets) cli::cli_end(d) @@ -261,7 +258,6 @@ check_search_terms <- function(result, atlas) { #' Internal function to check for homonyms in search term provided to #' `search_taxa()` -#' @importFrom rlang .data #' @noRd #' @keywords Internal check_homonyms <- function(result, diff --git a/R/collect_unnest.R b/R/collect_unnest.R index 28624674..9846bfa0 100644 --- a/R/collect_unnest.R +++ b/R/collect_unnest.R @@ -2,34 +2,25 @@ #' `request_metadata(type = "fields") |> unnest()` #' @noRd #' @keywords Internal -collect_fields_unnest <- function(.query, error_call = caller_env()){ +collect_fields_unnest <- function(.query, + error_call = rlang::caller_env()){ + facet <- .query |> + purrr::pluck("url") |> + httr2::url_parse() + if(is_gbif()){ - facet <- .query |> - purrr::pluck("url") |> - httr2::url_parse() |> - purrr::pluck("query", "facet") - - if (facet == "NA") { - cli::cli_abort("No `field` passed to `show_values()`/`search_values()`.") - } - + facet <- purrr::pluck(facet, "query", "facet") # NOTE: "facet" (singular) + check_missing_fields(facet, call = error_call) result <- .query |> query_API() |> purrr::pluck(!!!list("facets", 1, "counts")) |> dplyr::bind_rows() colnames(result)[which(colnames(result) == "name")[1]] <- facet - select(result, {{facet}}) + dplyr::select(result, {{facet}}) }else{ - facet <- .query |> - purrr::pluck("url") |> - httr2::url_parse() |> - purrr::pluck("query", "facets") - - if (facet == "NA") { - cli::cli_abort("No `field` passed to `show_values()`/`search_values()`.") - } - + facet <- purrr::pluck(facet, "query", "facets") # NOTE: "facets" (plural) + check_missing_fields(facet, call = error_call) result <- .query |> query_API() |> purrr::pluck(!!!list(1, "fieldResult")) |> @@ -39,20 +30,26 @@ collect_fields_unnest <- function(.query, error_call = caller_env()){ if(nrow(result) > 0){ result <- result |> dplyr::mutate( - field_value = stringr::str_extract( - result$i18nCode, - "(?<=\\.).*" # everything after . - ) - ) - + field_value = stringr::str_extract(result$i18nCode, + "(?<=\\.).*")) # everything after . colnames(result)[which(colnames(result) == "field_value")[1]] <- facet - dplyr::select(result, {{facet}}) + dplyr::select(result, {{facet}}) }else{ # i.e. catch empty results result } } } +#' Microfunction to prevent later failures due to missing field names +#' @noRd +#' @keywords Internal +check_missing_fields <- function(x, call){ + if (x == "NA") { + cli::cli_abort("No `field` passed to `show_values()`/`search_values()`.", + call = call) + } +} + #' Internal function to run `compute()` for #' `request_metadata(type = "lists") |> unnest()` #' @noRd diff --git a/R/compute_occurrences.R b/R/compute_occurrences.R index cccde270..cddf8dbf 100644 --- a/R/compute_occurrences.R +++ b/R/compute_occurrences.R @@ -2,7 +2,7 @@ #' @noRd #' @keywords Internal compute_occurrences <- function(.query){ - switch(pour("atlas", "region"), + switch(potions::pour("atlas", "region"), "Austria" = compute_occurrences_la_direct(.query), "United Kingdom" = compute_occurrences_la_direct(.query), "Global" = compute_occurrences_gbif(.query), @@ -28,8 +28,7 @@ compute_occurrences_gbif <- function(.query){ post_result <- query_API(.query) # returns an id status_code <- list( type = "data/occurrences", - url = paste0("https://api.gbif.org/v1/occurrence/download/", - post_result)) |> + url = glue::glue("https://api.gbif.org/v1/occurrence/download/{post_result}")) |> query_API() |> check_occurrence_response() result <- c( @@ -47,9 +46,9 @@ compute_occurrences_la <- function(.query){ status_code <- query_API(.query) |> as.list() |> check_occurrence_response() - if(pour("package", "verbose")){ + if(potions::pour("package", "verbose")){ n_records <- status_code$total_records - inform(glue("Request for {n_records} occurrences placed in queue")) + cli::cli_inform("Request for {n_records} occurrences placed in queue") } # return a useful object result <- c( @@ -61,15 +60,13 @@ compute_occurrences_la <- function(.query){ } #' Internal function to get the `fields` vector from a url -#' @importFrom httr2 url_parse -#' @importFrom purrr pluck #' @noRd #' @keywords Internal extract_fields <- function(.query){ .query |> - pluck("url") |> - url_parse() |> - pluck("query", "fields") |> + purrr::pluck("url") |> + httr2::url_parse() |> + purrr::pluck("query", "fields") |> strsplit(split = ",") |> - pluck(!!!list(1)) + purrr::pluck(!!!list(1)) } diff --git a/R/count-data_request.R b/R/count-data_request.R index a1527330..c10f5280 100644 --- a/R/count-data_request.R +++ b/R/count-data_request.R @@ -8,7 +8,6 @@ #' @param ... currently ignored #' @param sort currently ignored #' @param name currently ignored -#' @importFrom dplyr count #' @export count.data_request <- function(x, ..., @@ -18,7 +17,7 @@ count.data_request <- function(x, x$type <- switch(x$type, "occurrences" = "occurrences-count", "species" = "species-count", - "media" = abort("type = 'media' is not supported by `count()`"), - abort("`count()` only supports `type = 'occurrences' or` `'species'`")) + "media" = cli::cli_abort("type = 'media' is not supported by `count()`"), + cli::cli_abort("`count()` only supports `type = 'occurrences' or` `'species'`")) x } \ No newline at end of file diff --git a/R/galah-package.R b/R/galah-package.R index 68e2a553..323c5195 100644 --- a/R/galah-package.R +++ b/R/galah-package.R @@ -93,6 +93,8 @@ #' data quality profiles is returned by `show_all(profiles)`. Note this service #' is currently only available for the Australian atlas (ALA). #' +#' @importFrom rlang caller_env +#' @importFrom rlang .data #' @keywords internal "_PACKAGE" diff --git a/R/galah_apply_profile.R b/R/galah_apply_profile.R index 0d1cac25..ede8c116 100644 --- a/R/galah_apply_profile.R +++ b/R/galah_apply_profile.R @@ -29,21 +29,19 @@ #' apply_profile(ALA) |> #' atlas_counts() #' } -#' @importFrom rlang enquos #' @export apply_profile <- function(.data, ...){ - dots <- enquos(..., .ignore_empty = "all") + dots <- rlang::enquos(..., .ignore_empty = "all") result <- parse_quosures_basic(dots) |> - pluck(!!!list(1)) |> + purrr::pluck(!!!list(1)) |> parse_profile() update_data_request(.data, data_profile = result) } #' @rdname apply_profile -#' @importFrom rlang enquos #' @export galah_apply_profile <- function(...){ - dots <- enquos(..., .ignore_empty = "all") |> + dots <- rlang::enquos(..., .ignore_empty = "all") |> detect_request_object() switch(class(dots[[1]])[1], "data_request" = { @@ -58,19 +56,16 @@ galah_apply_profile <- function(...){ } #' Internal parsing of `profile` args -#' @importFrom glue glue -#' @importFrom rlang abort #' @noRd #' @keywords Internal -parse_profile <- function(dot_names, error_call = caller_env()) { +parse_profile <- function(dot_names, + error_call = rlang::caller_env()) { n_args <- length(dot_names) if (n_args > 0) { if (n_args > 1) { - bullets <- c( - "Too many data profiles supplied.", - x = glue("`galah_apply_profile()` accepts one profile argument, not {n_args}.") - ) - abort(bullets, call = error_call) + c("Too many data profiles supplied.", + x = "`galah_apply_profile()` accepts one profile argument, not {n_args}.") |> + cli::cli_abort(call = error_call) }else{ as.character(dot_names) } diff --git a/R/galah_bbox.R b/R/galah_bbox.R index d14a57ab..c549aea8 100644 --- a/R/galah_bbox.R +++ b/R/galah_bbox.R @@ -1,13 +1,5 @@ #' @rdname geolocate #' @order 4 -#' @importFrom glue glue -#' @importFrom rlang abort -#' @importFrom rlang caller_env -#' @importFrom rlang try_fetch -#' @importFrom sf st_as_sfc -#' @importFrom sf st_bbox -#' @importFrom sf st_crs -#' @importFrom sf st_is_valid #' @export galah_bbox <- function(...) { @@ -39,43 +31,45 @@ galah_bbox <- function(...) { unrecognised_class <- class(query) bullets <- c( "`galah_bbox` input must be an sf object, data.frame or tibble.", - x = glue("Can't use object of class '{unrecognised_class}'.") - ) + x = "Can't use object of class '{unrecognised_class}'.") if (inherits(query, "character")) { suggest <- c( i = "Did you mean to use `galah_polygon`?" ) - abort(c(bullets, suggest), call = caller_env()) + cli::cli_abort(c(bullets, suggest), + call = rlang::caller_env()) } else { - abort(bullets, call = caller_env()) + cli::cli_abort(bullets, + call = rlang::caller_env()) } } # handle shapefiles - if (inherits(query, "XY")) query <- st_as_sfc(query) + if (inherits(query, "XY")) query <- sf::st_as_sfc(query) # validate spatial objects & coordinates if (!inherits(query, c("sf", "sfc"))) { if(inherits(query, c("tbl", "data.frame"))) { check_col_names(query) query <- check_n_rows(query) - query <- st_bbox(c(xmin = query$xmin, + query <- sf::st_bbox(c(xmin = query$xmin, xmax = query$xmax, ymin = query$ymin, ymax = query$ymax), - crs = st_crs("WGS84")) + crs = sf::st_crs("WGS84")) } log <- NULL # see `log` to read any warnings that may have been silenced - valid <- try_fetch( # prevent warnings + valid <- rlang::try_fetch( # prevent warnings query |> - st_as_sfc() |> - st_is_valid(), warning = function(cnd) { + sf::st_as_sfc() |> + sf::st_is_valid(), warning = function(cnd) { log <<- cnd "" }) } else { - valid <- query |> st_is_valid() + valid <- query |> + sf::st_is_valid() } if (valid != TRUE) { @@ -83,16 +77,20 @@ galah_bbox <- function(...) { "Invalid spatial object or WKT detected.", i = "Check that the spatial feature or bounding box in `galah_bbox` is correct." ) - abort(bullets, call = caller_env()) + cli::cli_abort(bullets, call = rlang::caller_env()) } else { - if (inherits(query, c("tbl", "data.frame", "bbox")) && !inherits(query, c("sf", "sfc"))) { + if (inherits(query, c("tbl", "data.frame", "bbox")) && + !inherits(query, c("sf", "sfc"))) { bbox_coords <- round(query, 5) - query <- query |> st_as_sfc(crs = st_crs("WGS84")) + query <- query |> + sf::st_as_sfc(crs = sf::st_crs("WGS84")) } else { if (inherits(query, c("sf", "sfc"))) { - query <- query |> st_bbox(crs = st_crs("WGS84")) + query <- query |> + sf::st_bbox(crs = st_crs("WGS84")) bbox_coords <- round(query, 5) - query <- query |> st_as_sfc(crs = st_crs("WGS84")) # FIXME: should we define the projection? + query <- query |> + sf::st_as_sfc(crs = st_crs("WGS84")) # FIXME: should we define the projection? } } } @@ -100,10 +98,10 @@ galah_bbox <- function(...) { # currently a bug where the ALA doesn't accept some polygons # to avoid any issues, any polygons are converted to multipolygons if (inherits(query, "sf") || inherits(query, "sfc")) { - inform(glue(" + cli::cli_inform(" Data returned for bounding box: xmin = {bbox_coords$xmin} xmax = {bbox_coords$xmax} \\ - ymin = {bbox_coords$ymin} ymax = {bbox_coords$ymax}")) + ymin = {bbox_coords$ymin} ymax = {bbox_coords$ymax}") out_query <- build_wkt(query) } @@ -118,9 +116,6 @@ galah_bbox <- function(...) { } #' Internal function to `galah_bbox` -#' @importFrom glue glue -#' @importFrom rlang warn -#' @importFrom tibble tibble #' @noRd #' @keywords Internal check_n_rows <- function(tibble) { @@ -131,25 +126,21 @@ check_n_rows <- function(tibble) { ignored_rows <- paste(2:(nrow(tibble))) bullets <- c( "More than 1 set of coordinates supplied to `galah_bbox`.", - "*" = glue("Using first row, ignoring row(s) {ignored_rows}.") - ) - warn(bullets) + "*" = "Using first row, ignoring row(s) {ignored_rows}.") + cli::cli_warn(bullets) tibble <- tibble[1, ] }else{ tibble <- tibble } } - return(tibble) } #' Internal function to `galah_bbox` -#' @importFrom glue glue -#' @importFrom rlang abort -#' @importFrom tibble tibble #' @noRd #' @keywords Internal -check_col_names <- function(tibble, error_call = caller_env()) { +check_col_names <- function(tibble, + error_call = rlang::caller_env()) { valid_col_names <- c("xmin", "xmax", "ymin", "ymax") if (!identical(sort(names(tibble)), sort(valid_col_names))) { col_names <- names(tibble[!names(tibble) %in% valid_col_names]) @@ -159,8 +150,7 @@ check_col_names <- function(tibble, error_call = caller_env()) { bullets <- c( "Incorrect column names supplied to `galah_bbox`.", i = "Column names must be: 'xmin', 'xmax', 'ymin', 'ymax'.", - x = glue("Unrecognised column name(s): '{col_names}'.") - ) - abort(bullets, call = error_call) + x = "Unrecognised column name(s): '{col_names}'.") + cli::cli_abort(bullets, call = error_call) } } diff --git a/R/galah_call.R b/R/galah_call.R index a5829e24..261918b4 100644 --- a/R/galah_call.R +++ b/R/galah_call.R @@ -155,8 +155,6 @@ request_data <- function(type = c("occurrences", } #' @rdname galah_call -#' @importFrom glue glue -#' @importFrom rlang abort #' @export request_metadata <- function(type = c("fields", "apis", diff --git a/R/galah_filter.R b/R/galah_filter.R index 3e66744e..d12d730e 100644 --- a/R/galah_filter.R +++ b/R/galah_filter.R @@ -162,7 +162,6 @@ parse_quosures_metadata <- function(request, dots){ #' @rdname filter.data_request #' @order 3 -#' @importFrom rlang .data #' @export filter.files_request <- function(.data, ...){ dots <- rlang::enquos(..., .ignore_empty = "all") diff --git a/R/galah_group_by.R b/R/galah_group_by.R index b3311d32..086f4e3e 100644 --- a/R/galah_group_by.R +++ b/R/galah_group_by.R @@ -32,17 +32,16 @@ #' } #' @export group_by.data_request <- function(.data, ...){ -parsed_dots <- enquos(..., .ignore_empty = "all") |> +parsed_dots <- rlang::enquos(..., .ignore_empty = "all") |> parse_quosures_basic() df <- parse_group_by(parsed_dots) update_data_request(.data, group_by = df) } #' @rdname group_by.data_request -#' @importFrom stringr str_detect #' @export galah_group_by <- function(...){ - dots <- enquos(..., .ignore_empty = "all") |> + dots <- rlang::enquos(..., .ignore_empty = "all") |> detect_request_object() switch(class(dots[[1]])[1], "data_request" = { @@ -59,13 +58,14 @@ galah_group_by <- function(...){ #' Internal parsing of `group_by` args #' @noRd #' @keywords Internal -parse_group_by <- function(dot_names){ +parse_group_by <- function(dot_names, + error_call = rlang::caller_env()){ if(length(dot_names) > 0){ if(length(dot_names) > 3){ c( "Too many fields supplied.", i = "`group_by.data_request` accepts a maximum of 3 fields.") |> - cli::cli_abort(call = rlang::caller_env()) + cli::cli_abort(call = error_call) } if(length(dot_names) > 0){ names(dot_names) <- NULL # needed to avoid empty strings added as names diff --git a/R/galah_identify.R b/R/galah_identify.R index d07a4229..db5e8dc2 100644 --- a/R/galah_identify.R +++ b/R/galah_identify.R @@ -14,11 +14,7 @@ #' @order 1 #' @param x An object of class `data_request`, created using [request_data()] #' @param ... One or more scientific names. -#' @param search -#' `r lifecycle::badge("deprecated")` -#' `galah_identify()` now always does a search to verify search terms; ergo -#' this argument is ignored. -#' @return A tibble containing identified taxa. +#' @return A `tibble` containing identified taxa. #' @seealso \code{\link[=filter.data_request]{filter()}} or [geolocate()] for #' other ways to filter a query. You can also use [search_taxa()] to check that #' supplied names are being matched correctly on the server-side; see @@ -37,10 +33,6 @@ #' count() |> #' collect() #' } -#' @importFrom lifecycle deprecate_stop -#' @importFrom lifecycle deprecate_warn -#' @importFrom rlang warn -#' @importFrom tibble tibble #' @export identify.data_request <- function(x, ...){ dots_initial <- list(...) @@ -76,13 +68,12 @@ identify.metadata_request <- function(x, ...){ #' @rdname identify.data_request #' @order 3 #' @export -galah_identify <- function(..., search = NULL) { +galah_identify <- function(...) { dots_initial <- list(...) if (length(dots_initial) < 1) { cli::cli_warn("No query passed to `identify()`.") tibble::tibble("search_term" = character()) }else{ - dots_initial <- check_search_arg(dots_initial, search) if(inherits(dots_initial[[1]], "data_request")){ do.call(identify.data_request, dots_initial) }else{ @@ -90,41 +81,4 @@ galah_identify <- function(..., search = NULL) { return(search_terms) } } -} - -#' Remove `search` argument from `galah_identify()`, give deprecated warning -#' @importFrom lifecycle deprecate_warn -#' @noRd -#' @keywords Internal -check_search_arg <- function(dots_initial, search = NULL) { - if("search" %in% names(dots_initial)) { - search <- dots_initial$name - dots_initial <- dots_initial[names(dots_initial) != "search"] - } - if(!is.null(search)){ - if(!is.logical(search)){ - deprecate_stop( - when = "2.0.0", - what = "galah_identify(search = )", - details = glue("`galah_identify()` now always does a search to verify search terms. \\ - Passing anything other than TRUE or FALSE to `search` has never worked")) - }else{ - if(search){ - # if search = TRUE, this function still behaves correctly - deprecate_warn( - when = "2.0.0", - what = "galah_identify(search = )", - details = glue("`galah_identify()` now always does a search to verify search terms. \\ - Please remove `search` argument from `galah_identify()`.")) - # if search = FALSE, abort warning to use filter(lsid == x) instead - }else{ - deprecate_stop( - when = "2.0.0", - what = "galah_identify(search = )", - details = glue("`galah_identify()` now always does a search to verify search terms. \\ - To pass identifiers, please use `filter(lsid == 'identifier_here') instead.")) - } - } - } - dots_initial } \ No newline at end of file diff --git a/R/galah_polygon.R b/R/galah_polygon.R index e909f9ff..2ba7e635 100644 --- a/R/galah_polygon.R +++ b/R/galah_polygon.R @@ -23,70 +23,71 @@ galah_polygon <- function(...){ } #' parser for polygons -#' @importFrom rlang try_fetch -#' @importFrom sf st_as_sfc -#' @importFrom sf st_is_valid -#' @importFrom stringr str_detect -#' @importFrom stringr str_replace #' @noRd #' @keywords Internal -parse_polygon <- function(query){ +parse_polygon <- function(query, + error_call = rlang::caller_env()){ # make sure shapefiles are processed correctly - if (!inherits(query, "sf")) {query <- query[[1]]} else {query <- query} + if (!inherits(query, "sf")) { + query <- query[[1]] + } else { + query <- query + } # check object is accepted class - if (!inherits(query, c("character", "list", "matrix", "data.frame", "tbl", "sf", "sfc", "XY"))) { + accepted_classes <- c("character", + "list", + "matrix", + "data.frame", + "tbl", + "sf", + "sfc", + "XY") + if (!inherits(query, accepted_classes)) { unrecognised_class <- class(query) - bullets <- c( - "Invalid object detected.", + c("Invalid object detected.", i = "Did you provide a polygon or WKT in the right format?", - x = glue("`galah_polygon` cannot use object of class '{unrecognised_class}'.") - ) - abort(bullets, call = caller_env()) + x = "`galah_polygon` cannot use object of class '{unrecognised_class}'.") |> + cli::cli_abort(call = error_call) } # handle shapefiles - if (inherits(query, "XY")) query <- st_as_sfc(query) + if (inherits(query, "XY")){ + query <- sf::st_as_sfc(query) + } # make sure spatial object or wkt is valid if (!inherits(query, c("sf", "sfc"))) { check_wkt_length(query) # handle errors from converting impossible WKTs - query <- try_fetch( - query |> st_as_sfc(), + query <- rlang::try_fetch( + query |> sf::st_as_sfc(), error = function(cnd) { - bullets <- c( - "Invalid WKT detected.", - i = "Check that the spatial feature or WKT in `galah_polygon` is correct." - ) - abort(bullets, call = caller_env()) + c("Invalid WKT detected.", + i = "Check that the spatial feature or WKT in `galah_polygon` is correct.") |> + cli::cli_abort(call = error_call) }) - - # validate that wkt/spatial object is real - valid <- query |> st_is_valid() } - else { - valid <- query |> st_is_valid() } + + # validate that wkt/spatial object is real + valid <- query |> sf::st_is_valid() + if(any(is.na(valid))) { - bullets <- c( - "Invalid spatial object or WKT detected.", - i = "Check that the spatial feature or WKT in `galah_polygon` is correct." - ) - abort(bullets, call = caller_env()) + c("Invalid spatial object or WKT detected.", + i = "Check that the spatial feature or WKT in `galah_polygon` is correct.") |> + cli::cli_abort(call = error_call) } # check number of vertices of WKT if(any(n_points(query) > 500)) { n_verts <- n_points(query) - bullets <- c( - glue("Polygon has too many vertices."), + c("Polygon has too many vertices.", i = "`galah_polygon` only accepts simple polygons.", i = "See `?sf::st_simplify` for how to simplify geospatial objects.", - x = "Polygon must have 500 or fewer vertices, not {n_verts}." - ) - abort(bullets, call = caller_env()) + x = "Polygon must have 500 or fewer vertices, not {n_verts}.") |> + cli::cli_abort(call = error_call) } # currently a bug where the ALA doesn't accept some polygons @@ -98,12 +99,10 @@ parse_polygon <- function(query){ } else { # multiple polygons n_polygons <- length(query$geometry) - bullets <- c( - "Too many polygons.", + c("Too many polygons.", i = "`galah_polygon` cannot accept more than 1 polygon at a time.", - x = glue("{n_polygons} polygons detected in spatial object.") - ) - abort(bullets, call = caller_env()) + x = "{n_polygons} polygons detected in spatial object.") |> + cli::cli_abort(call = error_call) ## NOTE: Code below parses multiple polygons. ## Please do not remove! @@ -121,13 +120,14 @@ parse_polygon <- function(query){ # remove space after "POLYGON" if present if(str_detect(query, "POLYGON \\(\\(")) - query <- str_replace(query, "POLYGON \\(\\(", "POLYGON\\(\\(") + query <- string::str_replace(query, "POLYGON \\(\\(", "POLYGON\\(\\(") - if (str_detect(query, "POLYGON") & ! str_detect(query, "MULTIPOLYGON")) { + if (stringr::str_detect(query, "POLYGON") & + !stringr::str_detect(query, "MULTIPOLYGON")) { # change start of string - query <- str_replace(query, "POLYGON\\(\\(", "MULTIPOLYGON\\(\\(\\(") + query <- stringr::str_replace(query, "POLYGON\\(\\(", "MULTIPOLYGON\\(\\(\\(") # add an extra bracket - query <- paste0(query, ")") + query <- glue::glue("{query})") } out_query <- query } @@ -135,26 +135,23 @@ parse_polygon <- function(query){ } #' Internal function to `galah_polygon` -#' @importFrom sf st_geometry #' @noRd #' @keywords Internal n_points <- function(x) { - count_vertices(st_geometry(x)) + count_vertices(sf::st_geometry(x)) } #' Internal function to `galah_polygon` -#' @importFrom rlang caller_env -#' @importFrom sf st_is_empty #' @noRd #' @keywords Internal -count_vertices <- function(wkt_string, error_call = caller_env()) { +count_vertices <- function(wkt_string) { out <- if (is.list(wkt_string)) sapply(sapply(wkt_string, count_vertices), sum) else { if (is.matrix(wkt_string)) nrow(wkt_string) else { - if (!st_is_empty(wkt_string)) 1 else + if (!sf::st_is_empty(wkt_string)) 1 else 0 } } @@ -162,30 +159,25 @@ count_vertices <- function(wkt_string, error_call = caller_env()) { } #' Internal function to `galah_polygon` -#' @importFrom glue glue -#' @importFrom rlang abort -#' @importFrom rlang is_list -#' @importFrom rlang is_string #' @noRd #' @keywords Internal -check_wkt_length <- function(wkt, error_call = caller_env()) { - if (is_string(wkt) == TRUE | - is.matrix(wkt) == TRUE | - is_list(wkt) == TRUE | - is.data.frame(wkt) == TRUE) { +check_wkt_length <- function(wkt, + error_call = rlang::caller_env()) { + if (rlang::is_string(wkt) == TRUE | + is.matrix(wkt) == TRUE | + rlang::is_list(wkt) == TRUE | + is.data.frame(wkt) == TRUE) { # make sure strings aren't too long for API call if(!inherits(wkt, "character")){ - abort("Argument `wkt` must be of class 'character'", - call = error_call) + cli::cli_abort("Argument `wkt` must be of class 'character'", + call = error_call) } n_char_wkt <- nchar(wkt) max_char <- 10000 if (n_char_wkt > max_char) { - bullets <- c( - "Invalid WKT detected.", - x = glue("WKT string can be maximum {max_char} characters. WKT supplied has {n_char_wkt}.") - ) - abort(bullets, call = error_call) + c("Invalid WKT detected.", + x = "WKT string can be maximum {max_char} characters. WKT supplied has {n_char_wkt}.") |> + cli::cli_abort(call = error_call) } } } diff --git a/R/galah_radius.R b/R/galah_radius.R index 7b414ada..d9baf56c 100644 --- a/R/galah_radius.R +++ b/R/galah_radius.R @@ -23,26 +23,21 @@ galah_radius <- function(...){ } #' parser for radius -#' @importFrom rlang try_fetch -#' @importFrom sf st_as_sfc -#' @importFrom sf st_coordinates -#' @importFrom stringr str_detect #' @noRd #' @keywords Internal parse_point_radius <- function(..., error_call = caller_env()){ - query <- try_fetch( + query <- rlang::try_fetch( list(...)[[1]], error = function(cnd) { - bullets <- c( - "No input detected.", - i = "Did you forget to supply coordinates to `galah_radius()`?" - ) - abort(bullets, call = error_call) + c("No input detected.", + i = "Did you forget to supply coordinates to `galah_radius()`?") |> + cli::cli_abort(call = error_call) }) # Coords are supplied as an `sfc` point or lat/lon arguments - if (inherits(query, c("list")) && inherits(query[[1]], c("sf", "sfc"))) { + if (inherits(query, c("list")) && + inherits(query[[1]], c("sf", "sfc"))) { # make sure shapefiles are handled correctly coords <- query[[1]] @@ -53,21 +48,17 @@ parse_point_radius <- function(..., error_call = caller_env()){ # TODO: Recognise when more than one point is passed, default to first point unrecognised_class <- glue::glue_collapse(class(coords), sep = ", ") - bullets <- c( - "Invalid spatial object supplied as point coordinates.", + c("Invalid spatial object supplied as point coordinates.", i = "`galah_radius()` accepts sfc_POINT objects.", - x = glue("Cannot use class: {unrecognised_class}.") - ) - abort(bullets, call = error_call) + x = "Cannot use class: {unrecognised_class}.") |> + cli::cli_abort(call = error_call) } # extract point coordinate values - lat = st_coordinates(coords)[1] - lon = st_coordinates(coords)[2] + lat <- sf::st_coordinates(coords)[1] + lon <- sf::st_coordinates(coords)[2] - } - # Coords are supplied as lon/lat arguments - else { # + } else { # Coords are supplied as lon/lat arguments # TODO: Assign numeric values to lon/lat automatically? # if(!is.null(query[[1]]) && inherits(query[[1]], c("numeric", "double", "integer")) | @@ -75,18 +66,17 @@ parse_point_radius <- function(..., error_call = caller_env()){ # lon = query[[1]] # lat = query[[2]] # } - if(inherits(query, "list") && is.null(query$lat) | is.null(query$lon)) { - bullets <- c( - "Missing `lat` or `lon` values.", - i = "Point coordinates should be specified using `lat` & `lon` arguments, or supplied as an `sfc_POINT`." - ) - abort(bullets, call = error_call) + if(inherits(query, "list") && + (is.null(query$lat) | is.null(query$lon))) { + c("Missing `lat` or `lon` values.", + i = "Point coordinates should be specified using `lat` & `lon` arguments, or supplied as an `sfc_POINT`.") |> + cli::cli_abort(call = error_call) } else { query <- query # extract point coordinate values - lat = query$lat - lon = query$lon + lat <- query$lat + lon <- query$lon } } @@ -100,59 +90,50 @@ parse_point_radius <- function(..., error_call = caller_env()){ # Check for radius value. If empty, set to 10 km if(is.null(query$radius)) { - bullets <- c( - "No radius value specified.", - "*" = "Setting radius to 10 km." - ) - warn(bullets) - radius = 10 + c("No radius value specified.", + "*" = "Setting radius to 10 km.") |> + cli::cli_warn() + radius <- 10 } else { # Only use one radius value if(length(query$radius) > 1) { n_radius <- length(query$radius) - warn(c("More than 1 radius provided.", - "*" = glue("Using first radius, ignoring additional {n_radius - 1} value(s).")) - ) + c("More than 1 radius provided.", + "*" = "Using first radius, ignoring additional {n_radius - 1} value(s).") |> + cli::cli_warn() radius <- query$radius[[1]] } else { - radius = query$radius + radius <- query$radius } } # Check object is accepted class when supplied to lat/lon/radius arguments if (!any(inherits(c(lat, lon, radius), c("numeric", "double", "integer")))) { - wrong_classes <- tibble(arg = names(query), - class = lapply(query, class) |> unlist() - ) |> + wrong_classes <- tibble::tibble(arg = names(query), + class = purrr::map(query, class) |> unlist()) |> filter(!class %in% c("numeric", "double", "integer")) - unrecognised_class <- glue_collapse(unique(wrong_classes$class), - sep = ", ") - bullets <- c( - "Invalid class detected.", + unrecognised_class <- glue::glue_collapse(unique(wrong_classes$class), + sep = ", ") + c("Invalid class detected.", i = "Point can be specified as numeric `lat` & `lon` coordinates, or supplied as an `sfc_POINT`.", - x = glue("`galah_radius()` does not accept type '{unrecognised_class}'.") - ) - abort(bullets, call = error_call) + x = "`galah_radius()` does not accept type '{unrecognised_class}'.") |> + cli::cli_abort(call = error_call) } # make sure lat/lon aren't impossible if(lon > 180 | lon < -180 | lat > 90 | lat < -90) { - bullets <- c( - "Point location outside of possible range.", - i = "Are the coordinates valid?" - ) - abort(bullets, call = error_call) + c("Point location outside of possible range.", + i = "Are the coordinates valid?") |> + cli::cli_abort(call = error_call) } # Should this be an error? A message? if(radius > 1565) { - bullets <- c( - "Radius is larger than the area of Australia.", - i = "Try reducing the radius to narrow your query." - ) - inform(bullets) + c("Radius is larger than the area of Australia.", + i = "Try reducing the radius to narrow your query.") |> + cli::cli_inform() } out_query <- list(lat = lat, diff --git a/R/handle_quosures.R b/R/handle_quosures.R index 9974ecb2..bccfdb2c 100644 --- a/R/handle_quosures.R +++ b/R/handle_quosures.R @@ -8,9 +8,11 @@ #' parse quosures for objects of class `data_request` #' @noRd #' @keywords internal -parse_quosures_data <- function(dots){ +parse_quosures_data <- function(dots){ if(length(dots) > 0){ - result <- purrr::map(dots, switch_expr_type) |> + result <- purrr::map(dots, \(a){ + switch_expr_type(a) + }) |> dplyr::bind_rows() |> clean_assertions() |> clean_logical_statements() @@ -27,6 +29,7 @@ parse_quosures_data <- function(dots){ } result } +# FIXME: work out how to propagate `rlang::caller_env()` through the below functions #' parse quosures, but for `select` and related functions #' @@ -34,14 +37,16 @@ parse_quosures_data <- function(dots){ #' stuff that is a named object #' @noRd #' @keywords internal -parse_quosures_basic <- function(dots){ +parse_quosures_basic <- function(dots, + error_call = rlang::caller_env()){ if(length(dots) > 0){ parsed_dots <- purrr::map(dots, \(a){ switch(expr_type(a), "symbol" = {parse_symbol(a)}, "call" = {rlang::eval_tidy(a)}, "literal" = {rlang::quo_get_expr(a)}, - cli::cli_abort("Quosure type not recognised.")) + cli::cli_abort("Quosure type not recognised.", + call = error_call)) }) unlist(parsed_dots) }else{ @@ -53,7 +58,8 @@ parse_quosures_basic <- function(dots){ #' of data to be supplied #' @noRd #' @keywords internal -parse_quosures_files <- function(dots){ +parse_quosures_files <- function(dots, + error_call = rlang::caller_env()){ if(length(dots) > 0){ check_named_input(dots) dot_expr <- rlang::quo_get_expr(dots[[1]]) # i.e. only first entry is available @@ -76,7 +82,8 @@ parse_quosures_files <- function(dots){ rlang::as_label(x) }}, "literal" = {rlang::quo_get_expr(x)}, - abort("Quosure type not recognised.")) + cli::cli_abort("Quosure type not recognised.", + call = error_call)) if(inherits(rhs, "data.frame")){ list(variable = dequote(lhs), data = rhs) @@ -347,7 +354,7 @@ concatenate_logical_tibbles <- function(df, query_text <- df$query |> glue::glue_collapse(sep = logical_string) } - tibble( + tibble::tibble( variable = glue::glue_collapse(df$variable, sep = provided_string), logical = glue::glue_collapse(df$logical, sep = provided_string), value = glue::glue_collapse(df$value, sep = provided_string), @@ -497,7 +504,6 @@ parse_solr <- function(df){ } #' Internal function to `parse_solr()` -#' @importFrom glue glue_data #' @noRd #' @keywords internal switch_solr <- function(df){ diff --git a/R/handle_quosures_GBIF.R b/R/handle_quosures_GBIF.R index 6b477b71..5bec8584 100644 --- a/R/handle_quosures_GBIF.R +++ b/R/handle_quosures_GBIF.R @@ -39,7 +39,8 @@ switch_expr_type_pred <- function(x, ...){ "symbol" = {parse_symbol(x)}, # identical to `switch_expr_type()` "call" = {parse_call_pred(x, ...)}, # only 'new' line "literal" = {rlang::quo_get_expr(x)}, - cli::cli_abort("Quosure type not recognised.") + cli::cli_abort("Quosure type not recognised.", + call = rlang::caller_env()) ) } diff --git a/R/handle_request_objects.R b/R/handle_request_objects.R index 32e10b41..1a12e181 100644 --- a/R/handle_request_objects.R +++ b/R/handle_request_objects.R @@ -80,7 +80,6 @@ update_data_request <- function(data_request, ...){ } #' Internal function to join together two `select` objects -#' @importFrom rlang is_quosure #' @noRd #' @keywords Internal update_select <- function(x, y){ diff --git a/R/messages.R b/R/messages.R index 3542fff5..7518a90b 100644 --- a/R/messages.R +++ b/R/messages.R @@ -1,7 +1,7 @@ #' Internal function called by `check_login()` #' @noRd #' @keywords Internal -abort_email_missing <- function(error_call = caller_env()){ +abort_email_missing <- function(error_call = rlang::caller_env()){ c( "No user email was found.", i = "To download occurrence records, species lists, or (for GBIF) occurrence @@ -25,11 +25,12 @@ abort_email_missing <- function(error_call = caller_env()){ #' System-wide, generic failure message #' @noRd #' @keywords Internal -system_down_message <- function(function_name){ +system_down_message <- function(function_name, + error_call = rlang::caller_env()){ c( "Calling the API failed for `{function_name}`.", i = "This might mean that the API is down, or that you are not connected to the internet.", i = "Double check that your query is correct, or try again later." ) |> - cli::cli_inform(bullets) + cli::cli_inform(call = error_call) } \ No newline at end of file diff --git a/R/print.R b/R/print.R index ae05b51e..ab32c5e7 100644 --- a/R/print.R +++ b/R/print.R @@ -62,21 +62,22 @@ print.metadata_request <- function(x, ...){ #' @noRd #' @keywords Internal format_request_text <- function(x, object_type){ - filled_slots <- !unlist(lapply(x, is.null)) - formatted_object <- galah_pink(glue("`{object_type}`")) + filled_slots <- !unlist(purrr::map(x, is.null)) + formatted_object <- galah_pink(glue::glue("`{object_type}`")) if(any(filled_slots)){ - inform(glue("Object of type {formatted_object} containing:")) + cli::cli_inform("Object of type {formatted_object} containing:") x_names <- names(x)[filled_slots] - lapply(x_names, function(a){ - slot_name <- galah_green(a) - slot_content <- switch_slot_text(x, a) - glue("{slot_name} {galah_grey(slot_content)}") - }) |> + purrr::map(x_names, + function(a){ + slot_name <- galah_green(a) + slot_content <- switch_slot_text(x, a) + glue::glue("{slot_name} {galah_grey(slot_content)}") + }) |> unlist() |> - format_error_bullets() |> + rlang::format_error_bullets() |> cat() }else{ - inform(glue("An empty object of type {formatted_object}")) + cli::cli_inform("An empty object of type {formatted_object}") } } @@ -109,20 +110,19 @@ switch_slot_text <- function(x, a){ if(nrow(df) > 1){ df <- df[1, ] } - glue_collapse( + glue::glue_collapse( apply(df, 1, function(b){paste(b, collapse = " ")}), sep = " | ") } }, "select" = x[[a]]$summary, - "group_by" = glue_collapse(x[[a]]$name, sep = " | "), + "group_by" = glue::glue_collapse(x[[a]]$name, sep = " | "), "data_profile" ={x[[a]][1]}, "mint_doi" = {x[[a]][1]}, "") } #' @rdname print_galah_objects -#' @importFrom crayon silver #' @export print.query <- function(x, ...){ if(!is.null(x$arrange)){ @@ -170,9 +170,9 @@ print.query <- function(x, ...){ subtext <- "" } cat(c( - silver("Object of class"), + crayon::silver("Object of class"), galah_pink("query"), - silver("with type"), + crayon::silver("with type"), galah_green(x$type), subtext, # note: need code for url tibbles arrange, @@ -227,9 +227,9 @@ print.computed_query <- function(x, ...){ subtext <- "" } cat(c( - silver("Object of class"), + crayon::silver("Object of class"), galah_pink("computed_query"), - silver("with type"), + crayon::silver("with type"), galah_green(x$type), subtext, # note: need code for url tibbles arrange, @@ -240,13 +240,13 @@ print.computed_query <- function(x, ...){ #' @export print.query_set <- function(x, ...){ n_queries <- length(x) - message(c(silver("Object of class "), + message(c(crayon::silver("Object of class "), galah_pink("`query_set` "), - silver(glue("containing ")), + crayon::silver(glue("containing ")), ifelse(n_queries > 1, - silver(glue("{n_queries} queries:")), - silver("1 query:")))) - lapply(x, function(a){ + crayon::silver(glue("{n_queries} queries:")), + crayon::silver("1 query:")))) + purrr::map(x, function(a){ type_text <- galah_green(a$type) if(!is.null(a$url)){ url_temp <- a$url[1] @@ -263,41 +263,42 @@ print.query_set <- function(x, ...){ glue("{type_text} {subtext}") }) |> unlist() |> - format_error_bullets() |> + rlang::format_error_bullets() |> cat() } #' @rdname print_galah_objects -#' @importFrom rlang format_error_bullets #' @export print.galah_config <- function(x, ...){ - inform(galah_pink("Package")) + cli::cli_inform(galah_pink("Package")) package_settings <- galah_green(c("verbose", "run_checks", "send_email")) - package_lookup <- unlist(x$package[1:3]) |> as.integer() + 1 + package_lookup <- unlist(x$package[1:3]) |> + as.integer() + 1 names(package_settings) <- c("x", "v")[package_lookup] package_settings <- c(package_settings, - "i" = glue("{galah_green('directory')}: {galah_grey(x$package$directory)}")) |> - format_error_bullets() |> + "i" = glue::glue("{galah_green('directory')}: {galah_grey(x$package$directory)}")) |> + rlang::format_error_bullets() |> cat() cat("\n") - inform(galah_pink("User")) + cli::cli_inform(galah_pink("User")) values <- c( "{galah_green('username')} {galah_grey(hide_secrets(x$user$username))}", "{galah_green('email')} {galah_grey(x$user$email)}", "{galah_green('password')} {galah_grey(hide_secrets(x$user$password))}", "{galah_green('api_key')} {galah_grey(hide_secrets(x$user$api_key))}", "{galah_green('download_reason_id')} {galah_grey(x$user$download_reason_id)}") - password_settings <- lapply(values, - function(a, x){glue_data(x, a)}, x = x) |> + password_settings <- purrr::map(values, + function(a, x){glue::glue_data(x, a)}, + x = x) |> unlist() |> - format_error_bullets() |> + rlang::format_error_bullets() |> cat() cat("\n") - inform(galah_pink("Atlas")) + cli::cli_inform(galah_pink("Atlas")) atlas_text <- galah_green(x$atlas$organisation) atlas_subtext <- galah_grey(glue("({x$atlas$acronym}), {x$atlas$region}")) - atlas_settings <- glue("{atlas_text} {atlas_subtext}") |> - format_error_bullets() |> + atlas_settings <- glue::glue("{atlas_text} {atlas_subtext}") |> + rlang::format_error_bullets() |> cat() } @@ -313,25 +314,21 @@ hide_secrets <- function(string){ } #' Pink for printing primary text (e.g. object names) to the console -#' @importFrom crayon make_style #' @noRd #' @keywords Internal -galah_pink <- make_style("#bf2a6d") +galah_pink <- crayon::make_style("#bf2a6d") #' Green for printing secondary text (e.g. object types) to the console -#' @importFrom crayon make_style #' @noRd #' @keywords Internal -galah_green <- make_style("#176666") +galah_green <- crayon::make_style("#176666") #' Green for printing non-emphasized text to the console -#' @importFrom crayon make_style #' @noRd #' @keywords Internal -galah_pale_green <- make_style("#60a3a3") +galah_pale_green <- crayon::make_style("#60a3a3") #' Grey for printing non-emphasized text (e.g. urls) to the console -#' @importFrom crayon make_style #' @noRd #' @keywords Internal -galah_grey <- make_style("#8c8c8c") +galah_grey <- crayon::make_style("#8c8c8c") diff --git a/R/query_API.R b/R/query_API.R index 2f6df4e8..93836016 100644 --- a/R/query_API.R +++ b/R/query_API.R @@ -97,7 +97,6 @@ query_API_internal <- function(.query, error_call = caller_env()) { #' If supplied, add `headers` arg to a `request()` #' @noRd #' @keywords Internal -#' @importFrom potions pour add_headers <- function(req, headers){ if(!is.null(headers)){ req$headers <- headers diff --git a/R/read_zip.R b/R/read_zip.R index e338234b..fd7cd3e0 100644 --- a/R/read_zip.R +++ b/R/read_zip.R @@ -54,18 +54,18 @@ read_zip <- function(file){ }else{ available_files <- all_files[grepl(".csv$", all_files) & grepl("^data|records", all_files)] - result <- lapply(available_files, - function(a, x){ - # create connection to a specific file within zip - conn <- unz(description = x, - filename = a, - open = "rb") - out <- readr::read_csv(conn, - col_types = readr::cols()) |> - suppressWarnings() - close(conn) - return(out) - }, x = file) |> + result <- purrr::map(available_files, + function(a, x){ + # create connection to a specific file within zip + conn <- unz(description = x, + filename = a, + open = "rb") + out <- readr::read_csv(conn, + col_types = readr::cols()) |> + suppressWarnings() + close(conn) + return(out) + }, x = file) |> dplyr::bind_rows() # # add doi when mint_doi = TRUE if(any(all_files == "doi.txt")){ @@ -89,7 +89,7 @@ read_zip <- function(file){ cite_check <- grepl("cite", names(readme)) if(any(cite_check)){ attr(result, "citation") <- readme[cite_check] |> - glue_collapse(sep = "") + glue::glue_collapse(sep = "") } } } diff --git a/R/search_all.R b/R/search_all.R index a219f5e1..fb7538b4 100644 --- a/R/search_all.R +++ b/R/search_all.R @@ -110,7 +110,8 @@ search_all <- function(type, query){ if(missing(type)){ type <- "fields" }else{ - type <- parse_quosures_basic(enquos(type)) + type <- rlang::enquos(type) |> + parse_quosures_basic() if(!inherits(type, "character") | length(type) > 1){ cli::cli_abort("`type` must be a length-1 vector of class 'character'") } diff --git a/R/show_all.R b/R/show_all.R index 27faa4d4..0c0a0270 100644 --- a/R/show_all.R +++ b/R/show_all.R @@ -61,7 +61,7 @@ #' } #' @export show_all <- function(..., limit = NULL){ - dots <- enquos(..., .ignore_empty = "all") + dots <- rlang::enquos(..., .ignore_empty = "all") if(length(dots) < 1){ type_text <- "fields" }else{ @@ -76,20 +76,19 @@ show_all <- function(..., limit = NULL){ #' Internal function to handle `show_all` calls #' This is needed to handle slight differences between syntax of #' `show_all()` and `collect()` -#' @importFrom dplyr slice_head #' @noRd #' @keywords Internal show_all_generic <- function(type, limit){ x <- request_metadata(type = type) if(!is.null(limit)){ - x <- x |> slice_head(n = limit) + x <- x |> dplyr::slice_head(n = limit) } result <- collect(x) # `show_all()` always returns requested number of records # this differs from `collect()` which always returns what the API gives you if(!is.null(limit)){ if(nrow(result) > limit){ - result <- slice_head(result, n = limit) + result <- dplyr::slice_head(result, n = limit) } } result diff --git a/R/show_values.R b/R/show_values.R index 92cdac96..439f4800 100644 --- a/R/show_values.R +++ b/R/show_values.R @@ -38,8 +38,6 @@ #' Default is set to `FALSE`. #' Currently only implemented for metadata type `lists`. #' @return A `tibble` of values for a specified field, profile or list. -#' @importFrom tibble tibble -#' @importFrom cli col_yellow #' @examples \dontrun{ #' # Show values in field 'cl22' #' search_fields("cl22") |> @@ -87,22 +85,17 @@ show_values <- function(df, if(nrow(df) > 1) { n_matches <- nrow(df) df <- df[1, ] - inform( - bullets <- c( - "!" = glue("Search returned {n_matches} matched {type}."), - "*" = glue("Showing values for '{match_name}'.") - )) + c("!" = "Search returned {n_matches} matched {type}.", + "*" = "Showing values for '{match_name}'.") |> + cli::cli_inform() } else { if (is.na(match_name)) { - inform(cli::col_yellow(glue("`search_all()` returned no matched `{type}`."))) - tibble() + cli::col_yellow("`search_all()` returned no matched `{type}`.") |> + cli::cli_inform() + tibble::tibble() } else { - inform( - bullets <- c( - # glue("Search returned 1 matched {type}."), - "*" = glue("Showing values for '{match_name}'.") - ) - ) + c("*" = "Showing values for '{match_name}'.") |> + cli::cli_inform() } } request_metadata() |> @@ -130,32 +123,26 @@ search_values <- function(df, query) { check_values_input <- function(df, error_call = caller_env()) { # Check if missing input if(missing(df) || is.null(df)) { - bullets <- c( - "Missing information for values lookup.", + c("Missing information for values lookup.", i = "Field, profile or list must be provided as a tibble created by `search_all()`.", - i = "e.g. `search_all(fields, \"year\") |> show_values()`." - ) - abort(bullets, call = error_call) + i = "e.g. `search_all(fields, \"year\") |> show_values()`.") |> + cli::cli_abort(call = error_call) } # Check that original data.frame is from a `show_all` or `search_all` if(is.null(attr(df, "call"))) { - bullets <- c( - "Wrong input provided.", + c("Wrong input provided.", i = "Must supply a tibble created by `search_all()` or `show_all()`.", - i = "e.g. `search_all(fields, \"year\") |> show_values()`." - ) - abort(bullets, call = error_call) + i = "e.g. `search_all(fields, \"year\") |> show_values()`.") |> + cli::cli_abort(call = error_call) } # Input must be from valid `show_all` or `search_all` tibble valid_calls <- c("fields", "lists", "profiles", "taxa") if(!any(valid_calls == attr(df, "call"))){ type <- attr(df, "call") - bullets <- c( - glue("Can't lookup values for metadata type `{type}`."), - x = "Values lookup accepts `fields`, `lists`, `profiles` or `taxa`." - ) - abort(bullets, call = error_call) + c("Can't lookup values for metadata type `{type}`.", + x = "Values lookup accepts `fields`, `lists`, `profiles` or `taxa`.") |> + cli::cli_abort(call = error_call) } } \ No newline at end of file diff --git a/R/slice_head.R b/R/slice_head.R index f1187c35..560716d5 100644 --- a/R/slice_head.R +++ b/R/slice_head.R @@ -31,16 +31,15 @@ #' slice_head(n = 3) |> #' collect() #' } -#' @importFrom tibble tibble #' @export slice_head.data_request <- function(.data, ..., n, prop, by = NULL){ # handle inputs if(!missing(n)){ - result <- tibble(slice_n = n) + result <- tibble::tibble(slice_n = n) }else if(!missing(prop)){ - result <- tibble(slice_prop = prop) + result <- tibble::tibble(slice_prop = prop) } else { - result <- tibble() + result <- tibble::tibble() } # if no data - or NULL - is provided, make no updates diff --git a/R/tidyverse.R b/R/tidyverse.R index 982babc2..b9d33b61 100644 --- a/R/tidyverse.R +++ b/R/tidyverse.R @@ -48,7 +48,7 @@ NULL #' @param ... column to order by #' @export desc <- function(...){ - dots <- enquos(..., .ignore_empty = "all") + dots <- rlang::enquos(..., .ignore_empty = "all") parsed_dots <- parse_quosures_basic(dots) tibble(variable = parsed_dots, direction = "descending") diff --git a/R/utilities_occurrences.R b/R/utilities_occurrences.R index f0898cb1..a38b02b8 100644 --- a/R/utilities_occurrences.R +++ b/R/utilities_occurrences.R @@ -26,7 +26,7 @@ email_notify <- function() { - notify <- as.logical(pour("package", "send_email")) + notify <- as.logical(potions::pour("package", "send_email")) if (is.na(notify)) { notify <- FALSE } diff --git a/man/identify.data_request.Rd b/man/identify.data_request.Rd index 854d9069..b8861310 100644 --- a/man/identify.data_request.Rd +++ b/man/identify.data_request.Rd @@ -10,19 +10,15 @@ \method{identify}{metadata_request}(x, ...) -galah_identify(..., search = NULL) +galah_identify(...) } \arguments{ \item{x}{An object of class \code{metadata_request}, created using \code{\link[=request_metadata]{request_metadata()}}} \item{...}{One or more scientific names.} - -\item{search}{\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#deprecated}{\figure{lifecycle-deprecated.svg}{options: alt='[Deprecated]'}}}{\strong{[Deprecated]}} -\code{galah_identify()} now always does a search to verify search terms; ergo -this argument is ignored.} } \value{ -A tibble containing identified taxa. +A \code{tibble} containing identified taxa. } \description{ When conducting a search or creating a data query, it is common to identify diff --git a/man/select.data_request.Rd b/man/select.data_request.Rd index 83b67926..40ce43f9 100644 --- a/man/select.data_request.Rd +++ b/man/select.data_request.Rd @@ -73,14 +73,15 @@ the exception of GBIF, for which all columns are returned. Calling the argument \code{group = "basic"} returns the following columns: \itemize{ +\item \code{recordID} +\item \code{scientificName} +\item \code{taxonConceptID} \item \code{decimalLatitude} \item \code{decimalLongitude} \item \code{eventDate} -\item \code{scientificName} -\item \code{taxonConceptID} -\item \code{recordID} -\item \code{dataResourceName} +\item \code{basisOfRecord} \item \code{occurrenceStatus} +\item \code{dataResourceName} } Using \code{group = "event"} returns the following columns: diff --git a/tests/testthat/test-galah_bbox.R b/tests/testthat/test-galah_bbox.R index f59f53bb..565db3a9 100644 --- a/tests/testthat/test-galah_bbox.R +++ b/tests/testthat/test-galah_bbox.R @@ -1,11 +1,13 @@ test_that("galah_bbox returns bbox for sf", { polygon_sfc <- "POLYGON((142.36228 -29.00703,142.74131 -29.00703,142.74131 -29.39064,142.36228 -29.39064,142.36228 -29.00703))" |> - st_as_sfc() + sf::st_as_sfc() polygon_bbox <- galah_bbox(polygon_sfc) - expected_bbox <- polygon_sfc |> st_bbox() + expected_bbox <- polygon_sfc |> + sf::st_bbox() expect_message(galah_bbox(polygon_sfc), "Data returned for bounding box:") expect_true(grepl("MULTIPOLYGON", galah_bbox(polygon_sfc))) - expect_equal(attributes(polygon_bbox)$bbox, expected_bbox) + expect_equal(attributes(polygon_bbox)$bbox, + expected_bbox) }) test_that("galah_bbox returns bbox for shapefile", { @@ -19,7 +21,8 @@ test_that("galah_bbox returns bbox for shapefile", { }) test_that("galah_bbox returns bbox for bbox", { # FIXME: not backwards compatible with bbox coords? - bbox <- st_bbox(c(xmin = 143, xmax = 148, ymin = -29, ymax = -28), crs = st_crs("WGS84")) + bbox <- sf::st_bbox(c(xmin = 143, xmax = 148, ymin = -29, ymax = -28), + crs = sf::st_crs("WGS84")) bbox_galah <- galah_bbox(bbox) expected_polygon <- "MULTIPOLYGON (((143 -29, 148 -29, 148 -28, 143 -28, 143 -29)))" expect_message(galah_bbox(bbox), "Data returned for bounding box:") @@ -28,40 +31,43 @@ test_that("galah_bbox returns bbox for bbox", { # FIXME: not backwards compatibl }) test_that("galah_bbox returns bbox for tibble", { - tibble <- tibble(xmin = 143, ymin = -29, xmax = 148, ymax = -21) + tibble <- tibble::tibble(xmin = 143, ymin = -29, xmax = 148, ymax = -21) tibble_bbox <- galah_bbox(tibble) expected_polygon <- "MULTIPOLYGON (((143 -29, 148 -29, 148 -21, 143 -21, 143 -29)))" - expected_bbox <- st_bbox(c(xmin = 143, xmax = 148, ymin = -29, ymax = -21), crs = st_crs("WGS84")) + expected_bbox <- sf::st_bbox(c(xmin = 143, xmax = 148, ymin = -29, ymax = -21), + crs = sf::st_crs("WGS84")) expect_message(galah_bbox(tibble), "Data returned for bounding box:") expect_equal(galah_bbox(tibble)[1], expected_polygon) expect_equal(attributes(tibble_bbox)$bbox, expected_bbox) }) test_that("galah_bbox does not accept incorrect tibbles", { - tibble_wrong <- tibble(c1 = c("hi", "hello"), c2 = 1:2) - tibble_bad_colnames <- tibble(top = 148, bottom = -29, ymin = -29, ymax = -29) - tibble_invalid_bbox <- tibble(xmin = 148, ymin = -29, xmax = 143, ymax = -29) + tibble_wrong <- tibble::tibble(c1 = c("hi", "hello"), c2 = 1:2) + tibble_bad_colnames <- tibble::tibble(top = 148, bottom = -29, ymin = -29, ymax = -29) + tibble_invalid_bbox <- tibble::tibble(xmin = 148, ymin = -29, xmax = 143, ymax = -29) expect_error(galah_bbox(tibble_wrong)) expect_error(galah_bbox(tibble_bad_colnames)) expect_error(galah_bbox(tibble_invalid_bbox)) }) test_that("galah_bbox uses only first coordinates of tibble with many coordinates", { - tibble_many_coords <- tibble(xmin = c(148, 145), - ymin = c(-29, -42), - xmax = c(143, 146), - ymax = c(-30, -41)) + tibble_many_coords <- tibble::tibble(xmin = c(148, 145), + ymin = c(-29, -42), + xmax = c(143, 146), + ymax = c(-30, -41)) expect_warning(galah_bbox(tibble_many_coords), "More than 1 set of coordinates supplied to") }) test_that("galah_bbox checks number of inputs, uses first argument", { # FIXME - wkt_1 <- glue("POLYGON((142.36228 -29.00703,142.74131 -29.00703,142.74131 \\ - -29.39064,142.36228 -29.39064,142.36228 -29.00703))") |> st_as_sfc() - wkt_2 <- glue("POLYGON((145.6765 -42.13203, 145.9652 -42.63203, 146.5425 \\ + wkt_1 <- glue::glue("POLYGON((142.36228 -29.00703,142.74131 -29.00703,142.74131 \\ + -29.39064,142.36228 -29.39064,142.36228 -29.00703))") |> + sf::st_as_sfc() + wkt_2 <- glue::glue("POLYGON((145.6765 -42.13203, 145.9652 -42.63203, 146.5425 \\ -42.63203, 146.8312 -42.13203, 146.5425 -41.63203, 145.9652 \\ - -41.63203, 145.6765 -42.13203))") |> st_as_sfc() - expected_polygon <- glue("MULTIPOLYGON (((142.3623 -29.39064, 142.7413 -29.39064, \\ + -41.63203, 145.6765 -42.13203))") |> + sf::st_as_sfc() + expected_polygon <- glue::glue("MULTIPOLYGON (((142.3623 -29.39064, 142.7413 -29.39064, \\ 142.7413 -29.00703, 142.3623 -29.00703, 142.3623 -29.39064)))") bbox_1 <- expect_warning(galah_bbox(wkt_1, wkt_2), "More than 1 spatial area provided") expect_equal(as.character(bbox_1), @@ -81,18 +87,19 @@ test_that("galah_bbox checks inputs", { }) test_that("galah_bbox detects invalid spatial objects", { - impossible_bbox <- st_bbox(c(xmin = 148000, - xmax = -29000, - ymin = -29000, - ymax = -29000), - crs = st_crs("WGS84")) + impossible_bbox <- sf::st_bbox(c(xmin = 148000, + xmax = -29000, + ymin = -29000, + ymax = -29000), + crs = sf::st_crs("WGS84")) expect_error(galah_bbox(impossible_bbox), "Invalid spatial object") }) test_that("galah_bbox converts to multipolygon", { wkt <- "POLYGON((143.32 -18.78,145.30 -20.52,141.52 -21.50,143.32 -18.78))" - sf_wkt <- wkt |> st_as_sfc() + sf_wkt <- wkt |> + sf::st_as_sfc() expect_match(galah_bbox(sf_wkt), "MULTIPOLYGON") }) diff --git a/tests/testthat/test-galah_filter.R b/tests/testthat/test-galah_filter.R index c1c2cd96..f05f1d58 100644 --- a/tests/testthat/test-galah_filter.R +++ b/tests/testthat/test-galah_filter.R @@ -442,7 +442,7 @@ test_that("galah_filter handles `type = 'metadata'` correctly", { }) test_that("galah_filter handles `type = 'files'` correctly", { - x <- tibble( + x <- tibble::tibble( id = c(1, 2), images = c("1234", "5678")) y <- galah_call(method = "files") |> diff --git a/tests/testthat/test-galah_identify.R b/tests/testthat/test-galah_identify.R index 404f1642..58ae42a7 100644 --- a/tests/testthat/test-galah_identify.R +++ b/tests/testthat/test-galah_identify.R @@ -81,7 +81,7 @@ test_that("galah_identify truncates unmatched list of taxa at 3 ", { c( cli::cli_text("Matched {.bold 1 of 5} taxonomic search terms in selected atlas (Australia)."), "!" = cli::cli_text("{.yellow 4 unmatched search term:}"), - cli::cli_text(format_error_bullets(c("{.yellow \"blarghy\", \"blorp\", \"florp\" + 1 more}"))) + cli::cli_text(rlang::format_error_bullets(c("{.yellow \"blarghy\", \"blorp\", \"florp\" + 1 more}"))) ) ) }) @@ -102,18 +102,6 @@ test_that("galah_identify errors for deprecated `search = FALSE` argument", { collapse()) }) -test_that("galah_identify warns for deprecated `search = TRUE` argument", { - skip_if_offline(); skip_on_ci() - galah_config(run_checks = TRUE) - ids <- c("Litoria", "Crinia") - expect_warning( - galah_call() |> - galah_identify(ids, search = TRUE) |> - galah_filter(year == 2020) |> - count() |> - collapse()) -}) - ## NOTE: Not certain if this is a necessary test # cli::test_that_cli("Partial taxonomic match message theming", { # testthat::local_edition(3) diff --git a/tests/testthat/test-galah_polygon.R b/tests/testthat/test-galah_polygon.R index 26e864f5..c5e6f574 100644 --- a/tests/testthat/test-galah_polygon.R +++ b/tests/testthat/test-galah_polygon.R @@ -11,7 +11,7 @@ test_that("galah_polygon uses first argument", { test_that("galah_polygon checks inputs", { poly_path <- test_path("testdata", "act_state_polygon_shp", "ACT_STATE_POLYGON_shp.shp") wkt_path <- test_path("testdata", "long_act_wkt.txt") - expect_error(galah_polygon(st_read(poly_path, quiet = TRUE))) + expect_error(galah_polygon(sf::st_read(poly_path, quiet = TRUE))) expect_error(galah_polygon(readLines(wkt_path))) }) @@ -37,18 +37,19 @@ test_that("galah_polygon converts WKT strings to multipolygon", { test_that("galah_polygon converts WKT strings with spaces", { wkt_with_spaces <- "POLYGON ((143.32 -18.78,145.30 -20.52,141.52 -21.50,143.32 -18.78))" - converted_wkt_with_spaces <- build_wkt(st_as_sfc(wkt_with_spaces)) + converted_wkt_with_spaces <- build_wkt(sf::st_as_sfc(wkt_with_spaces)) expect_match(converted_wkt_with_spaces, "MULTIPOLYGON \\(\\(\\(143\\.32") }) test_that("galah_polygon converts sf object to multipolygon", { - sf_wkt <- "POLYGON((143.32 -18.78,145.30 -20.52,141.52 -21.50,143.32 -18.78))" |> st_as_sfc() + sf_wkt <- "POLYGON((143.32 -18.78,145.30 -20.52,141.52 -21.50,143.32 -18.78))" |> + sf::st_as_sfc() expect_match(galah_polygon(sf_wkt), "MULTIPOLYGON") }) test_that("galah_polygon counts vertices correctly", { wkt <- "POLYGON((143.32 -18.78,145.30 -20.52,141.52 -21.50,143.32 -18.78))" - expect_equal(n_points(st_as_sfc(wkt)), 4) + expect_equal(n_points(sf::st_as_sfc(wkt)), 4) }) test_that("galah_polygon checks for simple polygons only", { @@ -60,7 +61,8 @@ test_that("galah_polygon checks for simple polygons only", { }) test_that("galah_polygon counts n vertices correctly", { - sf_wkt <- "POLYGON((143.32 -18.78,145.30 -20.52,141.52 -21.50,143.32 -18.78))" |> st_as_sfc() + sf_wkt <- "POLYGON((143.32 -18.78,145.30 -20.52,141.52 -21.50,143.32 -18.78))" |> + sf::st_as_sfc() poly_path <- test_path("testdata", "act_state_polygon_shp", "ACT_STATE_POLYGON_shp.shp") shapefile_complex <- sf::st_read(poly_path, quiet = TRUE) expect_equal(n_points(shapefile_complex), 2787) diff --git a/tests/testthat/test-galah_select.R b/tests/testthat/test-galah_select.R index 7f97d45e..3b625073 100644 --- a/tests/testthat/test-galah_select.R +++ b/tests/testthat/test-galah_select.R @@ -51,7 +51,7 @@ test_that("`galah_select()` builds expected columns when group = basic", { identify("oxyopes dingo") |> select(group = "basic") |> collapse() - y <- url_parse(x$url)$query + y <- httr2::url_parse(x$url)$query expect_equal(strsplit(y$fields, ",")[[1]], preset_groups("basic")) expect_equal(y$qa, "none") }) @@ -62,7 +62,7 @@ test_that("`galah_select()` builds expected columns when group = event", { identify("oxyopes dingo") |> select(group = "event") |> collapse() - y <- url_parse(x$url)$query + y <- httr2::url_parse(x$url)$query expect_equal(strsplit(y$fields, ",")[[1]], c("recordID", preset_groups("event"))) expect_equal(y$qa, "none") @@ -74,7 +74,7 @@ test_that("`galah_select()` accepts multiple groups", { identify("oxyopes dingo") |> select(group = c("basic", "assertions")) |> collapse() - y <- url_parse(x$url)$query + y <- httr2::url_parse(x$url)$query expect_equal(strsplit(y$fields, ",")[[1]], preset_groups("basic")) expect_equal(y$qa, "includeall") @@ -85,7 +85,7 @@ test_that("galah_select defaults to group = 'basic' when there are no args", { x <- galah_call() |> identify("oxyopes dingo") |> collapse() - y <- url_parse(x$url)$query + y <- httr2::url_parse(x$url)$query expect_equal(strsplit(y$fields, ",")[[1]], preset_groups("basic")) expect_equal(y$qa, "none") }) @@ -96,7 +96,7 @@ test_that("galah_select works with group = 'taxonomy'", { identify("oxyopes dingo") |> select(group = "taxonomy") |> collapse() - y <- url_parse(x$url)$query + y <- httr2::url_parse(x$url)$query fields <- strsplit(tolower(y$fields), ",")[[1]] expect_equal(fields, c("recordid", @@ -116,7 +116,7 @@ test_that("galah_select returns assertions + recordID when group = assertions", identify("oxyopes dingo") |> select(group = "assertions") |> collapse() - y <- url_parse(x$url)$query + y <- httr2::url_parse(x$url)$query expect_equal(y$fields, "recordID") expect_equal(y$qa, "includeall") }) @@ -127,7 +127,7 @@ test_that("galah_select combines requested columns and group columns", { identify("oxyopes dingo") |> select(year, basisOfRecord, group = "basic") |> collapse() - y <- url_parse(x$url)$query + y <- httr2::url_parse(x$url)$query expect_equal(strsplit(y$fields, ",")[[1]], c(preset_groups("basic"), "year", "basisOfRecord")) }) @@ -138,7 +138,7 @@ test_that("galah_select can use tidyselect::contains", { identify("oxyopes dingo") |> select(tidyselect::contains("el")) |> collapse() - y <- url_parse(x$url)$query + y <- httr2::url_parse(x$url)$query fields <- strsplit(tolower(y$fields), ",")[[1]] assertions <- strsplit(tolower(y$qa), ",")[[1]] expect_true(all(grepl("el", fields))) @@ -151,7 +151,7 @@ test_that("galah_select can use tidyselect::starts_with", { identify("oxyopes dingo") |> select(tidyselect::starts_with("el")) |> collapse() - y <- url_parse(x$url)$query + y <- httr2::url_parse(x$url)$query fields <- strsplit(tolower(y$fields), ",")[[1]] assertions <- strsplit(tolower(y$qa), ",")[[1]] expect_true(all(grepl("^el", fields))) @@ -164,7 +164,7 @@ test_that("galah_select can use tidyselect::last_col", { identify("oxyopes dingo") |> select(tidyselect::last_col()) |> collapse() - y <- url_parse(x$url)$query + y <- httr2::url_parse(x$url)$query expect_equal(y$fields, "recordID") expect_equal(y$qa, "ZERO_COORDINATE") }) @@ -175,7 +175,7 @@ test_that("galah_select can use tidyselect::last_col & user-defined queries", { identify("oxyopes dingo") |> select(year, basisOfRecord, tidyselect::last_col()) |> collapse() - y <- url_parse(x$url)$query + y <- httr2::url_parse(x$url)$query expect_equal(y$fields, "year,basisOfRecord") expect_equal(y$qa, "ZERO_COORDINATE") }) @@ -186,7 +186,7 @@ test_that("galah_select can use tidyselect::last_col & group", { identify("oxyopes dingo") |> select(tidyselect::last_col(), group = "basic") |> collapse() - y <- url_parse(x$url)$query + y <- httr2::url_parse(x$url)$query expect_equal(strsplit(y$fields, ",")[[1]], preset_groups("basic")) expect_equal(y$qa, "ZERO_COORDINATE") diff --git a/tests/testthat/test-show_all.R b/tests/testthat/test-show_all.R index 366dc045..5dda8230 100644 --- a/tests/testthat/test-show_all.R +++ b/tests/testthat/test-show_all.R @@ -29,10 +29,15 @@ test_that("all show_all() functions return correctly with all syntax", { "ranks", "reasons") invisible(lapply(valid_types, function(a){ - syntax1 <- paste0("show_all_", a) |> do.call(args = list()) # e.g. show_all_fields() - syntax2 <- paste0("show_all(", a, ")") |> parse(text = _) |> eval() # e.g. show_all(fields) - syntax3 <- request_metadata(type = a) |> collect() - limit_test <- paste0("show_all_", a) |> do.call(args = list(limit = 3)) + syntax1 <- paste0("show_all_", a) |> + do.call(args = list()) # e.g. show_all_fields() + syntax2 <- paste0("show_all(", a, ")") |> + parse(text = _) |> + eval() # e.g. show_all(fields) + syntax3 <- request_metadata(type = a) |> + collect() + limit_test <- paste0("show_all_", a) |> + do.call(args = list(limit = 3)) expect_s3_class(syntax1, c("tbl_df", "tbl", "data.frame")) expect_equal(attributes(syntax1)$call, a) expect_equal(attributes(syntax1)$region, "Australia") From ece18ea3ccfbe918e157bd529f73cd9ac356c786 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Fri, 5 Sep 2025 16:02:41 +1000 Subject: [PATCH 20/94] Add basisOfRecord to "basic" group in `galah_select()` --- R/galah_select.R | 21 ++++++++++----------- R/utilities_internal.R | 17 +++++++++-------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/R/galah_select.R b/R/galah_select.R index 27290258..ed2e6677 100644 --- a/R/galah_select.R +++ b/R/galah_select.R @@ -53,14 +53,15 @@ #' #' Calling the argument `group = "basic"` returns the following columns: #' +#' * `recordID` +#' * `scientificName` +#' * `taxonConceptID` #' * `decimalLatitude` #' * `decimalLongitude` #' * `eventDate` -#' * `scientificName` -#' * `taxonConceptID` -#' * `recordID` -#' * `dataResourceName` +#' * `basisOfRecord` #' * `occurrenceStatus` +#' * `dataResourceName` #' #' Using `group = "event"` returns the following columns: #' @@ -120,14 +121,13 @@ #' galah_select(basisOfRecord, group = "basic") |> #' collect() #' } -#' @importFrom rlang inform #' @export select.data_request <- function(.data, ..., group){ if(is_gbif()){ - inform("`select()` is not supported for GBIF: skipping") + cli::cli_inform("`select()` is not supported for GBIF: skipping") .data }else{ - dots <- enquos(..., .ignore_empty = "all") |> + dots <- rlang::enquos(..., .ignore_empty = "all") |> as.list() |> add_summary() |> add_group(group) @@ -138,11 +138,11 @@ select.data_request <- function(.data, ..., group){ #' @rdname select.data_request #' @export galah_select <- function(..., group){ - dots <- enquos(..., .ignore_empty = "all") |> + dots <- rlang::enquos(..., .ignore_empty = "all") |> detect_request_object() |> as.list() if(is_gbif()){ - inform("`select()` is not supported for GBIF: skipping") + cli::cli_inform("`select()` is not supported for GBIF: skipping") if(inherits(dots[[1]], "data_request")){ dots[[1]] }else{ @@ -161,11 +161,10 @@ galah_select <- function(..., group){ } #' internal function to summarise select function (to support `print()`) -#' @importFrom rlang as_label #' @noRd #' @keywords Internal add_summary <- function(dots){ - labels <- lapply(dots, as_label) |> + labels <- purrr::map(dots, rlang::as_label) |> unlist() labels <- labels[labels != ""] last_entry <- length(dots) + 1 diff --git a/R/utilities_internal.R b/R/utilities_internal.R index 5fc69c23..8803b1d1 100644 --- a/R/utilities_internal.R +++ b/R/utilities_internal.R @@ -136,21 +136,19 @@ galah_version_string <- function() { } #' Internal function for determining if we should call GBIF or not -#' @importFrom potions pour #' @noRd #' @keywords Internal is_gbif <- function(){ - pour("atlas", "region") == "Global" + potions::pour("atlas", "region") == "Global" } #' Internal function for determining whether a Living Atlas supports reasons API. #' This affects whether a reason is appended to a query in `collapse()` (and #' checked in `compute()`) -#' @importFrom potions pour #' @noRd #' @keywords Internal atlas_supports_reasons_api <- function(){ - atlas <- pour("atlas", "region") + atlas <- potions::pour("atlas", "region") supports_reasons <- c("Australia", "Austria", "Guatemala", "Portugal", "Spain", "Sweden", "United Kingdom") atlas %in% supports_reasons @@ -197,7 +195,7 @@ preset_groups <- function(group_name) { #' @noRd #' @keywords Internal default_columns <- function() { - atlas <- pour("atlas", "region") + atlas <- potions::pour("atlas", "region") if(atlas %in% c("Austria", "Brazil", "Guatemala", @@ -210,6 +208,7 @@ default_columns <- function() { "latitude", "longitude", "occurrence_date", + "basis_of_record", "occurrence_status", "data_resource_uid") }else if(atlas %in% c("France")){ @@ -219,6 +218,7 @@ default_columns <- function() { "decimalLatitude", "decimalLongitude", "eventDate", + "basisOfRecord", "occurrenceStatus", "dataResourceName") }else if(atlas %in% c("Australia", @@ -231,6 +231,7 @@ default_columns <- function() { "decimalLatitude", "decimalLongitude", "eventDate", + "basisOfRecord", "occurrenceStatus", "dataResourceName") }else{ @@ -241,7 +242,7 @@ default_columns <- function() { #' @noRd #' @keywords Internal image_fields <- function() { - atlas <- pour("atlas", "region") + atlas <- potions::pour("atlas", "region") if(atlas %in% c("Austria", "Brazil", "Guatemala", @@ -262,7 +263,7 @@ image_fields <- function() { #' @noRd #' @keywords Internal species_facets <- function(){ - atlas <- pour("atlas", "region") + atlas <- potions::pour("atlas", "region") if(atlas %in% c("Australia", "Flanders", "France", @@ -286,7 +287,7 @@ source_type_id_lookup <- function(region){ #' @noRd #' @keywords Internal profiles_supported <- function(){ - atlas <- pour("atlas", "region") + atlas <- potions::pour("atlas", "region") if(atlas %in% c("Australia")) { TRUE }else{ From 3fd5f5830bef492ee0a60601f36f581bc09f32ad Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Fri, 5 Sep 2025 16:03:25 +1000 Subject: [PATCH 21/94] Ensure loading message correctly describes selected atlas Previously ALA was hard-coded, which is fine for first but not subsequent loads --- R/onload.R | 45 +++++++++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/R/onload.R b/R/onload.R index 03003fe9..d0ddf3e2 100644 --- a/R/onload.R +++ b/R/onload.R @@ -1,30 +1,39 @@ #' Set-up for galah during loading #' @noRd #' @keywords Internal -#' @importFrom cli col_magenta -#' @importFrom glue glue -#' @importFrom potions brew .onLoad <- function(libname, pkgname) { if (pkgname == "galah") { - brew(.pkg = "galah") + + # set up storage of standard information via {potions} + potions::brew(.pkg = "galah") galah_config() # to cache defaults - options(list( - "check_internal_cache" = galah_internal_cached)) - # add a note to the user + options(list("check_internal_cache" = galah_internal_cached)) + + # get information to display to the user galah_version <- "version unknown" suppressWarnings( try(galah_version <- utils::packageDescription("galah")[["Version"]], silent = TRUE)) ## get the galah version, if we can - bullets <- c( - glue("galah: version {galah_version}"), - i = col_magenta('Default node set to ALA (ala.org.au).'), - i = col_magenta('See all supported GBIF nodes with `show_all(atlases)`.'), - i = col_magenta('To change nodes, use e.g. `galah_config(atlas = \"GBIF\")`.') - ) - inform(bullets, - class = c("packageStartupMessage", # see ?packageStartupMessage (required by `check()`) - "simpleMessage", - "message", - "condition")) + current_node <- potions::pour("atlas", .pkg = "galah") |> + purrr::pluck("acronym") + current_url <- show_all_atlases() |> + dplyr::filter(.data$acronym == current_node) |> + dplyr::pull("url") |> + stringr::str_replace("^https://", "") + + # display a message + # NOTE: This message *must* have the following classes to enable them + # to be controlled programmatically. + # see ?packageStartupMessage (required by `check()`) + c( + glue::glue("galah version {galah_version}"), + i = cli::col_magenta('galah is currently configured to query {current_node} ({current_url}).'), + i = cli::col_magenta('You can see all supported organisations with `show_all(atlases)`.'), + i = cli::col_magenta('To change organisations, use e.g. `galah_config(atlas = \"GBIF\")`.') + ) |> + cli::cli_inform(class = c("packageStartupMessage", + "simpleMessage", + "message", + "condition")) } } From 393f7b7ae99289f9192fe34583f8a94494b6a1ce Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Fri, 12 Sep 2025 17:05:55 +1000 Subject: [PATCH 22/94] First pass implementing `select()` for lists #266 Detects when the user passes `everything()` to `select.metadata_request()` and parses accordingly. `show_values(all_fields = TRUE)` works unchanged --- NAMESPACE | 2 + R/galah_select.R | 32 +++++++++++- R/show_values.R | 21 +++++--- R/tidyverse.R | 40 +++++++++----- man/select.data_request.Rd | 18 ++++++- man/tidyverse_functions.Rd | 12 ++++- tests/testthat/test-show_values.R | 86 +++++++++++++++++++++---------- 7 files changed, 163 insertions(+), 48 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index fc1be97c..adc6cc0b 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -37,6 +37,7 @@ S3method(print,metadata_request) S3method(print,query) S3method(print,query_set) S3method(select,data_request) +S3method(select,metadata_request) S3method(slice_head,data_request) S3method(slice_head,metadata_request) S3method(st_crop,data_request) @@ -56,6 +57,7 @@ export(collect_media) export(compute) export(count) export(desc) +export(everything) export(filter) export(galah_apply_profile) export(galah_bbox) diff --git a/R/galah_select.R b/R/galah_select.R index ed2e6677..21f79268 100644 --- a/R/galah_select.R +++ b/R/galah_select.R @@ -9,7 +9,8 @@ #' #' `select()` supports `dplyr` **selection helpers**, including: #' -#' * \code{\link[dplyr]{everything}}: Matches all variables. +#' * \code{\link[dplyr]{everything}}: Matches all variables. This is treated +#' unusually in `galah`; see `details`. #' * \code{\link[dplyr]{last_col}}: Select last variable, possibly with an #' offset. #' @@ -95,6 +96,17 @@ #' * `synonyms` to include any synonymous names. #' * `lists` to include authoritative lists that each species is included on. #' +#' The [everything()] function is recoded in galah to support three changed +#' behaviours: +#' +#' * When called with [unnest()] for type `"lists"`, it adds user-provided +#' columns, for example on conservation status or species traits. +#' * For occurrence downloads with type `"species`, it adds `counts`, +#' `synonyms` and `lists` to the download. +#' * For 'normal' occurrence downloads, it returns an error. Returning all +#' fields is computationally expensive and probably not what you want +#' anyway. +#' #' @seealso \code{\link[=filter.data_request]{filter()}}, #' \code{\link[=st_crop.data_request]{st_crop()}} and #' \code{\link[=identify.data_request]{identify()}} for other ways to restrict @@ -135,6 +147,22 @@ select.data_request <- function(.data, ..., group){ } } +#' @rdname select.data_request +#' @export +select.metadata_request <- function(.data, ...){ + if(.data$type != "lists"){ + cli::cli_abort("`select()` is only supported for type `lists`") + } + select_entries <- rlang::enquos(..., .ignore_empty = "all") |> + as.list() |> + purrr::map(rlang::as_label) |> + unlist() + names(select_entries) <- NULL + .data$select <- list(value = select_entries, + summary = select_entries) + .data +} + #' @rdname select.data_request #' @export galah_select <- function(..., group){ @@ -168,7 +196,7 @@ add_summary <- function(dots){ unlist() labels <- labels[labels != ""] last_entry <- length(dots) + 1 - dots[[last_entry]] <- paste(labels, collapse = " | ") + dots[[last_entry]] <- glue::glue_collapse(labels, sep = " | ") names(dots)[last_entry] <- "summary" dots } diff --git a/R/show_values.R b/R/show_values.R index 439f4800..9c2d677c 100644 --- a/R/show_values.R +++ b/R/show_values.R @@ -98,11 +98,19 @@ show_values <- function(df, cli::cli_inform() } } - request_metadata() |> - filter({{type}} == {{match_name}}) |> - unnest() |> - `attr<-`("all_fields", all_fields) |> - collect() + + if(type == "lists" & isTRUE(all_fields)){ + request_metadata() |> + filter({{type}} == {{match_name}}) |> + select(everything()) |> + unnest() |> + collect() + }else{ + request_metadata() |> + filter({{type}} == {{match_name}}) |> + unnest() |> + collect() + } } #' @param query A string specifying a search term. Not case sensitive. @@ -120,7 +128,8 @@ search_values <- function(df, query) { #' Internal function to check inputs to `show_values()` & `search_values()` #' @noRd #' @keywords Internal -check_values_input <- function(df, error_call = caller_env()) { +check_values_input <- function(df, + error_call = rlang::caller_env()) { # Check if missing input if(missing(df) || is.null(df)) { c("Missing information for values lookup.", diff --git a/R/tidyverse.R b/R/tidyverse.R index b9d33b61..5080538c 100644 --- a/R/tidyverse.R +++ b/R/tidyverse.R @@ -29,13 +29,20 @@ #' #' # Return values of field `basisOfRecord` #' request_metadata() |> -#' galah::unnest() |> +#' unnest() |> #' filter(field == basisOfRecord) |> #' collect() #' #' # Using `galah::unnest()` in this way is equivalent to: #' show_all(fields, "basisOfRecord") |> #' show_values() +#' +#' # to add information to a species list: +#' request_metadata() |> +#' filter(list == "dr650") |> +#' select(everything()) |> +#' unnest() |> +#' collect() #' } #' @name tidyverse_functions NULL @@ -50,35 +57,44 @@ NULL desc <- function(...){ dots <- rlang::enquos(..., .ignore_empty = "all") parsed_dots <- parse_quosures_basic(dots) - tibble(variable = parsed_dots, - direction = "descending") + tibble::tibble(variable = parsed_dots, + direction = "descending") } +#' @rdname tidyverse_functions +#' @export +everything <- function(){ + # still need to test this for + # - getting all fields in atlas_species() + # - erroring in atlas_occurrences() + # browser() +} + #' @rdname tidyverse_functions #' @param .query An object of class `metadata_request` #' @export unnest <- function(.query){ if(!inherits(.query, "metadata_request")){ - abort("`galah::unnest()` can only be used with objects of class `metadata_request`.") + cli::cli_abort("`galah::unnest()` can only be used with objects of class `metadata_request`.") } if(!is.null(.query$filter)){ + # check whether `type` is supplied as singular (i.e. `field` not `fields`) supplied_type <- .query$filter$variable[1] - if(supplied_type != "taxa" & - !grepl("s$", supplied_type)){ - supplied_type <- paste0(supplied_type, "s") + if(supplied_type != "taxa" & !grepl("s$", supplied_type)){ + supplied_type <- glue::glue("{supplied_type}s") } }else if(!is.null(.query$identify)){ supplied_type <- "taxa" }else{ supplied_type <- .query$type } + # ensure only used with certain query types valid_types <- c("fields", "lists", "profiles", "taxa") if(!(supplied_type %in% valid_types)){ - bullets <- c( - "Invalid `type` supplied to `unnest()`", - i = "Valid types are `fields`, `lists`, `profiles` or `taxa`") - abort(bullets, call = caller_env()) + c("Invalid `type` supplied to `unnest()`", + i = "Valid types are `fields`, `lists`, `profiles` or `taxa`") |> + cli::cli_abort(call = caller_env()) } - .query$type <- paste0(supplied_type, "-unnest") + .query$type <- glue::glue("{supplied_type}-unnest") .query } diff --git a/man/select.data_request.Rd b/man/select.data_request.Rd index 40ce43f9..fcffdbe7 100644 --- a/man/select.data_request.Rd +++ b/man/select.data_request.Rd @@ -2,11 +2,14 @@ % Please edit documentation in R/galah_select.R \name{select.data_request} \alias{select.data_request} +\alias{select.metadata_request} \alias{galah_select} \title{Keep or drop columns using their names} \usage{ \method{select}{data_request}(.data, ..., group) +\method{select}{metadata_request}(.data, ...) + galah_select(..., group) } \arguments{ @@ -33,7 +36,8 @@ or messages will be triggered at the end of the pipe. \code{select()} supports \code{dplyr} \strong{selection helpers}, including: \itemize{ -\item \code{\link[dplyr]{everything}}: Matches all variables. +\item \code{\link[dplyr]{everything}}: Matches all variables. This is treated +unusually in \code{galah}; see \code{details}. \item \code{\link[dplyr]{last_col}}: Select last variable, possibly with an offset. } @@ -118,6 +122,18 @@ it should be one or more of: \item \code{synonyms} to include any synonymous names. \item \code{lists} to include authoritative lists that each species is included on. } + +The \code{\link[=everything]{everything()}} function is recoded in galah to support three changed +behaviours: +\itemize{ +\item When called with \code{\link[=unnest]{unnest()}} for type \code{"lists"}, it adds user-provided +columns, for example on conservation status or species traits. +\item For occurrence downloads with type \verb{"species}, it adds \code{counts}, +\code{synonyms} and \code{lists} to the download. +\item For 'normal' occurrence downloads, it returns an error. Returning all +fields is computationally expensive and probably not what you want +anyway. +} } \examples{ \dontrun{ diff --git a/man/tidyverse_functions.Rd b/man/tidyverse_functions.Rd index 4aba5f2a..5c3fc98f 100644 --- a/man/tidyverse_functions.Rd +++ b/man/tidyverse_functions.Rd @@ -3,11 +3,14 @@ \name{tidyverse_functions} \alias{tidyverse_functions} \alias{desc} +\alias{everything} \alias{unnest} \title{Non-generic tidyverse functions} \usage{ desc(...) +everything() + unnest(.query) } \arguments{ @@ -52,13 +55,20 @@ galah_call() |> # Return values of field `basisOfRecord` request_metadata() |> - galah::unnest() |> + unnest() |> filter(field == basisOfRecord) |> collect() # Using `galah::unnest()` in this way is equivalent to: show_all(fields, "basisOfRecord") |> show_values() + +# to add information to a species list: +request_metadata() |> + filter(list == "dr650") |> + select(everything()) |> + unnest() |> + collect() } } \seealso{ diff --git a/tests/testthat/test-show_values.R b/tests/testthat/test-show_values.R index 7ef23bdb..515d9dd2 100644 --- a/tests/testthat/test-show_values.R +++ b/tests/testthat/test-show_values.R @@ -9,39 +9,48 @@ test_that("show_values checks values", { test_that("show_values accepts search & show_all inputs from fields", { skip_if_offline(); skip_on_ci() - search <- search_all(lists, "EPBC act") - filtered_show <- show_all(lists) |> - dplyr::filter(species_list_uid == "dr656") - values_search <- search |> show_values() - values_show <- filtered_show |> show_values() + # traditional syntax + values_search <- search_all(lists, "EPBC act") |> + show_values() expect_s3_class(values_search, c("tbl_df", "tbl", "data.frame")) expect_gt(nrow(values_search), 0) + # newer syntax (doesn't require `show_all_lists()`) + values_show <- request_metadata() |> + filter(lists == "dr656") |> + unnest() |> + collect() expect_s3_class(values_show, c("tbl_df", "tbl", "data.frame")) expect_gt(nrow(values_show), 0) }) test_that("show_values accepts search & show_all inputs from profiles", { skip_if_offline(); skip_on_ci() - search <- search_all(profiles, "ALA") - filtered_show <- show_all(profiles) |> - dplyr::filter(shortName == "ALA") - values_search <- search |> show_values() - values_show <- filtered_show |> show_values() + # traditional syntax + values_search <- search_all(profiles, "ALA") |> + show_values() expect_s3_class(values_search, c("tbl_df", "tbl", "data.frame")) expect_gt(nrow(values_search), 0) + # newer syntax + values_show <- request_metadata() |> + filter(profiles == "ALA") |> + unnest() |> + collect() expect_s3_class(values_show, c("tbl_df", "tbl", "data.frame")) expect_gt(nrow(values_show), 0) }) test_that("show_values accepts search & show_all inputs from lists", { skip_if_offline(); skip_on_ci() - search <- search_all(fields, "cl22") - filtered_show <- show_all(fields) |> - dplyr::filter(id == "year") - values_search <- search |> show_values() - values_show <- filtered_show |> show_values() + # old syntax + values_search <- search_all(fields, "cl22") |> + show_values() expect_s3_class(values_search, c("tbl_df", "tbl", "data.frame")) expect_gt(nrow(values_search), 0) + # new syntax + values_show <- request_metadata() |> + filter(fields == "basisOfRecord") |> + unnest() |> + collect() expect_s3_class(values_show, c("tbl_df", "tbl", "data.frame")) expect_gt(nrow(values_show), 0) }) @@ -61,7 +70,7 @@ test_that("search_values returns filtered results for fields", { paste(values_search[,1]), ignore.case = TRUE)) expect_s3_class(values_search, c("tbl_df", "tbl", "data.frame")) - expect_equivalent(names(values_search), names(values_show)) + expect_equal(names(values_search), names(values_show)) expect_lt(nrow(values_search), nrow(values_show)) expect_true(search_result_check) }) @@ -75,7 +84,7 @@ test_that("search_values returns filtered results for profiles", { paste(values_search$description), ignore.case = TRUE)) expect_s3_class(values_search, c("tbl_df", "tbl", "data.frame")) - expect_equivalent(names(values_search), names(values_show)) + expect_equal(names(values_search), names(values_show)) expect_lt(nrow(values_search), nrow(values_show)) expect_true(search_result_check) }) @@ -117,8 +126,8 @@ test_that("search_values specifies matched field", { test_that("show_values returns unformatted names", { skip_if_offline(); skip_on_ci() - expected <- tibble(basisOfRecord = c("HUMAN_OBSERVATION", - "PRESERVED_SPECIMEN")) + expected <- tibble::tibble(basisOfRecord = c("HUMAN_OBSERVATION", + "PRESERVED_SPECIMEN")) search <- search_all(fields, "basisOfRecord") expect_equal(search |> show_values() |> head(2L), expected) @@ -146,15 +155,40 @@ test_that("unnest syntax works", { test_that("show_values all_fields = TRUE works for lists", { skip_if_offline(); skip_on_ci() - search <- search_all(lists, "dr650") |> - show_values(all_fields = TRUE) + # simple, fake version for testing `show_values()` + df <- tibble::tibble(species_list_uid = "dr650") + attr(df, "call") <- "lists" + show_values_query <- show_values(df, all_fields = TRUE) + expect_equal(all_fields_query, show_values_query) + # NOTE: above is same as following code, but much faster + # search <- search_all(lists, "dr650") |> + # show_values(all_fields = TRUE) extra_cols <- c("raw_scientificName", "status", "sourceStatus", "IUCN_equivalent_status") - - expect_s3_class(search, c("tbl_df", "tbl", "data.frame")) - expect_gt(nrow(search), 0) - expect_true(any(colnames(search) %in% extra_cols)) - expect_gt(ncol(search), 6) # adds additional columns + expect_s3_class(show_values_query, c("tbl_df", "tbl", "data.frame")) + expect_gt(nrow(show_values_query), 0) + expect_true(any(colnames(show_values_query) %in% extra_cols)) + expect_gt(ncol(show_values_query), 6) # adds additional columns + # doesn't work for fields expect_warning(search_all(fields, "cl22") |> show_values(all_fields = TRUE)) }) +test_that("unnest() |> `select(everything()) works as alternative to all_fields",{ + x <- request_metadata() |> + filter(list == "dr650") |> + select(everything()) |> + unnest() |> + collect() + extra_cols <- c("raw_scientificName", "status", "sourceStatus", "IUCN_equivalent_status") + expect_s3_class(show_values_query, c("tbl_df", "tbl", "data.frame")) + expect_gt(nrow(show_values_query), 0) + expect_true(any(colnames(show_values_query) %in% extra_cols)) + expect_gt(ncol(show_values_query), 6) # adds additional columns + + # explicitly errors for other metadata types + request_metadata() |> + filter(field == "basisOfRecord") |> + select(everything()) |> + unnest() |> + expect_error() +}) From f59ea40f7fe41dbec4aa2a1d4febd185b85843f2 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Fri, 12 Sep 2025 17:10:21 +1000 Subject: [PATCH 23/94] Update as_query-unnest() to support added fields #266 --- R/as_query-unnest.R | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/R/as_query-unnest.R b/R/as_query-unnest.R index 8d89b24f..6dba36a5 100644 --- a/R/as_query-unnest.R +++ b/R/as_query-unnest.R @@ -27,23 +27,25 @@ as_query_fields_unnest <- function(.query){ #' @noRd #' @keywords Internal as_query_lists_unnest <- function(.query){ - + # get list lookup url url <- url_lookup("metadata/lists-unnest", list_id = .query$filter$value[1]) |> httr2::url_parse() - # Request additional raw fields if `show_fields(all_fields = TRUE)` - if(isTRUE(attributes(.query)$all_fields)) { - url$query <- list( - max = -1, # remove max limit - includeKVP = TRUE # add name & status columns - ) - } else { - url$query <- list( - max = -1 # remove max limit - ) + # set a default query + query <- list(max = -1) # remove max limit + # Request additional raw fields if `select(everything())` + if(!is.null(.query$select)){ + if(any(.query$select == "everything()")){ + query <- list( + max = -1, # remove max limit + includeKVP = TRUE # add name & status columns + ) + } } + url$query <- query + # create object result <- list( type = "metadata/lists-unnest", url = httr2::url_build(url)) From e31e906b9aba5e022935257ae593272beb632bfd Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Fri, 12 Sep 2025 17:12:48 +1000 Subject: [PATCH 24/94] Ensure adding arguments like `thumbnail` are parsed in `as_query()` #278 basically a cleaning exercise --- R/as_query-occurrences.R | 23 +++++++++++++---------- R/as_query.R | 8 ++++++-- R/atlas_occurrences.R | 3 ++- R/coalesce.R | 17 +++++++++-------- R/collapse.R | 19 ++++++------------- man/as_query.data_request.Rd | 5 ++++- man/coalesce.Rd | 7 ++----- man/collapse.data_request.Rd | 12 +++--------- 8 files changed, 45 insertions(+), 49 deletions(-) diff --git a/R/as_query-occurrences.R b/R/as_query-occurrences.R index c9f75a0a..288de81e 100644 --- a/R/as_query-occurrences.R +++ b/R/as_query-occurrences.R @@ -2,6 +2,7 @@ #' @noRd #' @keywords Internal as_query_occurrences <- function(.query, + ..., error_call = rlang::caller_env()){ if(is.null(.query$filter) & is.null(.query$identify) & @@ -10,16 +11,16 @@ as_query_occurrences <- function(.query, call = error_call) } switch(potions::pour("atlas", "region"), - "United Kingdom" = as_query_occurrences_uk(.query), - "Global" = as_query_occurrences_gbif(.query), - as_query_occurrences_la(.query)) + "United Kingdom" = as_query_occurrences_uk(.query, ...), + "Global" = as_query_occurrences_gbif(.query, ...), + as_query_occurrences_la(.query, ...)) } #' calculate the query to be returned for the UK atlas #' @param .query An object of class `data_request()` #' @noRd #' @keywords Internal -as_query_occurrences_uk <- function(.query){ +as_query_occurrences_uk <- function(.query, ...){ # set default columns if(is.null(.query$select)){ .query$select <- galah_select(group = "basic") @@ -52,7 +53,9 @@ as_query_occurrences_uk <- function(.query){ #' calculate the query to be returned for GBIF #' @noRd #' @keywords Internal -as_query_occurrences_gbif <- function(.query, format = "SIMPLE_CSV"){ +as_query_occurrences_gbif <- function(.query, + format = "SIMPLE_CSV", + ...){ # get user string username <- potions::pour("user", "username", .pkg = "galah") password <- potions::pour("user", "password", .pkg = "galah") @@ -81,7 +84,8 @@ as_query_occurrences_gbif <- function(.query, format = "SIMPLE_CSV"){ #' @param .query An object of class `data_request()` #' @noRd #' @keywords Internal -as_query_occurrences_la <- function(.query){ +as_query_occurrences_la <- function(.query, + mint_doi = FALSE){ # set default columns if(is.null(.query$select)){ .query$select <- galah_select(group = "basic") @@ -101,10 +105,9 @@ as_query_occurrences_la <- function(.query){ email = potions::pour("user", "email"), dwcHeaders = "true") # DOI conditional on this service being offered - if (!is.null(.query$mint_doi) & - potions::pour("atlas", "region") == "Australia" - ) { - query$mintDoi <- .query$mint_doi + if(isTRUE(.query$mint_doi) & + potions::pour("atlas", "region") == "Australia"){ + query$mintDoi <- TRUE } # build url url <- url_lookup("data/occurrences") |> diff --git a/R/as_query.R b/R/as_query.R index c0e3b1a9..f4ce8a0f 100644 --- a/R/as_query.R +++ b/R/as_query.R @@ -52,13 +52,17 @@ as_query <- function(x, ...){ } #' @rdname as_query.data_request +#' @param mint_doi Logical: should a DOI be minted for this download? Only +#' applies to `type = "occurrences"` when atlas chosen is "ALA". #' @order 2 #' @export -as_query.data_request <- function(x, ...){ +as_query.data_request <- function(x, + mint_doi = FALSE, + ...){ switch(x$type, "occurrences" = { if(is.null(x$group_by)){ - as_query_occurrences(x) + as_query_occurrences(x, mint_doi = mint_doi) }else{ as_query_species(x) } diff --git a/R/atlas_occurrences.R b/R/atlas_occurrences.R index 7b8738ac..1f3fac71 100644 --- a/R/atlas_occurrences.R +++ b/R/atlas_occurrences.R @@ -117,7 +117,8 @@ atlas_occurrences <- function(request = NULL, }else{ args <- as.list(environment()) # capture supplied arguments check_atlas_inputs(args) |> # convert to `data_request` object - collect(wait = TRUE, + collect(mint_doi = mint_doi, + wait = TRUE, file = file) } } \ No newline at end of file diff --git a/R/coalesce.R b/R/coalesce.R index d816dd61..f7e11bbd 100644 --- a/R/coalesce.R +++ b/R/coalesce.R @@ -13,7 +13,7 @@ #' @rdname coalesce #' @param x An object to be coalesced. Works for `data_request`, #' `metadata_request` and `file_request`. -#' @param ... Other arguments; not currently used. +#' @param ... Other arguments passed to [as_query()]. #' @order 1 #' @return An object of class `query_set`, which is simply a list of all `query` #' objects required to properly evaluate the specified request. @@ -66,24 +66,25 @@ coalesce.metadata_request <- function(x, ...){ cli::cli_abort("Requests of type `{current_type}` containing `unnest` must supply `filter()`.") } } - result[[(length(result) + 1)]] <- as_query(x) + if(x$type == "lists-unnest"){ + query_obj <- as_query_lists_unnest(x, ...) + }else{ + query_obj <- as_query(x) + } + result[[(length(result) + 1)]] <- query_obj class(result) <- "query_set" result } #' @rdname coalesce -#' @param thumbnail Logical: should thumbnail-size images be returned? Defaults -#' to `FALSE`, indicating full-size images are required. #' @order 4 #' @export coalesce.files_request <- function(x, - thumbnail, ...){ # NOTE: switch is technically superfluous right now, but could be useful # for future file types result <- list(switch(x$type, - "media" = as_query_media_files(x, - thumbnail = thumbnail) + "media" = as_query_media_files(x, ...) )) class(result) <- "query_set" result @@ -130,7 +131,7 @@ build_query_set_data <- function(x, mint_doi, ...){ } } if (x$type %in% c("occurrences", "media", "species") & - atlas_supports_reasons_api()) { + reasons_supported()) { result[[(length(result) + 1)]] <- request_metadata("reasons") |> as_query() } diff --git a/R/collapse.R b/R/collapse.R index 455dadea..e03c68f8 100644 --- a/R/collapse.R +++ b/R/collapse.R @@ -12,9 +12,7 @@ #' include `data_request`, `metadata_request` and `files_request` for building #' queries; and `query` or `query_set` once constructed (via [as_query()] or #' [coalesce()]). -#' @param ... Arguments passed on to other methods -#' @param mint_doi Logical: should a DOI be minted for this download? Only -#' applies to `type = "occurrences"` when atlas chosen is "ALA". +#' @param ... Arguments passed on to [as_query()]. #' @return An object of class `query`, which is a list-like object containing #' two or more of the following slots: #' @@ -32,8 +30,8 @@ #' \code{\link[=compute.data_request]{compute()}} or #' \code{\link[=collect.data_request]{collect()}}. #' @export -collapse.data_request <- function(x, ..., mint_doi){ - coalesce(x, mint_doi, ...) |> +collapse.data_request <- function(x, ...){ + coalesce(x, ...) |> collapse() } @@ -41,7 +39,8 @@ collapse.data_request <- function(x, ..., mint_doi){ #' @rdname collapse.data_request #' @order 2 #' @export -collapse.metadata_request <- function(x, ...){ +collapse.metadata_request <- function(x, + ...){ coalesce(x, ...) |> collapse() } @@ -49,18 +48,12 @@ collapse.metadata_request <- function(x, ...){ # if calling `collapse()` after `request_files()` #' @rdname collapse.data_request #' @order 3 -#' @param thumbnail Logical: should thumbnail-size images be returned? Defaults -#' to `FALSE`, indicating full-size images are required. #' @export collapse.files_request <- function(x, - # prefix? could be useful for file names - thumbnail = FALSE, ... ){ # convert to `query_set` then parse - coalesce(x, - thumbnail = thumbnail, - ...) |> + coalesce(x, ...) |> collapse() } diff --git a/man/as_query.data_request.Rd b/man/as_query.data_request.Rd index be688152..67f0feed 100644 --- a/man/as_query.data_request.Rd +++ b/man/as_query.data_request.Rd @@ -9,7 +9,7 @@ \usage{ as_query(x, ...) -\method{as_query}{data_request}(x, ...) +\method{as_query}{data_request}(x, mint_doi = FALSE, ...) \method{as_query}{metadata_request}(x, ...) @@ -22,6 +22,9 @@ as those produced by \code{\link[=galah_call]{galah_call()}}, namely \code{data_ \item{...}{Other arguments, currently ignored} +\item{mint_doi}{Logical: should a DOI be minted for this download? Only +applies to \code{type = "occurrences"} when atlas chosen is "ALA".} + \item{thumbnail}{Logical: should thumbnail-size images be returned? Defaults to \code{FALSE}, indicating full-size images are required.} } diff --git a/man/coalesce.Rd b/man/coalesce.Rd index f1f3edc1..d75ef2f9 100644 --- a/man/coalesce.Rd +++ b/man/coalesce.Rd @@ -13,19 +13,16 @@ coalesce(x, ...) \method{coalesce}{metadata_request}(x, ...) -\method{coalesce}{files_request}(x, thumbnail, ...) +\method{coalesce}{files_request}(x, ...) } \arguments{ \item{x}{An object to be coalesced. Works for \code{data_request}, \code{metadata_request} and \code{file_request}.} -\item{...}{Other arguments; not currently used.} +\item{...}{Other arguments passed to \code{\link[=as_query]{as_query()}}.} \item{mint_doi}{Logical: should a DOI be minted for this download? Only applies to \code{type = "occurrences"} when atlas chosen is "ALA".} - -\item{thumbnail}{Logical: should thumbnail-size images be returned? Defaults -to \code{FALSE}, indicating full-size images are required.} } \value{ An object of class \code{query_set}, which is simply a list of all \code{query} diff --git a/man/collapse.data_request.Rd b/man/collapse.data_request.Rd index c2be74e0..a486e465 100644 --- a/man/collapse.data_request.Rd +++ b/man/collapse.data_request.Rd @@ -8,11 +8,11 @@ \alias{collapse.query} \title{Generate a query} \usage{ -\method{collapse}{data_request}(x, ..., mint_doi) +\method{collapse}{data_request}(x, ...) \method{collapse}{metadata_request}(x, ...) -\method{collapse}{files_request}(x, thumbnail = FALSE, ...) +\method{collapse}{files_request}(x, ...) \method{collapse}{query_set}(x, ...) @@ -24,13 +24,7 @@ include \code{data_request}, \code{metadata_request} and \code{files_request} fo queries; and \code{query} or \code{query_set} once constructed (via \code{\link[=as_query]{as_query()}} or \code{\link[=coalesce]{coalesce()}}).} -\item{...}{Arguments passed on to other methods} - -\item{mint_doi}{Logical: should a DOI be minted for this download? Only -applies to \code{type = "occurrences"} when atlas chosen is "ALA".} - -\item{thumbnail}{Logical: should thumbnail-size images be returned? Defaults -to \code{FALSE}, indicating full-size images are required.} +\item{...}{Arguments passed on to \code{\link[=as_query]{as_query()}}.} } \value{ An object of class \code{query}, which is a list-like object containing From a83356cbc0487e0ae149292108f4bc5a141a2a59 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Fri, 12 Sep 2025 17:17:17 +1000 Subject: [PATCH 25/94] Swap to API for GBIF field descriptions #272 Also some tidying of other international APIs and associated tests --- R/as_query-metadata.R | 17 ++-- R/check.R | 14 ++-- R/collect_metadata.R | 39 ++++----- R/sysdata.rda | Bin 17853 -> 15318 bytes R/utilities_internal.R | 81 ++++++++++--------- data-raw/1_gbif_web_scraping.R | 51 ++++++------ data-raw/2_internal_data.R | 4 +- data-raw/gbif_fields.csv | 79 ------------------ data-raw/node_config.csv | 5 +- tests/testthat/test-international-Flanders.R | 63 +++++++-------- tests/testthat/test-international-GBIF.R | 64 ++++++++++----- tests/testthat/test-international-Spain.R | 34 +++++--- tests/testthat/test-international-Sweden.R | 34 +++++--- 13 files changed, 223 insertions(+), 262 deletions(-) delete mode 100644 data-raw/gbif_fields.csv diff --git a/R/as_query-metadata.R b/R/as_query-metadata.R index cca77f8f..3579705a 100644 --- a/R/as_query-metadata.R +++ b/R/as_query-metadata.R @@ -89,19 +89,14 @@ as_query_datasets <- function(.query){ #' @noRd #' @keywords Internal as_query_fields <- function(){ - if(is_gbif()){ + update_needed <- internal_cache_update_needed("fields") + if(update_needed){ result <- list(type = "metadata/fields", - data = "galah:::gbif_internal_archived$fields") + url = url_lookup("metadata/fields"), + headers = build_headers()) }else{ - update_needed <- internal_cache_update_needed("fields") - if(update_needed){ - result <- list(type = "metadata/fields", - url = url_lookup("metadata/fields"), - headers = build_headers()) - }else{ - result <- list(type = "metadata/fields", - data = "galah:::check_internal_cache()$fields") - } + result <- list(type = "metadata/fields", + data = "galah:::check_internal_cache()$fields") } class(result) <- "query" return(result) diff --git a/R/check.R b/R/check.R index 8c259657..75b9b1f4 100644 --- a/R/check.R +++ b/R/check.R @@ -86,14 +86,14 @@ check_email <- function(.query, # actually we check the userpwd entry here email_text <- .query$options$userpwd if(email_text == ":"){ - abort_email_missing(call = call) + abort_email_missing(error_call = call) } }else{ email_text <- httr2::url_parse(.query$url)$query$email if(is.null(email_text)) { - abort_email_missing(call = call) + abort_email_missing(error_call = call) }else if(email_text == ""){ - abort_email_missing(call = call) + abort_email_missing(error_call = call) } } .query @@ -598,10 +598,10 @@ check_occurrence_status <- function(.query){ #' @noRd #' @keywords Internal check_password <- function(.query, - error_call = rlang::caller_env()){ + call = rlang::caller_env()){ if (.query$options$userpwd == ":") { cli::cli_abort("GBIF requires a username and password to download occurrences or species.", - call = error_call) + call = call) } } @@ -655,9 +655,9 @@ check_profiles <- function(.query, #' @keywords Internal check_reason <- function(.query, error_call = rlang::caller_env()){ - if(atlas_supports_reasons_api()) { + if(reasons_supported()) { if(.query$type %in% c("data/occurrences", "data/species")){ - query <- url_parse(.query$url)$query + query <- httr2::url_parse(.query$url)$query if(is.null(query$reasonTypeId)){ c("Missing a valid download reason.", i = "See `show_all(reasons)`.", diff --git a/R/collect_metadata.R b/R/collect_metadata.R index 0ed4b2b8..15e159f8 100644 --- a/R/collect_metadata.R +++ b/R/collect_metadata.R @@ -154,17 +154,19 @@ collect_distributions_metadata <- function(.query){ #' @noRd #' @keywords Internal collect_fields <- function(.query){ - if(is_gbif()){ - result <- .query$data |> - parse(text = _) |> - eval() - attr(result, "call") <- "fields" - attr(result, "region") <- potions::pour("atlas", "region") - result - }else{ - if(!is.null(.query$url)){ # i.e. there is no cached `tibble` - result <- query_API(.query) |> - dplyr::bind_rows() + if(!is.null(.query$url)){ # i.e. there is no cached `tibble` + result <- query_API(.query) |> + dplyr::bind_rows() + + if(is_gbif()){ + result <- result |> + dplyr::mutate(id = .data$simpleName, + description = .data$qualifiedName, + type = "fields") |> + dplyr::select("id", "description", "type") + + }else{ + # if there is a 'stored' field, use it to filter results if(any(colnames(result) == "stored")){ result <- result |> @@ -176,14 +178,15 @@ collect_fields <- function(.query){ dplyr::select(dplyr::all_of(wanted_columns("fields"))) |> dplyr::mutate(type = "fields") |> dplyr::bind_rows(galah_internal_archived$media, - galah_internal_archived$other) - attr(result, "call") <- "fields" - attr(result, "region") <- potions::pour("atlas", "region") - check_internal_cache(fields = result) - result - }else{ # this should only happen when `data` slot is present in place of `url` - check_internal_cache()[["fields"]] + galah_internal_archived$other) } + + attr(result, "call") <- "fields" + attr(result, "region") <- potions::pour("atlas", "region") + check_internal_cache(fields = result) + result + }else{ # this should only happen when `data` slot is present in place of `url` + check_internal_cache()[["fields"]] } } diff --git a/R/sysdata.rda b/R/sysdata.rda index dbaec2d8f009ee50bc8d675e7269c7a225b8fef9..41de923d6c380d7300a4f6c15effad514231315d 100644 GIT binary patch literal 15318 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 literal 17853 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 - # dplyr::filter(type == "metadata/reasons") |> - # dplyr::pull(atlas) +is_gbif <- function(){ + potions::pour("atlas", "region") == "Global" } -##--------------------------------------------------------------- -## Data request helper functions -- -##--------------------------------------------------------------- - - -## show_all_atlases / search_atlases --------------------------# - #' Internal function to populate `groups` arg in `select()` #' @noRd #' @keywords Internal @@ -235,7 +224,7 @@ default_columns <- function() { "occurrenceStatus", "dataResourceName") }else{ - rlang::abort("Unknown `atlas`") + cli::cli_abort("Unknown `atlas`") } } @@ -256,7 +245,7 @@ image_fields <- function() { "Sweden")){ c("multimedia", "images", "sounds", "videos") }else{ - rlang::abort("Unknown `atlas`") + cli::cli_abort("Unknown `atlas`") } } @@ -275,22 +264,40 @@ species_facets <- function(){ } } -#' @noRd -#' @keywords Internal -source_type_id_lookup <- function(region){ - switch(region, - "Austria" = 1, - "United Kingdom" = 2001, - "2004") # ALA default for galah -} - #' @noRd #' @keywords Internal profiles_supported <- function(){ atlas <- potions::pour("atlas", "region") - if(atlas %in% c("Australia")) { + if(atlas %in% c("Australia", + "Flanders", + "Sweden", + "Spain")) { TRUE }else{ FALSE } +} + +#' Internal function for determining whether a Living Atlas supports reasons API. +#' This affects whether a reason is appended to a query in `collapse()` (and +#' checked in `compute()`) +#' @noRd +#' @keywords Internal +reasons_supported <- function(){ + atlas <- potions::pour("atlas", "region") + supported_atlases <- show_all(apis) |> + dplyr::filter(type == "metadata/reasons") |> + dplyr::pull(atlas) + atlas %in% supported_atlases +} + +#' @noRd +#' @keywords Internal +media_supported <- function(){ + atlas <- potions::pour("atlas", "region", + .pkg = "galah") + unsupported_atlases <- c("France", "Global") + if(atlas %in% unsupported_atlases){ + cli::cli_abort("`atlas_media` is not supported for atlas = {atlas}") + } } \ No newline at end of file diff --git a/data-raw/1_gbif_web_scraping.R b/data-raw/1_gbif_web_scraping.R index 34a0914e..75a3fc41 100644 --- a/data-raw/1_gbif_web_scraping.R +++ b/data-raw/1_gbif_web_scraping.R @@ -8,31 +8,32 @@ library(dplyr) # data manipulation library(purrr) # extraction from lists library(rvest) # web scraping assertions from gbif.org -# tibble of available fields: -gbif_parameters_url <- "https://www.gbif.org/developer/occurrence#parameters" -data_raw <- read_html(gbif_parameters_url) |> - html_node("body")|> - html_nodes("table") - -data_text <- data_raw |> - pluck(7) |> # poor practice to hard-code this; checks may be needed - html_nodes("tr") |> - html_text() - -lapply(strsplit(data_text, "\n"), function(a){ - tibble( - id = trimws(a[[1]]), - description = trimws(paste(a[-1], collapse = ""))) -}) |> - bind_rows() |> - slice(-1) |> # header row gets imported as a row by mistake - mutate(type = "fields") |> - filter( - !grepl("^facet", id), - !(id %in% c("geometry", "geodistance", "q", "hl", "format"))) |> - write_csv("./data-raw/gbif_fields.csv") - - +# Legacy code: this is now available via API +# # tibble of available fields: +# gbif_parameters_url <- "https://www.gbif.org/developer/occurrence#parameters" +# data_raw <- read_html(gbif_parameters_url) |> +# html_node("body")|> +# html_nodes("table") +# +# data_text <- data_raw |> +# pluck(7) |> # poor practice to hard-code this; checks may be needed +# html_nodes("tr") |> +# html_text() +# +# purrr::map(strsplit(data_text, "\n"), function(a){ +# tibble( +# id = trimws(a[[1]]), +# description = trimws(paste(a[-1], collapse = ""))) +# }) |> +# bind_rows() |> +# slice(-1) |> # header row gets imported as a row by mistake +# mutate(type = "fields") |> +# filter( +# !grepl("^facet", id), +# !(id %in% c("geometry", "geodistance", "q", "hl", "format"))) |> +# write_csv("./data-raw/gbif_fields.csv") + +# Assertions, however, don't appear to have an API (yet) # tibble of assertions, scraped from gbif developer docs: gbif_assertions_url <- "https://gbif.github.io/gbif-api/apidocs/org/gbif/api/vocabulary/OccurrenceIssue.html" data_raw <- read_html(gbif_assertions_url) |> html_node("body") diff --git a/data-raw/2_internal_data.R b/data-raw/2_internal_data.R index 87ec4829..1c1e62b2 100644 --- a/data-raw/2_internal_data.R +++ b/data-raw/2_internal_data.R @@ -100,7 +100,7 @@ galah_internal_archived <- list( # cached versions of some show_all functions # NOTE: may be necessary to expand this given changes to `show_all()` stored_types <- c("assertions", "fields", "profiles", "reasons") -galah_internal_cached <- lapply( +galah_internal_cached <- purrr::map( stored_types, function(a){ result <- request_metadata(type = a) |> collect() @@ -114,7 +114,7 @@ names(galah_internal_cached) <- stored_types # Import web-scraped gbif data as csv gbif_internal_archived <- list( assertions = read_csv("./data-raw/gbif_assertions.csv"), - fields = read_csv("./data-raw/gbif_fields.csv"), + # fields = read_csv("./data-raw/gbif_fields.csv"), ranks = tibble( id = seq_len(9), name = c("kingdom", "phylum", "class", diff --git a/data-raw/gbif_fields.csv b/data-raw/gbif_fields.csv deleted file mode 100644 index a1c7615a..00000000 --- a/data-raw/gbif_fields.csv +++ /dev/null @@ -1,79 +0,0 @@ -id,description,type -basisOfRecord,"Basis of record, as defined in our BasisOfRecord enum",fields -catalogNumber,"An identifier of any form assigned by the source within a physical collection or digital dataset for the record which may not be unique, but should be fairly unique in combination with the institution and collection code.",fields -classKey,Class classification key.,fields -collectionCode,An identifier of any form assigned by the source to identify the physical collection or digital dataset uniquely within the context of an institution.,fields -continent,"Continent, as defined in our Continent enum",fields -coordinateUncertaintyInMeters,The horizontal distance (in meters) from the given decimalLatitude and decimalLongitude describing the smallest circle containing the whole of the Location. Supports range queries.,fields -country,The 2-letter country code (as per ISO-3166-1) of the country in which the occurrence was recorded.,fields -crawlId,Crawl attempt that harvested this record.,fields -datasetId,The ID of the dataset.,fields -datasetKey,The occurrence dataset key (a uuid).,fields -datasetName,The name of the dataset.,fields -decimalLatitude,Latitude in decimals between -90 and 90 based on WGS 84. Supports range queries.,fields -decimalLongitude,Longitude in decimals between -180 and 180 based on WGS 84. Supports range queries.,fields -depth,Depth in meters relative to altitude. For example 10 meters below a lake surface with given altitude. Supports range queries.,fields -elevation,Elevation (altitude) in meters above sea level. Supports range queries.,fields -establishmentMeans,"EstablishmentMeans, as defined in our EstablishmentMeans enum",fields -eventDate,"Occurrence date in ISO 8601 format: yyyy, yyyy-MM, yyyy-MM-dd, or MM-dd. Supports range queries.",fields -eventId,An identifier for the information associated with a sampling event.,fields -familyKey,Family classification key.,fields -fromDate,"Start partial date of a date range, accepts the format yyyy-MM, for example: 2015-11",fields -gadmGid,"A GADM geographic identifier at any level, for example AGO, AGO.1_1, AGO.1.1_1 or AGO.1.1.1_1",fields -gadmLevel,"A GADM region level, valid values range from 0 to 3",fields -gadmLevel0Gid,"A GADM geographic identifier at the zero level, for example AGO",fields -gadmLevel1Gid,"A GADM geographic identifier at the first level, for example AGO.1_1",fields -gadmLevel2Gid,"A GADM geographic identifier at the second level, for example AFG.1.1_1",fields -gadmLevel3Gid,"A GADM geographic identifier at the third level, for example AFG.1.1.1_1",fields -genusKey,Genus classification key.,fields -geoDistance,"Filters to match occurrence records with coordinate values within a specified distance of a coordinate, it supports units: in (inch), yd (yards), ft (feet), km (kilometers), mmi (nautical miles), mm (millimeters), cm centimeters, mi (miles), m (meters), for example /occurrence/search?geoDistance=90,100,5km",fields -hasCoordinate,Limits searches to occurrence records which contain a value in both latitude and longitude (i.e. hasCoordinate=true limits to occurrence records with coordinate values and hasCoordinate=false limits to occurrence records without coordinate values).,fields -hasGeospatialIssue,"Includes/excludes occurrence records which contain spatial issues (as determined in our record interpretation), i.e. hasGeospatialIssue=true returns only those records with spatial issues while hasGeospatialIssue=false includes only records without spatial issues. The absence of this parameter returns any record with or without spatial issues.",fields -identifiedBy,The person who provided the taxonomic identification of the occurrence.,fields -identifiedByID,Identifier (e.g. ORCID) for the person who provided the taxonomic identification of the occurrence.,fields -institutionCode,An identifier of any form assigned by the source to identify the institution the record belongs to. Not guaranteed to be unique.,fields -issue,A specific interpretation issue as defined in our OccurrenceIssue enum,fields -kingdomKey,Kingdom classification key.,fields -lastInterpreted,"This date the record was last modified in GBIF, in ISO 8601 format: yyyy, yyyy-MM, yyyy-MM-dd, or MM-dd. Supports range queries. Note that this is the date the record was last changed in GBIF, not necessarily the date the record was first/last changed by the publisher. Data is re-interpreted when we change the taxonomic backbone, geographic data sources, or interpretation processes.",fields -license,The type license applied to the dataset or record.,fields -limit,"The maximum number of results to return. This can't be greater than 300, any value greater is set to 300.",fields -locality,The specific description of the place.,fields -mediaType,The kind of multimedia associated with an occurrence as defined in our MediaType enum,fields -modified,"The most recent date-time on which the resource was changed, according to the publisher",fields -month,"The month of the year, starting with 1 for January. Supports range queries.",fields -networkKey,The GBIF Network to which the occurrence belongs.,fields -occurrenceId,A single globally unique identifier for the occurrence record as provided by the publisher.,fields -occurrenceStatus,Either 'ABSENT' or 'PRESENT'; the presence or absence of the occurrence.,fields -orderKey,Order classification key.,fields -organismId,An identifier for the Organism instance (as opposed to a particular digital record of the Organism). May be a globally unique identifier or an identifier specific to the data set.,fields -organismQuantity,A number or enumeration value for the quantity of organisms.,fields -organismQuantityType,The type of quantification system used for the quantity of organisms.,fields -otherCatalogNumbers,Previous or alternate fully qualified catalog numbers.,fields -phylumKey,Phylum classification key.,fields -preparations,Preparation or preservation method for a specimen.,fields -programme,"A group of activities, often associated with a specific funding stream, such as the GBIF BID programme.",fields -projectId,"The identifier for a project, which is often assigned by a funded programme.",fields -protocol,Protocol or mechanism used to provide the occurrence record.,fields -publishingCountry,The 2-letter country code (as per ISO-3166-1) of the owining organization's country.,fields -publishingOrg,The publishing organization key (a uuid).,fields -publishingOrgKey,The publishing organization key (a uuid).,fields -recordedBy,The person who recorded the occurrence.,fields -recordedByID,Identifier (e.g. ORCID) for the person who recorded the occurrence.,fields -recordNumber,An identifier given to the record at the time it was recorded in the field.,fields -relativeOrganismQuantity,The relative measurement of the quantity of the organism (i.e. without absolute units).,fields -repatriated,Searches for records whose publishing country is different to the country where the record was recorded in.,fields -sampleSizeUnit,"The unit of measurement of the size (time duration, length, area, or volume) of a sample in a sampling event.",fields -sampleSizeValue,"A numeric value for a measurement of the size (time duration, length, area, or volume) of a sample in a sampling event.",fields -samplingProtocol,"The name of, reference to, or description of the method or protocol used during a sampling event",fields -scientificName,"A scientific name from the GBIF backbone. All included and synonym taxa are included in the search. Under the hood a call to the species match service is done first to retrieve a taxonKey. Only unique scientific names will return results, homonyms (many monomials) return nothing! Consider to use the taxonKey parameter instead and the species match service directly",fields -speciesKey,Species classification key.,fields -stateProvince,"he name of the next smaller administrative region than country (state, province, canton, department, region, etc.) in which the Location occurs.",fields -subgenusKey,Subgenus classification key.,fields -taxonKey,"A taxon key from the GBIF backbone. All included and synonym taxa are included in the search, so a search for aves with taxonKey=212 (i.e. /occurrence/search?taxonKey=212) will match all birds, no matter which species.",fields -toDate,"End partial date of a date range, accepts the format yyyy-MM, for example: 2019-12",fields -typeStatus,"Nomenclatural type (type status, typified scientific name, publication) applied to the subject.",fields -userCountry,Country country of the user who made the requested,fields -verbatimScientificName,"The scientific name provided to GBIF by the data publisher, before interpretation and processing by GBIF.",fields -verbatimTaxonId,The taxon identifier provided to GBIF by the data publisher.,fields -waterBody,The name of the water body in which the Locations occurs.,fields -year,The 4 digit year. A year of 98 will be interpreted as AD 98. Supports range queries.,fields diff --git a/data-raw/node_config.csv b/data-raw/node_config.csv index 58623fe8..75fee2a7 100644 --- a/data-raw/node_config.csv +++ b/data-raw/node_config.csv @@ -93,8 +93,8 @@ Flanders,metadata/profiles,https://natuurdata.inbo.be/data-quality-filter-servic Flanders,metadata/profiles-unnest,https://natuurdata.inbo.be/data-quality-filter-service/api/v1/data-profiles/{profile},TRUE Flanders,metadata/providers,https://natuurdata.inbo.be/collectory/ws/dataProvider,TRUE Flanders,metadata/reasons,https://natuurdata.inbo.be/logger/service/logger/reasons,TRUE -Flanders,metadata/taxa-single,https://api.gbif.org/v1/species/match?verbose=FALSE&name={name},TRUE -Flanders,metadata/taxa-multiple,https://api.gbif.org/v1/species/match,TRUE +Flanders,metadata/taxa-single,https://api.gbif.org/v2/species/match?verbose=FALSE&scientificName={name},TRUE +Flanders,metadata/taxa-multiple,https://api.gbif.org/v2/species/match,TRUE Flanders,metadata/taxa-unnest,https://api.gbif.org/v1/species/{id}/children,TRUE France,data/occurrences,https://openobs.mnhn.fr/biocache-service/occurrences/offline/download,TRUE France,data/occurrences-count,https://openobs.mnhn.fr/biocache-service/occurrences/search,TRUE @@ -170,7 +170,6 @@ Portugal,metadata/collections,https://metadados.gbif.pt/ws/collection,TRUE Portugal,metadata/datasets,https://metadados.gbif.pt/ws/dataResource,TRUE Portugal,metadata/fields,https://registos-ws.gbif.pt/index/fields,TRUE Portugal,metadata/fields-unnest,https://registos-ws.gbif.pt/occurrence/facets,TRUE -Portugal,metadata/fields-unnest,https://api.gbif.org/v1/occurrence/search/predicate,TRUE Portugal,metadata/licences,https://imagens.gbif.pt/ws/licence,TRUE Portugal,metadata/media,https://imagens.gbif.pt/ws/imageInfoForList,TRUE Portugal,metadata/providers,https://metadados.gbif.pt/ws/dataProvider,TRUE diff --git a/tests/testthat/test-international-Flanders.R b/tests/testthat/test-international-Flanders.R index feeb3ae7..e3b0f529 100644 --- a/tests/testthat/test-international-Flanders.R +++ b/tests/testthat/test-international-Flanders.R @@ -73,15 +73,34 @@ test_that("show_all(assertions) works for Flanders", { expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) }) -## profiles integration planned, but not currently working -# test_that("show_all(profiles) works for Flanders", { -# skip_if_offline(); skip_on_ci() -# x <- show_all(profiles) |> -# try(silent = TRUE) -# skip_if(inherits(x, "try-error"), message = "API not available") -# expect_gt(nrow(x), 1) -# expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) -# }) +test_that("show_all(profiles) works for Flanders", { + skip_if_offline(); skip_on_ci() + x <- show_all(profiles) |> + try(silent = TRUE) + skip_if(inherits(x, "try-error"), message = "API not available") + expect_gte(nrow(x), 1) + expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) + + # and values + y <- request_metadata() |> + filter(profiles == x$shortName[1]) |> + unnest() |> + collect() |> + try(silent = TRUE) + skip_if(inherits(x, "try-error"), message = "API not available") + expect_gt(nrow(y), 1) + expect_true(inherits(y, c("tbl_df", "tbl", "data.frame"))) + + # and actually reduces record count + records_all <- galah_call() |> + count() |> + collect() + records_clean <- galah_call() |> + apply_profile(x$shortName[1]) |> + count() |> + collect() + expect_lt(records_clean$count, records_all$count) +}) test_that("show_all(lists) works for Flanders", { skip_if_offline(); skip_on_ci() @@ -141,16 +160,6 @@ test_that("show_values works for fields for Flanders", { expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) }) -# test_that("show_values works for profiles for Flanders", { -# skip_if_offline(); skip_on_ci() -# x <- search_all(profiles, "LA") |> -# show_values() |> -# try(silent = TRUE) -# skip_if(inherits(x, "try-error"), message = "API not available") -# expect_gte(nrow(x), 1) -# expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) -# }) - test_that("atlas_counts works for Flanders", { skip_if_offline(); skip_on_ci() x <- atlas_counts() |> @@ -189,20 +198,6 @@ test_that("atlas_counts works with galah_identify for Flanders", { 0.1) # i.e. <1% margin of error }) -# test_that("atlas_counts works with apply_profile for Flanders", { -# skip_if_offline(); skip_on_ci() -# without_profile <- galah_call() |> -# count() |> -# collect() -# with_profile <- galah_call() |> -# apply_profile(LA) |> -# count() |> -# collect() -# expect_gt(with_profile$count, 0) -# expect_equal(class(without_profile), class(with_profile)) -# expect_lt(with_profile$count, without_profile$count) -# }) - test_that("atlas_counts works with group_by for Flanders", { skip_if_offline(); skip_on_ci() result <- galah_call() |> @@ -236,7 +231,7 @@ test_that("atlas_occurrences works for Flanders", { skip_if_offline(); skip_on_ci() galah_config( atlas = "Flanders", - email = "galah@natuurdata@inbo.be", + email = "galah.natuurdata@inbo.be", download_reason_id = 10, send_email = FALSE) query <- galah_call() |> diff --git a/tests/testthat/test-international-GBIF.R b/tests/testthat/test-international-GBIF.R index 5632f548..9ecf077d 100644 --- a/tests/testthat/test-international-GBIF.R +++ b/tests/testthat/test-international-GBIF.R @@ -9,16 +9,53 @@ test_that("swapping to atlas = GBIF works", { }) test_that("show_all(fields) works for GBIF", { - x <- request_metadata() |> collapse() + skip_if_offline(); skip_on_ci() + # first ensure underlying syntax is valid + x <- request_metadata() |> + collapse() expect_true(inherits(x, "query")) expect_true(x$type == "metadata/fields") x <- collect(x) expect_gt(nrow(x), 1) expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) + # then test 'traditional' syntax y <- show_all(fields) expect_equal(x, y) }) +test_that("search_all(fields) works for GBIF", { + skip_if_offline(); skip_on_ci() + result <- search_all(fields, "year") + expect_gte(nrow(result), 2) + result |> + inherits(c("tbl_df", "tbl", "data.frame")) |> + expect_true() + grepl("year", tolower(result$id)) |> + all() |> + expect_true() +}) + +test_that("show_values works for GBIF fields", { + skip_if_offline(); skip_on_ci() + # query syntax + x <- request_metadata() |> + filter(fields == "gbifRegion") |> + unnest() |> + collapse() + collect(x) + # traditional syntax + y <- search_fields("gbifRegion") |> + show_values() + # tests + x |> + inherits(c("tbl_df", "tbl", "data.frame")) |> + expect_true() + x |> + nrow() |> + expect_gt(1) + expect_equal(x, y) +}) + test_that("show_all(collections) works for GBIF", { skip_if_offline(); skip_on_ci() x <- show_all(collections, limit = 10) @@ -84,14 +121,14 @@ test_that("search_all(taxa) works for GBIF", { expect_true(x$class == "Mammalia") }) -test_that("search_all(taxa) works using data.frames for GBIF", { +test_that("search_all(taxa) works using a tibble for GBIF", { skip_if_offline(); skip_on_ci() x <- search_all(taxa, data.frame(kingdom = "Animalia", phylum = "Chordata")) |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") - expect_gte(nrow(x), 1) + expect_equal(nrow(x), 1) expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) }) @@ -100,7 +137,7 @@ test_that("search_all(identifiers) works for GBIF", { x <- search_all(identifiers, "359") |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") - expect_gte(nrow(x), 1) + expect_equal(nrow(x), 1) expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) }) @@ -129,27 +166,12 @@ test_that("search_all(providers) works for GBIF", { galah_config(verbose = FALSE) -test_that("search_all(fields) works for GBIF", { - skip_if_offline(); skip_on_ci() - result <- search_all(fields, "year") - expect_equal(nrow(result), 2) - expect_true(inherits(result, c("tbl_df", "tbl", "data.frame"))) -}) - -test_that("show_values works for GBIF fields", { - skip_if_offline(); skip_on_ci() - search_fields("basisOfRecord") |> - show_values() |> - nrow() |> - expect_gt(1) -}) - test_that("atlas_counts works for GBIF", { skip_if_offline(); skip_on_ci() galah_call() |> count() |> collect() |> - pull("count") |> + dplyr::pull("count") |> expect_gt(0) }) @@ -278,9 +300,7 @@ test_that("`count` works with 2 `group_by` args for GBIF", { # collect() # }) -# FIXME: GBIF grouped counts only work for n = 1 - expand this or add warning # FIXME: `slice_head()` not tested for GBIF -# FIXME: `check_fields()` not tested for GBIF - try sending invalid fields to `filter()` test_that("`count()` works with `galah_polygon()` for GBIF", { skip_if_offline(); skip_on_ci() diff --git a/tests/testthat/test-international-Spain.R b/tests/testthat/test-international-Spain.R index 6e471df7..84d61285 100644 --- a/tests/testthat/test-international-Spain.R +++ b/tests/testthat/test-international-Spain.R @@ -71,13 +71,33 @@ test_that("show_all(assertions) works for Spain", { expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) }) -test_that("show_all(profiles) works for Spain", { +test_that("show_all(profiles) works for Flanders", { skip_if_offline(); skip_on_ci() x <- show_all(profiles) |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") - expect_gt(nrow(x), 1) + expect_gte(nrow(x), 1) expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) + + # and values + y <- request_metadata() |> + filter(profiles == x$shortName[1]) |> + unnest() |> + collect() |> + try(silent = TRUE) + skip_if(inherits(x, "try-error"), message = "API not available") + expect_gt(nrow(y), 1) + expect_true(inherits(y, c("tbl_df", "tbl", "data.frame"))) + + # and actually reduces record count + records_all <- galah_call() |> + count() |> + collect() + records_clean <- galah_call() |> + apply_profile(x$shortName[1]) |> + count() |> + collect() + expect_lt(records_clean$count, records_all$count) }) test_that("show_all(lists) works for Spain", { @@ -137,16 +157,6 @@ test_that("show_values works for fields for Spain", { expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) }) -test_that("show_values works for profiles for Spain", { - skip_if_offline(); skip_on_ci() - x <- search_all(profiles, "LA") |> - show_values() |> - try(silent = TRUE) - skip_if(inherits(x, "try-error"), message = "API not available") - expect_gte(nrow(x), 1) - expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) -}) - test_that("atlas_counts works for Spain", { skip_if_offline(); skip_on_ci() x <- atlas_counts() |> diff --git a/tests/testthat/test-international-Sweden.R b/tests/testthat/test-international-Sweden.R index b720b5e9..adbe49ef 100644 --- a/tests/testthat/test-international-Sweden.R +++ b/tests/testthat/test-international-Sweden.R @@ -60,12 +60,12 @@ test_that("show_all(providers) works for Sweden", { expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) }) -test_that("show_all(reasons) fails for Sweden", { +test_that("show_all(reasons) works for Sweden", { skip_if_offline(); skip_on_ci() x <- show_all(reasons) |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") - expect_gte(nrow(x), 0) # no data at present + expect_gt(nrow(x), 0) expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) }) @@ -85,6 +85,26 @@ test_that("show_all(profiles) works for Sweden", { skip_if(inherits(x, "try-error"), message = "API not available") expect_gte(nrow(x), 1) expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) + + # and values + y <- request_metadata() |> + filter(profiles == x$shortName[1]) |> + unnest() |> + collect() |> + try(silent = TRUE) + skip_if(inherits(x, "try-error"), message = "API not available") + expect_gt(nrow(y), 1) + expect_true(inherits(y, c("tbl_df", "tbl", "data.frame"))) + + # and actually reduces record count + records_all <- galah_call() |> + count() |> + collect() + records_clean <- galah_call() |> + apply_profile(x$shortName[1]) |> + count() |> + collect() + expect_lt(records_clean$count, records_all$count) }) test_that("search_all(fields) works for Sweden", { @@ -157,16 +177,6 @@ test_that("show_values works for lists in Sweden", { expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) }) -test_that("show_values works for profiles in Sweden", { - skip_if_offline(); skip_on_ci() - x <- try({search_all(profiles, "SBDI") |> - show_values()}, - silent = TRUE) - skip_if(inherits(x, "try-error"), message = "API not available") - expect_gte(nrow(x), 1) - expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) -}) - test_that("atlas_counts works with type = 'occurrences' for Sweden", { skip_if_offline(); skip_on_ci() x <- atlas_counts() |> From 8c14acd8f09f268ff484099ec5d3312dc4000cdd Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Mon, 15 Sep 2025 11:09:21 +1000 Subject: [PATCH 26/94] New version of API availability plot --- man/figures/atlases_plot.png | Bin 314243 -> 301630 bytes vignettes/atlases_plot.R | 224 +++++++++++++----- vignettes/choosing_an_atlas.Rmd | 5 +- .../object_oriented_programming.Rmd.orig | 4 - 4 files changed, 168 insertions(+), 65 deletions(-) diff --git a/man/figures/atlases_plot.png b/man/figures/atlases_plot.png index b75603045ee913a5179ea4457697ac192f203eab..0b96941d19eca02ecd821101dd8156aa24590cad 100644 GIT binary patch literal 301630 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

    $awWJ3 z=0+OT%{|{4M82nq<#bU^$mYtx{`rXBhYDSdyghGD?y8L1#{8YlK6{{Jq9uziXrr|HeNF9mrkL2oHQ z4Px;7^z>U4pfRKEmJ?7{7@S*py!#-y1mJ-l6<+a+Nm#$wn_Z;bM!|KLgq6& zcCWc);!^&}L$*ch)ekmM6tI!&f2!li>dd77Ja#T}!n=*)i8 z`Ne_Lq5H=DhqA~6-)>;kFWZjOu~Iayk4BB9DE~eXwQ@my@no2*-k!Cws#*2$ZfaV5 zq@GRVYs9W23;ITke~7Iz6|lqy()_ZzCL-%aXiW0bBwdc^Uos^fwM<8uDrf^bszw-o=q|W7Q@Q8p z7*wHn;N}?=X3brOL_#%1C_}7VYguO`*1##B!~sogu&%Pr{CbsgyXzOO$@9oI)nG53 z6^tN)gjLbr0NVXOSXR~=n4aATKnX;H+v?=}9?m(rEOHs2VN!L}^L}k2FAaE$G8j1q1uNN{~?wrWksKa_PwDa!rmr%R=J0TH~>3O^)>!@ipFJ%V%W#?Cg!Oy@~_2 zAd2Z?xygN91?-$|tS8rD-pMVZvPtFCVbRe_K@5^ZC`-~kDsXfOE=o-zKmq%gFZZyr z7%v{S;`gG^w?`!Qbt0V)MxhI%01{u2ELxdH%I69YrpW&Yw&QxKHD@!$vFO%$cF<99 zStQ<`Acq4^lF*n{!_>so>WwXFyem_-<9zSQ4U*q--B0UQSuyQx>OvOdBLRz+&8503 z#YyD%&i6!Cp7G(Go~xwj*rFxz(Q=YDToy*$aw#l_lUn<1E&wc4%;n0-2VA22%(0qW zPl^VQDeRy1-wL|_Y=&{3tmL1QJ$uV8L6#FQMC!}&OyyE80!x~!8po?5GxEi0IZ_B^ z5e7m@lN((RE4%eLoTd|1IVW)@*sDKU;NN$>@RsmF*DzUaZ#P7v{Zeq`voU)DjRmwZ z0J4^UyDYwO)PQ6aX7z|G*-G>EoD;Y3n;Q*%y_(D;D6(2_~G$nNpYW+>3mr z6x0)IiY(3FqY9@cjd1*SAg0h`DdGe(81;(p@q6U&+wR42*}&3#**n*~x5$mT?E`Nv z)^eL8&sMcF>jBJV8tK__LDd7%;4-{sq4nTQxYzWPhgd8IBMI{!mYU@`XYhwAbc=_J zw`f!V`5UwtYQ{geMfzAI|Mi`PR{K?@{8P^AR4Eix3MmE@WRXN$&(?H!K60BoY!xTf zb*pYEOOpf5L?RmTIb!JWA>l_|ql@*YcP4LCEm5GwzY9cbK(FBZ6nS%j7Z z-1j|EXO+vmxwVTxefvdt^Hi=xpEN4PLdi`uOY>mg04JaZmtm`q#A(q$h+rBa<33Po zs$ly?zhtz-b!}js?9r*Iggy3ZFR`!G{o*p$AD3y8Alccz=0{LWbgVZZ231T3H0tf^ z$cMv)uDiz+d7S{u@$%eS3AV(%k=kt4r7s#xu~CY~e~~$+Iq&j>aE;rn{scCi4g*LQ zLSA8HYEqn9CxTXH^s79bWsM7C*p+=!*ITw z+vz4)s*{3=q~z4$PQ8%V{_c^=^8rns(yYp?f!3gt-GxlWp$Z(ENul1L3h)7&HeK?G zLdJVIJ5IX7f4)!9k-J<2XwZe{_#;XcPk-mjq~@o$1C@0ZT-PU=vwMKdO&V)(7Cx90 zvcfjkUF{}oa88}uc+*mXL4_iG{Rg~tlIMFy`Ej(jshXOav55(`$5lX7*n#DqB&2A| zZH(Uph% zLrxxuD4The`s{BZ$_>I(cBFtMemC(J1dwRyKcD-;9;c^bs5 zCdzj*7S=xTsi0yYNk?^if(Z4ooMrSMezoVPzHnG8I1+A?y11JX7pU(zuO=D{1P<>? zwH;2DrM{p%Zx#*4O5$pU^>hQO-)gaJbqN_;AagcfUC(hE3ARtTHc=*B{wP3qND>R{ zrm7Kg(F00(a+qolivJA?4Ew3=pE49FjxQhTp9cNQCn92r=;&|QG!H4w*SCl?mh#Yna5NCop6+I=kRi6seYnwB_ zAt87xla2G1qiAR!{BSIsQ2%^rj~|Y{&!Ev#ZE!OK&|BfHi@}4&R{~Qt&&K~hqy<7p zwX2IBm#05Wsn!6DTq9&V)Ul-^>cXtwWPiCGBep=Y0PEK)6*z62T*Yc07igKrI~Q_1 zH@v0uuiZF89pc+Nd0yw1z99r(saS8HSi8Uz)kYg*)K*xLCon-v;#<~C;SW*>_Nksak23v zsVZ}DDxU_L$d&l|5Dta8^&B8-1`^jNH==>N&K+%H_o01<;2xli?3pJA|?A02?)(7V5kv~nm4q`0LBXZn}0eYy%^$yboPfYcuSYAb< zar*RRlz4eq!z?^PZ&XZ&@*+orv}!GTE^q5YGBP=I95#wMN%lnL28OVAy@miOf%mxcFXU6zjj_jTzMTTuAb+fO z1yo}Px_e*reT`))E*op36I+v+p(YjKqWl)!jUs-FvN4e4qI|AGg6~@il+Q~>Cdh%f zALela2=zSHzI;-13d_s0Y90Mm+6>F7^J6=O8f*_QS6n#6)3VH5(rJ64kA3 z?*I(~hu5wvbcnyCVfw`;N|eHT&IfD~vUib22O=$JWHE#kxG~_Sgh=3LS9Nj&AECc< zQIhbKpTxypZ=|<430D5k_No~*p9O83*8(cz+@ebHMiDb?mY(@#eqIs&shp=g_o7c+ zjv3CP)8cWy!$7IiwUqvD%TV)rVe7Hk+ZW#~$9p+5xnF-YDFs;sxV-rU)ru*rF^Sx_5qYA83 zB`9#C%!Aw&v>-t7AS%1%+%QXKzq$E{A~9hV=)Dpv3^AxuDbC7Ed+GcU%A275MweT! zGp9No<=)1*Ida{Gp=GuKh5Ejl1oFwvTl2}s6KT&2Iog#mD#ZP( z@M%@|?rO8Wxo0a?cPXrbox{{eaBDnDp{KY1RptP6aEoK@Yv}-T&QQwf>bfBGo90)` zm(dv1Ww^eTU;6#pS=vf)@Fg~182RkYJ(>$ky-37tkLOySwP0>22kIWEg&^pAC0n48 zm)i&d9FGuz4A-{NLB)IE1cm~`l)wqz<9z(1V&2&vCFyl}&{y&iNF2vr0hSz;aqp{j zk9DL9KpzMERt)Toa+Kuy{u)WTuuJH&0%@eBb$ZNXx8)X&h3-}l6zsOO>S^SjZDlE+ zd`JS>?5?8uDe7)d{_z4TrQr<GlPTiPHJw9Tw4GY$4h3$1& z*9~-~Z(uLK!gPx0xWT5e_^ho0DF5JBT?eAUAWx$lkp4M!J*eAqcJPpBWuy{!llT>+ zAM#ifbDNve@#xR@qW*rm{bK9Y1a=F>i`xsRqQh#0pZ27X1yI8bC@|wJKCxR@%lZPE z6hQaXz&cjLN^iE8_pmv*;dA8A72T#U{la)x=&3r+$b7K8V5f`l>s8PW_~QbFFN=_t z1G|6c9GbK#Lbud=###j~;`y>q-lVWP&126-WODotijx8yGWrvvH`f@9XmiHQCL=CwZk<4O?pV>sEY$< ze&NZknhN^(Y2P2;1W4bWbH~S=j;kngZtiadf(B^0k^CUuMbcZqQ+!Ip{`br$pkb<7 zj3K0$teBHLkS4-|nR8khOosjow5RQ+%ZpaG+^^sbNOzBf7n z@;Hse5}WazS&brqSxrJ2c<%4bmaDxpF`3$_nQ3}A0f3VKmy;UorILR$$ZDE0l{obJ`UlLR!eQXQ zM!i1`DMwnS1txvHNZE!!t!aRGQkUOlYo@Lbl>e>5W2?DJvx!f)P0%CM)&*uL0gLBl z@#KE}(Zmvi#YAbb5H}t7sIlL}+XL;}A|He8kQCh#))DvC{lnc!^Tn$ZnIaUN}uzJxmt+KxzO zpasRAa%s``z8y%Wnb~vJ4zncHVh--mf{ZerY2Lr#!`16>^+m5SooO|+^l0`CN9&1K!m@29 zF9$GE@N!b4m0M9_q-7+q(hHZkUP&Y5Ss5}MX|?CAu?-D852=8|oq;wy)nV&rm}x4z zcW>5}{!gRKbOD_kH-7_2w&(vo%y(7U$-A3P-i_1Qsm7s%x1m%J<$#~e&?nn zWF?=CLE15k1&F}B(6Q60ovyfyO-+N~W-rUD-U7qeTc!y`UZ$f`e6W6@JN@IKVfFQK zMP0g?*51~6ro~%g4rdvzjEbY_ABUOMY~*=V1UR2xDw+xU5KbU^58Q{-zB5R;TPJrR zitwioK;plj6&+M#aeIoIM&==@t{*eR8WNhs^;_ejepH=!%kj}Skwm#)6H-66M#|l> zad5Ex(}bKoS!D`afP$uwxqi3zbsVC+03G3#$#DnqZIYF<^fU)sE_OkC0jb4xA-GDx&D1(y|a36neM1` z!wUy^J;qh?Ied7MyI&hV5R`VhZ5Mbs3dY}@oh&wn-Hk?)=X`nY+dxo*-0k}~nZ;CfG>q}aW zmigDu0EE&-Yt-?3#LGRrt0a6k2 z??<4CeQHq(YFL!J5A??6lvjuc&cfs{)LYh?-|ln0l`BohtWn=Rw>eWMx2N8Iwsv`0 z6F8tv<~YapdwKfDQP&St%lYPk{s~Msi&|XjsTi|`m4JzT4L<<+oaGrxHuCHOnJ=NJ zs_HxI&>!ABMGjpoR*C;xX@w4rzZp$%rY20z*G@0UoAO_CJm6OSSbdRJVto%Hr$}e%tc;jB z8T@e*f5Bn5&DV?$ps!b)bU6D3Ff9PEp;H~(I}GSKmf>yC&i+H91TVjfL2Dxc0~=!r zM>}uXmx#V1i=!Ng;2!R~QK7TZRRU4zfe)5x{nL%KSg!Zw%7 zxrbZE5z5<1J-;t~Cs4_r&MktLdA)O%qJ?5>Zuy|e%KBt?f~J;(CD*;XuFk*)D=p-P z+TE3U%VUCfy)#JPljO`-vH7~KebeIVVfx}xA9=hz` zff^HF#v%CMO6XMe>k0}AmJg7fq841=d?M2^Z&OV(*|_g1C;hJP=24Lx5G`8!Yu_>xDm-I3(3z{Rfzuw$ zyLAWN?4xr$0LA*>MRT4S%7m)BZRg>S`z!ldxWKJrO4q@QnbSo&a`qaEW~dN!ZpNIh zd7^{9M}d2ySO2d=POSVxr1nmna3gmd(Sv+;pv{A`23z_{w{Gk8ww}FkRdE;jSP+xJF&r5bxR1cc;RI`%NH)d1L)v>7Ll4_xv1J0}TS1F2)g%1|y&H^?LWYx&DvJqTL+W{woeF zLB+jCb3V$sgY_l1penqt;0$~pl5$v8r|UsaWJ$%*Zc4cula^*Y^O|*z z4@-KD1plAgGcJEVuSui)MDzs!#{O(>Yc+zAFNrjkW;QSuj7kK)<6rwpC-vz7wDK87 z{;Ecp1Ue1y@2AFtm53(4=<6Vdx*dO!RRacWLdTmycEv(IRsM_Ngk~9 zzk-1$u>Xwf1iu^!3rnP4@T?~pD-TBc;$_ovAn8-qNsHY(PEhVaJ%33{wv2Tu?#g+w zPwF@<1c8in4G2FVoSSg+0>XW~9EE+}yjzQda8?%~*pgv7Qx{jyKm#~QGq49n=xews z#Q`JpV*oJ1XX*^68f@l0Z*{?tym%|U`FXzHk&}Dhd-t|EjdwmI7^w6+LntP}=`4R- zFcWCHN`YppM&BW`ZXR}wLH8!bvYAYG5|nYzePhD-_PUa~-QL}z7gYYy_M3RW3tJI~ z>)MBePdBQq)^$*g*VYz?k{617N*8P3W~rkeN}K$+(G?=R;4RFg9Nu#!E7Z^z`Y|?O zjOepzHu*hskE%m6H^q7ZjbXbZETumbHO=b-IL;O$l!lwpt6v2~-Z%m`Qvs7Y?>Q$5 zfr#$!?*)K=^2a6Ry20m@>jdWkT?DUu*3U?XT6Ue)&uf0a+AD4?ji03vx@3*ltn{Rz zTzhs7X*1|=$+6X)jpD1`_dEghtzQuIXPXG%GPjfPspw6mF_bqBQ%C6sqXzoL$9~hB z()te1Qy?Gi?<*#6HRLU~`dCN#*aAtqsav3pTg@j9yOxy)uM=fX$*L_3H zjA?ZhqZ>?fwaZ6@X@dFZ#6a(kC;Dp0=4?9YASqK>R6@qH_%rC_+{<_FS{4GFj=XZ} zdLH5IJFFzRUD;ppZdPvZ5;Chma`N`mRosEY4qCLEz4;=>Mei}RBE%R%7<2^IbHf|Q zuUjwp>OE}gR@Z%WwGDGicDzb!=|`_2IRK#G0dSSrd306Vw=uNv;%N&hV}))0UPrLiTs5)eGOb|)ieZd>Ci@JSkeMJYYY87H2bf-SXovV zD4!h&`oB6(gC3+AoE`yap0JMOzly1Z7ctE$4< zy!Nf%7*8yLR5gpdt7p<%ec9Z4WIE4O0V3>?=D0n~c99!^pEY=g zq~!hS5$7ylTAI8Ga^`)9W;c42qRXuQahzm3!Ven`huU#wNLfsug2d34gB^>E8`Q01 z;!*Wn6UeI6Gx3bY68zAReegA#B>^*ckS`@f%3UplN?||Tnkf|$a&kuVthx1oUU}Q+ zMzF?*d(Y!PLTM~71_BvpcDd9%v8i)Q4f=JVl?NpixcYZDQ~5Q z?Kv$Yi}X9lyMu~7j!TVT_Sc#{miI-E;-V{yWhz(qp7ZVb`t6&Wl_Yu_VRw+xUwqrm ziUpkVkAu5_wUymvWZK3b${p9jlv$HW4(Nb>X7P!{`uG;Iax|sh1r4na4E@GSihDyU z0Nbr-x!Ja8*k2R)tJu~_?AV7G_1tagqin~vN2@G7Hb_HO$`A}X#+SMg&5$#Vc6y$2fl!?3w|}6m6vr< zVP{CEkW}7ikS*sxHggpLDxmI^jmYeTRmPk#Ad6O?%WG76ngQnjy56Vd9n4XnUhKBQ z<9v6aUFlb!B>I^<2K{n59TfcL03aaJW>%y7zsSuoLhDO}ui7}kVW;^Q!nc<}6N{tMt{!TwtS^eUgOg#64uulZ4x*Ejv#+VHo9{){BtNiF*v$;9N-( zyGQ5B8lWy;(las)9=hZ3E&asfC-*TqA5#!YmlI?%GHgAg<(GM8JWgflj)2gLeE+zMa#d>z-bjtQqBvvpd39>$qK z->f>H;K@EyuGQjv(l!WxH7M535JA1U?rXF@FGx}UQe`eaioVt=$&gG$P{s!H>I|U? z!w1SWO{aQG8z+LTa)iVv3<&!knU&Y;4#&EByC`Nle%;PCJy#5wXzdPW2U=ahl0OMf zBzdj-Z;J7$X*upwS~s4>5df_qCHkFKaJsoW8LgN>j1dT)o_+%Iy(zq*S?;gkH)*(z z7XHIZAk4NwwTG(gA3eGw<4EayXvzG2fAG5d|6%VvqoT^Ts8LWsOau{;pyZq+3kV1j zM3Nv#29cZviX6&-f@F!3B}0*voJ&PPvSi63Ip-Yet+Ma!?)&|Czuz0tOSVr<%ez)-&u%^v-ir5#kHk#eiDTBEjn!^x4EZrK|_cGSDbP3^+9l{u`{)X`Mvjg=^iS$ywu2dZ9N&&5) z+Mfo7ng?*!$;TQAoXEDPHtkMN+mwFpATI8khQ{VxQjl$J4W8dN%VK$5MUPX{ zJaP?#9rUkN&}m(scl}lhEe&*=d`~=mz7qat+zM@z#%yKOvlgfK&h^rOkna9l^&rO^ zr$BEx5!%qZ!`Eo`7>AFAVMb62y9!tZVz_e26W^eVM0pz-^q{1p03{tgo5?7N3;|t9 z9ih>iADLDnw=vK+B9Hr&K5jXhf|K$&BW-3s<4iQUGyxogBQQ5|hA~*N$7yn%lmWDP z*0AB?+IAW(j$--+f|9q{UjJu#l;Cybdy=9+6aUnn`;JZg^e;CHF_5J7rVF+^xyut} zyl|KBht2_?#OLpFxUYR>TUGi?CP@#XtF&}}Ppx!&mTrw+aK)x$6%DWVmPw&P z(I)vWWtiU&C;rL)Y__Sw?!Mbv)sycc@e`HLb4lu%f2}dOK2QD(AqT>|DgmvMGZ|qp z2(s=(CBoAZ?Dt>}8tkt7a-Ldc9>fCS=ym~KFBdr1F6Y4*`0%;A;OQCf^V9ZAg z)A*pVkWZ8Bo;D7S#gB}b6o$GtdFvpA47bM$A;G8azoDK;Wk-Q)mYRNWnf^b@y-3jO zTG1zz1$W=qTu^+BW1Bi{#^?Hj5-S7Dj>7gY z?GL^&3VH$h-2;$;eg{FhdfR3$6`aYl(L)%e1n(ga!s>vG-i^z=a=*~625D0@zq&5YA#CheIQ597aU4CD638B znm+>>cLOYjDTuO_`68{)%WE#c;Ycs6)BOIUs-%yMyTQf7zs}VpWlriOtI6(i_N}zJ zg=H0mG_$g2+zV`h@vi29W?UGj*iwc<+;pE4Uxf1)Pi)^p%EtT~LW1xu{rH&W=)tk6 zHsMInaMOb6D|wFc4JUl}U`W3i1v2UupD*u=SxMmKEa2ttWrRZmO48f!Y!>Mt(~VQr z+>5NM2#?}xO*gY3aO*Dpki`kY@ocq|XKV8uIGwCJf9(q=i3UFmqduz$h?Md#o=S#>d2C9aWZW4 zMXWmgdyP-TxD11bKD5vEbzNqsF&3*Gr_)(io5CddYZAnsKlNiAAr8Mwh$rJ@DZ7`ny>BV>*|-d zh{r<5dosr?9+bSW*hnKooP@u_zHEO`5_K^qVOpD;iD^Q zQof%uPZJOxok%+~Hk08Y2&-b+oN#KXdZ`EOo^|kCY>v2MOcKGUp$UH(&KSgo^CS;9 z0Eh1HmJDL)Q;%!;akw<-G@w0ZK?z6M>gYv+wHBiefx`J(@^Q;e0^^9xt_3_-+3ykI zj3kzr=za7>TH2?ictn&tttj>@n)lT2)bA9cq%~i$YiOG7JzjL2$9ZuYjs7LoFq4`i zed*^gjx1T)b^FtdI@UL5)XO*`7=4e7@X^3N|9cp&J9>ZrD^5MbzmACWBR=K#_eXaK z=>G3V|DTu*XD=^%$~&e$aL`F7rfcKB~r(<;_BHaz;3zhVzQJ*Y(Be11M|WT_$}BLg?ltBP#FUiZk%xmhc zyh`FNIzRWr<%-CNh@&z+p0X~5zn_M4qtx!%hx+=H%}z=l!od@`S% z65l=}wp!~v(-wC%D6{Gq3^7q|5FN?J5%_z=xTZ~dSdQhd4m#}E-G97(7@REOb5MV{ zqF0T`ofW*f?0V==B1F0&b6CQYhBuJa_Kg!z?8a7{zQ`++^D$Ev?bEEkLOigYYYhJS~tGDy5P0DGOBN`5Mf`tE>|*m)FHM?KzIJa z1-IoPm+5FN>s$89%I)QL!#m5vv2k%$C4-(jOCIx?u`0>8MMOjfTg7ithxYdNCW*Oa zWoC*{hdw`jHuSof>&8^Gnwnbj1@^#*w{Jh%3={?j2mk*4L?Pnx<;(aNuiZ3nd#9Ez z_v+Ovt6Tq^cln-cPx4MB3}|FT-{K$)d6e!y_r7&&zDL+$EHOHo$xj<*ya-oXQxko( z(TY5pq(Pyog=VI(7pGCZ_tsojnD~yijEvuG(iL)Y$(^4j)6>(1TvmUsAab6Q<5eVb zyH874>n0c1a-Wt_L4H1BT>Am``rxr?t`jrcktF7GfJo{1XWA?oe3PGOX!Ze*E!M-v z#ujVf8Yi5|s8D^lQj;{FmX=mr%$E{c`S|k-_(qNV+0o_Uid8N2LG-N8%1G6wFnT}4 z3-QATQ-nS_$WHMcGW~qlpHO_WjsH^G%atp#9^H{ z=NTQ}k6`4vKuE#&HM*mt18!Opizj)!)#HFZ?vG36GizC{7#}OM(Nc(LX>D~|9XnpB zS!EA=Ql?AeG4&P(B`iEVi^d!~(D`w;?vA>RYib#gk&-?%GD5NxfnTkE#-G0QYpquHnNbMc(j@NXbK1;Y&|GwE&_mC#m$hRss^S+{@lt6nS&3JHm3WaH$t-CLV*sM~(z z?On^?kl;{1_&EG`?!)Z-M|s5be0)w5UtYn)h6DtJGb%)6ND20FX zErWb5M(FR6J`?&qi!-q@T9fp~xZ$-9=T*!~?Z+^QgYPbti$&;q$Jc&-ek{rEU*b-{ zifpW{N z8W~DkI%rGi-w>ywqOvoYoDh|iw5%i}C%3aYj;^weU{p@NJs3AuMeT{!wNG%_pP)liRY0chmYHSpWwVQ5Uc5ME@#M#QsbptnW@b@R7S&{c zQH}z<+}s^^*TG1|NFf^4*Vlg@3Pho9h$1XUt6jizFj42AtLg#bmzn5{(*s-?v;kKx|%MRg7os0Ov1UeM!zz*%%jvwX>L~XSsR%sSwxVU zy6A<67gmYS;P}(iNV0Tj1hOa0T}$0r$YJ)17okut5y{9eQMW4lAkw>cuL~VHl>3VO z!KsESYqs4)M4_CvX_;cF=!YPJU5nLm2@SgGlvou_0-L0Oa}R6W9vprRx_Xs|@XY5S zf^DW)jr+r@_pM40?wb0C=p=F==Z_IfoqMpsHi9}FRzeL zc^${}+34u#Y}J&){Ct5pB}Yfc=H_OpwNeOTB^I4A(a``C_gd z^1!03tV~u`_EDZ@M8>;!@1~|qJDs<2Z(fmj~KR!ol}`45rnb%o{yri zH+6N{{VJbMyqHad7~sr&nUpjkUtLo(`cNR_Qsry*cqL}7Y(qW08VC~>o&dahvQ-_5 zZG%25etMR%)sv;#J$3inmx=QHEKW9th$zhU)Zly8Xp|MpAeqN8?|zU{?k^Uti-OEb zaX+-GOfrT9tLql}DZ8tz_a_BB^a@Q2oW~1y=6+QghdUiVHa>UM)MTgHf_{9k_rA8~ zLGR#bMIO0}%gAkwDpXPU>9yD}_NnV08-a-ZpMxp3MEq=FyZV>)3c+<7*DL;HM+r zj$(azntzrfTm&{X1bx7gBjex6r4bNQHd=XTz|3gnz!2Np+D5s_17o@ixXR-Hwqpn74SWMi*MITD(C zMhwTXCoXV`g`K=+AD{tO0PxE7s>p@j+-MFhUjTy@W1cpTFHNEH^YuSIk@fZUoeW!Y zJ%nJ$$IlNTNz!9u>Z+C$U)0FR2zYcsk?Dj!Yb<;KxF>AWpO1SQf6hp_{Y4qcLOvkq zRvz?i@4cs!VOM0E+p*VmQN$cL|1V!oZIBt#BKE5Es+Wpe_2=Q~o<=|Rr2|!)7O>&B z>&a(8h(d+QH3031zV-Cnqv@KyzLXFbuYG0nZ@8g4A+|6-Ufqg`uEPJ`GFXk1Z zQoBMI@0zS2`lw(&vXuEWKX>=r%bT@j>K`>LJj?ni@}?(v;zJ3mbG~fpRPTRlm!x2| zwcUuBoXHjvqPUKD%f7(b>BE3bD$py?ZLN`GwTO9#tdV0!t%b8>MECg}Kk;zx`iv3P zBXjMvP`CP2TRVLhv)$?Aso6q=wH*TGz_fhrkH2eH>o;!?KLWt?`&$se{35+?y7K_H z0fpN0iO!{=79fbl@))g1{Al?_NF#C7B~RmFNQ}zLG9N59^C!ICUGS*j3f7x}J<)it zNkw)^xj`tx91Nf#Oy9OR{iFVfnsx~g<_F`3#ChceA#jx*I#2shn)Nqqz6OwV$N)g` z*O9{}dX%Hc^>50mT-$Icp|F;|X5S?$p<6xaav@H3Zd`hE#?0$OLqqBEVM4oXk2z5{ z(d7m$?Ay#mh@-^$X$Nu14k3bh+vw?6?P-m9PqXGr5FRFv^t76JrrJfa!fI>qipmm< zM(9<}sytPc&mtoFK0fUc`A}5L&Ub~1gxGOiw`O`@xm%|wW;^=fg;Px2+%)1gp2uQE z++u;&hLt6+_HvBh`B?f1&ddtq$uacC(Q~2=7_67MX0J&1if|GYUfscQ=VnMMC_b`^ ztT-OnXxK8s!TI#7+N|;plWsx3S!)cQjQ(fy5}%D`mP=XVwjBU8>i1_7$_pcm z5^sArEf1BA`SELN9{&#Fx!oim!T_$QLqqfUXtOgYFtE1S`(SIHjN{RVQ{<0LPiTH( zZKtN2r+zPObZlcYJ~1&7p0Mxv9x{!+J(}6;x^(7T|HMjaBp7pkWa2{^*Ah`Fy@k99kN8_uE&O*KbQS6J*>^m|q^1n5yWH8z1d)-9GoA`^{#SYLEAKqo&QBTCl)MuOT9&m=WvL?rXJ$@LcG0_QH$qS-y$XAutuA>%>U$9xBF;0ndh#9>SUd4`*4EzEnp=-WA7%R2 z)orwJl$BjdL1`EC?{ChE^`Q5@@r(>)FAtVvbJSV&WHpK%E#wuA_{(k-p1G(@mPNFC zsQbNkEBy0WlKU(jtzsMA91jf~bocKj^fn@=G$rq83XKOYaj!YKTh86|K|I*|>8qUQ z8_s_5_~d;?E^b21K{@`1ld08>jN9YpZf{jI-<5mDOO6nQPn z;bL_7F0@B0eNcqCnuWo?1uZVMz5#0wY5b= zTuup$ijF>Bd)c)rTj4N%!y62`gJr)yW~kbQgQyX=!8$V25xL)LL`W&{J+7m(6EoQ9 zy8{k*W@ZL*crl){g#MF|6AhQ!B|sJoxk(`k#o1a^x1$Sbi0WBNNnh{PvAU#rmK1NN zedsde)_;2Ai*;F{5&FapeVDY_^ZDi3Flv$Fs^fRP$q!f0SPIU z$L5SkdIInVfEz=+-p5oBmsbe{~Z^_*e=aoqerTfmH zMT*x-6yYIO31FOJeS1(vODcWjYb3Ip6q?PiDACB6~Irt5%z62;sK ziRrnx?0|ckYz*-N*HtGeb%&-~Duh)3yVbYQ=LSc56p|4=0;9;p@<9GaZ?7$`F6{Xu z3Ux*2y#%xf_8)w#*nH`SBT^!x_&|!l@%=sh$Zn@{S9+T6-kIxxQNFs@hfAgtmt}?p z@2iLb&m6yeG#uwW72GK2{^6byuRV62ndJ4Pddf)ypQGgtc-*kA^-Ogni>jxjQD)6# zQy3AA#K`LQdMqhRX>DT$>wPJy&(CmA9~sght?Vh7J2y5qG&Hx@w#t0)Y~XI?LaUtb z5)+rkTX5Ebgeas~{cgr7L5{cvJ9jKyo~Tmbjv5nh8jzIhs)gM?-62B)-gQda38)<3j$uI!x0zihXt*tfE$jZq{ zfVjbJ#8F;dUETQt&8bGZq=H`U4cllVj^7Ttc`OurwE_dm?1C!_WiM%uDPNX&Xbc>R zvP^_6EJgHva+txNrcrc@Kji8AWn^+wDC}459Uu;QZW%2Oe6lV(s9-*sHdf9QV$XS5 zT`g*NntE|@k&0)`lo~P`m#lpo0jGqs()vKpArur8uuKkM*rfP)PvBL6wA9eh@O$-& ziJ3VoBO{nvWCQk5NXYT3O-IA)OL{e~c9X|S@d8%e84B&!nfUlNGGh&azZotzYdtwW zgh=t_%NH;}VIT-OifX%kK7PW#%BlV9J`tsW<$QN0m5}YvayvaKDJf`SgFp2IwhoxL z{)Seb)A$#)i%pw>VM)w{$$fp1g3tJiAFoNHHs@73KSqVk*-j%!bpWwscZMO?(0Tag)_>W}l7WGN5oUu`?<0x?kaVI1 zEs`-O0>}_Kd#XDDHhI8xpCK+ovJ~Kn9l{pM{OD=ST;`RCUi>Yo@R7bdEj4jSYtl&M z)^^X(OvtgIbz$<5@EBTfokd6e&@wl9s3zT7Xw4_Ie=!?XwvEr^a!<<+FItN7!opoI zOkes-?0v9y>c`$5v?@^bJIoQW*snbMLKK*<;Q?R*2bjqn_y}fyKcc+0AC@WEj)~QI ze&HtPX~|os-;8u$;uMSXL?dB;d@8XoQzyc~{eQ-)|;7a32%e;$T#NXGwSLOOXfY2srVK0cUuwrgz z(~q&GoxDQU?C4&Dvt5~U6kn=?o(A(%5qrwtHY6_EXms(R?*wJ^0%V0- zT*&t+cOJACJJof@3MN_YqrE?NQKgQF%H{a@oJjYMt*7OsW>=(*`aX%sr zu7BjmXZNf#WUU^@(oZxGxnVugLwZ%=Cr!dn^S*V`AX`kdgQ_+*PkHv%z^R!@Lb;E% zb)(d(=i-ffq|!=BqM9*2?v+EpC0|-6OZwK>Xz2KjOoEI;*k0eC5I=d*kbtBX5R8_A zi>9e*#_m3poS0Pvj#hbS{YYrf(E1Ul-IY1+iS%o(v3bEMk{qA!x-s$keI2XT_PTHF zi+JkUJ@h#{T^i&^%*y>$78ZoQJ(;ny zy7W$XY`9JB>Y?+)i**mik+)W;a}B}AwY7XVohFyV^l{1j5R$IjUV4m12Ja4D-!;tg zm^Kh+BRyc>GWrwCF6hKyL&xgmc|bp^DneWF%qnl*ycsZXONxzy@!P|P3_QKFSQi;} z#lfNBU}sqjoU%|sn9&&sX*vkRmR-f@jG4ZE%(R|Eogj1L9M9^h)V3{19V*^ znXT&K!$?_ooTG)SUn)axii%cmt8yb2(Y;dVPzn(}fqLIr&NU@5Tz3oY%~bb&?>>mU zH1`B)6+iU$$t}7|t_2y#g>!U+)AD3Odl$Aq_7Ea$im#-*qYHAAD3V3Eh4zW6gf*s z?Y>x0QC@$2q2WWw%@4jvm!+Q{fu7UR(E%3wZaKtxnDxHHd;IaHLJ2;U#e<9q?dByO z-ZR8YUm6*k6jl2KtHbF>fF zAY;T?-Q!`!GZOQr<%Z=j{plXYyk(y5H2p|=PG*rB4FR$q&6*|=Ut}h@GVzo`{C*-0 zI=_H0jUok-GS~gRD&_{Oh43>?G%OriJ6*XFzG*^X63GFOFno- zyzoJ$Y0WH1<1D%kg*?;ghvOg^~uIsBOX$I7Wy=JTFVh9^3; z(oI52%KzjmK|;GLWUnwb;Gz@Zf4@Gz+|CU{GqSK`XJkP75gi*Ve4-IEt;kb{0J1ts z6z|jRtK7_yRsgYJm4GW%eti|< z)9#M1?o9@Djs2$|fy%t1AI{wya4#sHI6ZWsFnD?X_zDuAQFAC`6c1Rjd8N!86a?%* z!|Tz!;?Zvv#yTh#CD+&23($_hN!t4O^n~pVhiW|H%AQg550up@Gj$G7d3ztzu`pd1 z6;zGC7U6&jFHy`i*xy*^b1alA`>q~qY37~=0b{7FEG%v)5;71mwpDSqK=zD4T|psz z#wNCOFV@&?;)&;5J*Cf6grsW%!qnA=3P@amGuS3d7|O(ejS2Yp%1p&52yROg2!6@m zTFvjcmKMC6i!?iW4OW8t+>7*8xw%k+p5#_Xw^_wQ2+eQhaF zCk8u$%goFS$`K?aBuSz!ls6yW0o!|jk=iyQmD2dv$q7^)THD%gdu(Lz-=-iX?Ey9i zig_-}LzVs>z<|@AIGvb@ouJgZe?N05UWyf)Qet4P; z=hPKpHA_NVY4^X?Ed;~|c(~F;?jnGO{&Nw~fz|!@U;fX6{{PEI%cQhNaO}TDdwiMi zv1jKXKvp3j8ncr!&T%Eszc%VUQ!f{!ih{o6H?o8tOv|x6Hje{$%DMk!@~oz15#$C%9DddC^jXg+z@kJ zdc%@@jnDKu*d(K$cDe1~#&qj-LVx1lzyn5`?N9jA)E_M5iEA{Pm)Jq_59#UP;NaDu zX`rv_cZO_us-`aQcKJi~kw|>&2NZO-zcYj_!M^EHl@x~xAxbkZ(ZgHxpW6DTU%(c& zwYU2mtv4+$EF@w^{-9_t`>N>}(2H0+?i__3NP|3D2Y!84a&vPN1I2P4qxz#Fz z0$X{q--#?Vtkd0`|9g#a8fQCkj9px|F~{SW!Q~wae)AFE!(}M^0N)8O>;*v~z71{O z9uMWhS|FY7zPM_6#V7GZum@w2!lVI zOyz?@5c+U*6(T8%ay+6_d>66wQ%x-j5~~3tq&Nr1Vw^>CoFuaT@YWYF$JXMbsYumO zO2HNnOC!~PDtw#0eNcaX0)@iDPZ>qoe@?3YGkMoi#!va7P=$IGDg^6j%pv-c1SF9W zhuY$?Hbr&iCQYF$dYo5V0kSar-BVV!Gc)TiG~n>7gpvytwJ2248-ppN1EA_&=^&iY z4qn2({eM=a0>>B%{VpzL#Pm?m`7F;I*<6+N_%uWTyIO2cE*${1f_{xG=vB0S@N`BhabS2BdeEhU#LJ)Cqx(uca^KkR2H^H zRV{#%g(dt3k0Ph92`2t^=s5OeAA9>=Ag+Ero{JjV>w!$RkqXG{AMPe*W|_qFA|h3S z(T=i*@%c&$3U7$%+1Q#JZV3FJf8F@?>n&0;GO|k&sDc8Q#wp70-@jYhn?#m`c^1Jt zb$kWRlM-3&wQEoJ^&7d-k}(mb%D21V-wINdO2$W^&TG&1;yWi9JiOG8vZ@Q7WN`R1HS7)M%cCt zEVB>nDT~s($3h8td3k8q>2Pm_h+g+X6-`adn!n`j^{=Ep2U{fYlM>O#+eOF%6pB*F z_MNk$&CvqLOh8T{Y(Mfd^{&58i8+WS@=&_k+Iv8z$`bIxZiOz6{awT}q02Nh<71jQ zwPav02T5PQePjENeoJt`)8+>f_t|dc8);|LK_VVk*ggl-@4x<6+IZA0M9;urz)}1v zi&)a9^Ju>h%Vosq+3Qp}O~1ecbJYGdBw^73se6A^RMfqQsbnP7M;8Z*kf7)q72cWa zn#|2k-q`+u73a9R?m*pqwQBapYz%O0KwEB1#~K;ddjomdI?7K(CA5_4e`Dit9>Af5O&R?pR=TX3UXqd@0%|x^d5(E!?yW9kgYZem%2SWj5loI|{i8 zltA)8?qH;)B@Cnzc$Q#FLF?uSrZly$Vi0^kzrcftvky@SXoQ4_2tLSB*&b$D5(I-N z;OQ;O+I0c@kxC}T81MCOoGyAV!IsIdi!Kz`oe0D;^ zAOm1hz!nCGgR3tT|MP`A_tqQqcesIV+<;n{klj%4V=qt7YT_plG@g^G{QIjoe#p~) zSRR)6NmrUI$le@pQA-CvZ3ZM(l9>HY=>VV*d%~K!rNWc`F%#M&v2QsjR8j55eQRs$ z$uOW3ov}V}@y-Fkzs7;%H$FhCp(zL|ljDPJAb|lHLM8+*pdJDfRypT>W%SJ8zxJvx z;lF-I_$D8?!GF{hpMDYi>q9!f|IxMn-;e12Yz^!`h^7C3HXC0@#>jAfyoisd)YJr! z_)H7ZEM?}09B%4*dU}1t3os7mw9baLhge6RGGm@Z~*Bjhq{Z%>ZTnD%jiG1MmC?r@r0`x$tA9 z8X))qP0rsBDH$9&z%lmmIevA4{79YV+WG$~P2U6rSo%EsP+r~-q-~}IF1wIW434og z{3zGo%kX~y*0i20>8&wbdiCB_{60XPVpWv~)WXDepd$h$nREbngxf&+7cS>%6&V(r zz9D`BW(9=5s+ZO`w9f$v~{K zZ6H{cRF?iX?0g@01NTdQem+d`E*G}eJ5f1Q`NA26Dt9yZd%!r7Jtl!&;5CXr$MofC zg1<`=PT@a-)Khv1VYK-L1*7#oHT>eXKW|!qvu?01E8kB2`*An|KV}#R zc2~z&mA*DLVP%n6Uy_6w0b&aze8UHGOiWBbv5%sWNC;dg6nF)ITU8LeUK3ttZ}lYz zMWH;wTY-m%oFB->jD3EnRjnK^06!eyg5mDjcHy?j`22l_^E;}b@{x+HA z9Spe5k0p}a?(ivO+PV{=(+={)h1sfm|4L#>Gp`0)v4)bcIymx-shDvow4 z&@T8SM(`os-PgB-ArStxu;WKF!r)@tfmAQH=)5s2U-&`=1ftkl1K^6o#utCC_5|V9 z;@fU3BY=v%_V6jS>pZt3m=yhp`7JtXXOq1sE!GZ6{+?lC?;+Q~F5om06R9mcLGmk& z%?i5qJ0X9Lj*4<;1-og0QBen`3B<~4YBnJS_AFuBVS)2AFRjG`Rvw8F-2`L1gn5WD3~j;p0;$P%|({ii_Lt*K>eJ=AsS&HcYn* zk4G^oG|+)@08)jMI9KZr0pMH%iQ-UL8Y>hJ+_C`lNjQwv0!!=f?|*>~q7F!gVJYHc zVwnA~QfLr!QG$xQWwQ?x@b``;cwl!6D?kRNiAWE?5?U{Tl;bW zLZP#{gwxc$#ZIk%Z`C_x#?RKizQJP0j#3U*R`^lM>czx2!22H^d826t%uA|V#h^CR zNCD%tubjXS+!3_?uNJ_f`oo70pR)@eq&P#~?0d4AvJ3lY-E~01p?W?GK5L|4REQU{ zi|1dCb@(3(xl=ZAB^@@h8*?FmM{u(PuDkC?L09T~yl8Z|TUnTsL7Y~#eF0k=fdWZI z^x0|%-U%ZT|B;1q>*?t&D)kNwR1{M#JIVY#_h)j-DW$ZHj4-ggw|WXEJXJu!s-^Gv zioec&RMfv=V*D=PJ}plGxhJDlieWdz1&XG$3=Edzy)|hLXh{CPQDg5T1_Dq3C#LU0 z9)*92viDaHW8FG3e)d+P2>=WQW_#? zlH-~mpx4v{z^kaJ2nz&{xnp0_ZRzLwS_2X6 z^}~;+BAJ!hSy{EKoHA_`p;^J)Jd2o~p8ofcO;M7Noi0GiWB0H4P{_kpgdtUGq)~_v zu9zhOzQC$1z%ItOyaeoNQ4!#!aPtCPEo)7nE@IiFYqN0rW7F~ z8^@{-A*z6b1mJwM9)?`%&J21z>G{aO;P}&NBJF}lvyx^yHy|5Wn`k)PUW|SB?ix8c zi$ZrG8OPDJ!k+)J#1kUO=c^&99gwVxTXX{Z8#?8;qSn|5ne!&3!H+aGuSWD;+_@`~Ll72%JW~`-9YKsueRWY_B`t8@ScDAQA3x>mQYYa9oW9$#RB~iD zXQ*@^fC94%i5GD}aHH_(*CN`|@3~(BE1IC?ghSgQZHuflHk5W5jc|qV0MHz?znU5v zKbLnvmjTNTlpZWqUbDydQ!h{`JUl$$JH(-30U}qkX&_LCJ=vW=|4vR$4%fI24ce9i z%2--jg8A(0?S&2>1auD|F(>2(h_^_s;BFll%yE*4Gc!Mb33#RAVs!%Gl(F0)*bfUc zbGchz>BB2bEG#TQmOy?3Y{taI1Y|=`%lAQA3zPXN=i~50#rr!^G0FL;;{JiA`1aBC z^d`izs01Bd%LsFct~3<}!wXr@loBHoQytI{H@I_w#sT+L#1FOdKa6u2HU(SdPf*IL z2a)^o@NQwvis$}x?C$dLZ2mGd6s$Hao-{|Y7|dfijw)NwKR@@9IU_u8T4mLBWB)Kx za_}9}kouO{!Cts1%Kx~Y*LpQ{SyQ|nRI)R>yKjD<-wjcB&Hee-!qXwlq>;GEfI-&u z{iV8sl7!gI<~(0wp^Ay#Z1#*`{*42ZG`;-eF}0SjzROx@9LM^`qtCwk3AU+6yRzqV zKj>3Sa3PaZvk>Lu!gAM>dqc^3T$zP*p^^PQ{InbCBsFi+R_mj4ueQp{X;KmRQ_x&*_Bk7=892%@jM}5y&ns zb^=!caMB6+wyv~PKvEJ3(pjJ$d$N>aU#K2xK1c^_1AQpfpI^2Bjxn{ca2P7J`uV{a zV9KS+A1~5aq3{Ao((9#vAVhFc(0ZDhPWhc91bdZKk^Rx3hF-s z?h+PugCzrn8}>0kxO!=-mVoxj0n~S(xLKhBB?E{}Arr?o{k$P%dHo7+Bd^)6GZkgC zv5E0Ha<6KisTVJ)^?sa8Sigz+VfK3^wng7%SlrE%}g_TN?&QALXl&!M)O=btm)ctjr zEVB9MeFQFYm`sOu-EV`)xEF;`dANda#vp($8Qz%X0)c|*kD)nqyQ4O-DZ{)ph{A!=*|Lt3%*&u+*ZfVu1E4mFdKHsfu%2p8{>lp6$%mH z`a9a%dP^P^WU#h?LlNMhy?~B~QD#?bCCp*BB+wfw59Z7rxXbNO=yJ7n2y$J~)pzjJWp5WNY z0Q$tG|4&*4+s}HX5^9QtrO0t-N3NhbW3o?w$yf1LNJ#RhUdqmp3kAg=T;1p!;uh@4XnJgwF1yk18Y zCS$)!IihZwDLmwksOE$V!wA(KsFq6&BkSrCPB8SwtV5P$gG zpxU5*Wg7VYZQ>JbUj%4MRrQHjE^%WQ3|d%VRqSn;k0#Xt^npHYdTMI!VP3x;+85j@ za{NhoLEVM1lNWC(^l^!|9TUOQ#D<>vK3Scp0mM#hXEO&gulYBnpPsds%5~hglhM6=Cl}fC=2I3ss?DK8} zdd#Mj-vUK)L)`OLPYUv+>TppAioFPCr~^`>KY|JY3wW7#JwSJIi}a7Q_EQlGr5MBM zm<4SUW0m(izJjsV)&8t<*3_%yYMNUCg`-o7YT0%}-?k+Ei-`HjyXu)dksF$WN*IYh zf{}!VE!FWCXS&BCH*jgL3fqx)PdxF^s#ld&qFEv>3m(Co^Eg(^{S>P}cct$rY&GO$ z?jczg(tCCJF4TXC)zw}t9fLfdKB<7OKxsEOpjPy5qR>GYu}1?h+jxbXrLOIsT#i^z zvA_Y36sE7R@{Lo(4xR;r0FTJyBe@*@OUGJ|M`Y^I!h7~ag%yKbd8kmr*SKOh!8>O~%GK4?-rk<7kL6BuZ$J00qj+VshsU?zZ?+C67m2IG zYU@D*TE{8qJfma*;@X*6%x+Ac5!$!#qmQcOF+x{2ev>Sry1E+70c4>SSy@?-VT8E- z1nLMBL+9wAy zjj3zgAbbNP=9)F`xmd8hI3RXbf1tlVD?3{fOdaySWMX>W^`^`rkoTsctZhW8j>7_I zH6A(){o3T9+7KSZH=0vaX^-uAw6AYn*2N;l=fOLubsC)4!^d2ny*sAk7Bvx}QTGO4 z8#>5=KAc8k*@kU%fdLHvyR~$T6OSyWkK593 zB{O)OVBp|Jhlmyz{7h10==H!6z-t@#b+6PiW`KT%No!rHBs z^#UBNqp2L^4!t3+t2VKG(~kBJe2W^*k=k8jy`OAkp% zOd(+DW;Hpoc1zqvFR`umcQ<`M8+7rw*rJXee$*#(6wkLv@mGY3=Dlq1`h8OA?~Nyv z)8g?cw?^wVSwyXl=Z9SN*C!?lvSiSrbP2D_hw5|S$s$YU9D3+32W4ui7Jcte0wM}D ziS_OnI*z18JQYG^RyEa=P zXc3+S@v;38w(AS4!&c<3%J@9wH@4WD+XHYi;o@7le`|gtZ0;E?&Ld^cP(VLo`HPKn zA$Z%V>$MAHcBu?L_r8&(skE+Iv?HhAGg{eIt0$+X;98w<*qlgjz0Vn{cLg|KOyy?w z{@0Pod??HrOxa6>vT@==l1KR;Ds(l3e+g%;6QQ(1O-t+o$D{eZ}L&wk2r#{ zJw*};j5yd~KuE}S-X}6a{_lW;04z52JK)(Zqx6rz@Cxu;=&U4*tm#De%eZ|R4AzWM zyD3*V2efapS~&pLT9EeI{Gt1Rh^$P4i(reC+*&5q65ZeEfNd-bImyF4;DPyMxGm3s z;IW*OjOln$hB@Dnz&dJWf1`V!vO$9K;?RkrQFMGVWkuF|2^7jZ`C&ygp38%W4<~z2 zfh)i&Ky~;?j|SN_5)*vX)NS;^H>99SPO$2fE&=MpAhqtKH@R&&w_&H;IrOI9vk%MI zjz*Z?DqwQGxh(nOpsXhXZHhuU8m%zT<`6i*HO=q=Jv{-~>Czkny}OKM-4#Eo1IjeH zcT9FgX_Wi5FSf!m`878yjMs{IM>5LZKkv*Fbm3p~z_SC{V7TGsvRxyRtCCvZ!r+Tc z^e@)GaQIO+EBF3&+)d7E69#SJ>O6j~>RI^H@iPQzBf_Qhu{iXUwM+9!8=09O5Gv2W`A*)$>Q0WQ&mC(9c`;&Wy6BI9rorHZYg1Cp_Z~7o>9wCsJLM==TAH+pBeZI-zwA>E>7b;eUL1po%%*q`Vy4Rs+d|S0F(ntT z<{Zb?ueC`A@LfBO<%rxh!nYZ(uhSfOGYRREr}#CVZt`T#R_NI?Bx(!-JI+YQ9y-Dp zw~NF1fGYL#R@o(0cFMa(?$CNO6=rbBwnt92AcJvp<<^VS=Rozm82;XjsnQ_NF27$S zSMc}xRhNup<3u4;WL)HVX#$t1Gj!&&gEnpEcm7Pu&v0e@@_@Wa-rlLeh(jMsnS6a%@5?h%qPM$xw-wJXtnF7OGrZbTo?p& zpY0eu7sbBLK$vx04TewK($W(AuuJa(bjR3yo#cZ2`78Zg6Mle{uZkj zW{gU?gs6ycI=mA}^W7@$94f&BkBd(xF4uz9meEDdfvd5u;hc+mOtY#CrD`1io$o7U zkEahLL&NCea$mT99S(S1Xh{p3kaeXY84Yg;KO*<|a%^Xo_vK+X}sUJp6SH;ea3 zc+f~Qi?d&D{Mp!8eQZ31&7YQ0G<1Db+VAgAJzOhqD;31h=V^1{Z0O35JZi~>5(DmI z#okl%+m0=L*;=MNBVxB*vZpvuU$4Ww`xhrSpWeH;B8I+GY=?zruNBzmONX?c9qB3k zLeUrRs>MEUZ7H^2Zf?ml+gXCIii2~W%5MH!++^nVaiL<())YG5$Y}Cnl*mf(#QGZt z^V?EnOv&WbI>UA8J34I@l016T{<%$U>%yFp#2|NNp=^Ko&2rv#ndDr%1P0pWSS$!@ z>nZ5{wDrUU1e!X&g(*Q=!rBp@(GE8Qp#@bALR|tZ$ztK&^WBe(K>dGneJ-H#ZW{$< zGsHjH{q43Bow{AiB5n%Miwn1T&!&R*)aFGYw3uhGU#{NGW$AvVy@o+xtmoof*0WO% z`?hg`U-Hv-+nP`nb#>!UW#=RrpR@%%whd zy03n|q$*hcL8{9Qf3JDZ50TQ%spYS)pFC84EtEUSXEb5iJmKe~x=Sjt&0>0O&XV<^ zoJZu!-3~S-$rt#91eh^gh8(Y?XGk$(wc_B6e(9V(lNEpQ^7MyJ3X}CZ2On>HwbCKA zQJM0%1-Wm>qh!h(maW%wC7yCsfC|M__@LuPT5|Htd66oLudAFNp05Ox#)JWyfqv(V zqt{rjw4|g1MLUJ zfcZ_dWhEWE>+m_#yVlpI2t4SvtP$X-=(>b)e?t+3+*q@#gPC&o?%l9c$Rd$l^Ex`O zcf*@U*Ck7W?jBJ48a}_@q`oRDOOX~fk^D0@Gx5d6#&(RmWgU@zeE9NZqtqdHimhAu z7TT)Cr0!84J>{nOdJ9YP8{zm2n|CV0j;|i5l;`l2FIye>M&&rqocW;szR8!a@PJf) zF+Hm6wh4y#>Z42d=uoJ!DRYVTj`vNepnP^5R?}_? zkw?AEpK31gDoNkxxN=IatV+OZDr0@r3v$n|BxQeAuzEilrbAl%=E1Epkktu>JBYQE z6kj+A;nv)k69D{*U|u10nV>!h?vZ(k&psM`aRy37ko+J|4*krDPDo9tfLvL@igO7b zQISs(coC4npt%jrc$ZW8lZJ8igK?6ZFJG?d|FO-M>>8BoxBvRR#_Pg#cPwdl?V?|PFxCBN z$}_Kz(L~z2B2`36ebd{EmeuM`d$X73*rWrCgv}R7n}#Fa$$4EkYx|~Vn4M<1JGX!F zd?J@zQ9ui0z(bPF`!w70s;MJU3d5w(g`tz49;{ZJ8Z25q|6Z_I;4F7! zJ%u)%+u@yc?)hVn^G8oPOl;uW7M-kq?O~ZoEwaOhkHR7+hZK|5acAGKfvyvSn zcwIm`MQ(mS4YuR7tgJslNVxj>2|<}t-d!czkO>VRT8VLr{cs;jMyKU>H*qSGGg1eA zsvmuQW1R(g)ncqHESKNkS*xM%lEoZH(g{h~u{boT0i7fg_gkZ1kF{vp!C4F~82ocn zWC$N7?L86`kzcmv+FlEvF#+on!ioEo4E@dLV%MuQCCw_8s5yB+ka}E(Cs0kUOMiE;MX~vgFUndkrK-&5!p`t3?W}*N zqI`iPQ2pAm%h5Oz}|ntfzg|Sw5o`$CjwXVE{R(NJnow$;-jJBsmD4)81FIWJgSS*2GQKl1WAhhLYMpD7c$!DzepGIkl*$4Un#M|#sx z^P`|WF{K1R1xyduP{Gs~^u-`0gld6{i;IVc=g^@Nc8gA>o$lL1sW%9=w?2GW-`4i* z$&=fDeq+(m&To46OMG2V<9?@gVRSv`cd;U|(;L43XxWso=DRPwRMfH6i2YJYhAnH? zZ}cgur7uz(3%mj%&wpkFP={{XT+H^1yzdVUX3ZLInS1{-z&pVDFDm_h$^Y#~+CShb z0007=)=kcge+hWschIu4`|K< zv>b4rBAU*NLoYq3Ngy0tym&FZ23#SM9!Sh6ztLk2x9I0zPaVV$!ngz_7_UnA@P+(&?VMltw=oehDq7={c)R{~9@Q>UEDpB@2M19WcJY_xV@YX(OuM(i2GXi+@E{?ejq<+eP33kp;}On>Wx{1Bgph^%}{Sx)-;@d4&YmLh#ZV zNoermRrUNN*z?VkYWyjElbjEM=dIY8etG$ab@5)6xcth>r>w8uyqPKw#ep$n1$%o6g^5*Y%&jAMmA{6=^JFvqbV%Gy! zyu7?j0yPEXd5tUWBeJ?IQfg{V=nhzBw4&j6tBlbObg`sFN3`D2gj)csKuL7A5dwrk zVc(ySQ$VMXUdB}M<;!MDN?OUU*WYe}A>)l~qU#TM0Gkb88W_3#%4|#6s*=MY*FRt9 z*O9aiH~3pY!i#}+u*oRn1J4E)R)9&3B_)edZU`vc5`puaE6b-}lf9IwYF&8Zj6)wm zqd6-hbKd`I`X!{;<9LiLpYUA5zUnCrZ(#{S&m*JtO-2UcPUr5v{_HIFIkeG7I5>uJ z`Dmipa;KTK!AMc~>SyY+w=YnnUvE7QUOxyLk-$+PKk#Su@bpv)<^N*a+SL4r`OhW0 zmS5W}$?^%(PIqde53=ll(9ZG+T6h`ny>W-jK}ps0VJb ze8O_n8K{EMlIG@C!^xFHTe16&GXAp_Nf1;MV}yWidnm~Dc>Dh6Z~PIJ1jW=W+UyTUVa5C4QT z?2?fEyCmhG9liV?`t%TxSY_opFc&pMC+?wt7kT;c2YQ-pWJ&>E4) zT~Uf#cK&%Vt{=kYaREBEO&FsJjm183R~?Ev&??KOtF_mG@uEyohkmSnlXceBFElKl z@*Sp$3=UZy5{lm@i^wE1w1;Io5P-_o4HVGmy1-RH(hClW{FB?UrF=GlJ7KGbO>X|> zc+_~QKKNb(AwjV$e{O<-pq?W2Tgxu2r5ot(Bdkh#0gw=msQL0`hh#E36G$*2CW?xR zDopGv_lpFy8ZZ(N8jqlr+{!&0n_6PHN5uX$wIL|^wt5I_kTE@?HUv#TA>XaZd<^|O zYC~w_Z1on=atx(BowJjK>W8`q^>6C6hvuPg;l>r@{P#qXFR74weEv5J3Z;uHP8Ud; zY~|1IB!B6|Aw#B-e9RzJ?U|*69_96p+*uevI3hgu^!Cz9A31#ZSDE-K6rnd*+^dYQ z-CV0wGqQzcb)H1FgD46e76|6Tz%0WR#G9ednd^WFf zep6)nk^ZtFbRjsO^2Xk_KQ95VV!l7jvJ)~V1WGV1oWNOzGo%hVrXx|C90UB9*=aO= zAd=(?ZwTA?p4qW>j3{-O6;Jm;FsMOX#nVI7g(nzAa;gQ(lVI~YHwTr49Bp(=bHChA;WlYur0dw+3VLw7mfJu z>u#c^|5_7s45h4(z(02v^YcG}uZwiqXH#|9Kf))h?ZH98c_sELE-{hFR)iz*7Yj>W zWI|2_l3G%e@Xhb*hyF ztiAxFlgPWVCn*YigPc*o(j)YTVYz`et0ELK^qbi)k+ zCVzI?1SB7>3NIss!^HLCzyipjEK=yPf8PEl^>`shvxR*FMk4n`rp@SYL@@|qyOFv zxKjTA=x!vXty@$381;@qgEQlX+5?a#g2~5JD2wu`?No!A6 z9P^UAg8JFeFxt~nMORlB4ckC_1CIa3x19itpmOaFL%ole{UIiC#3j7?Mh?qAT+d=~ z8`{gl?Q2bsq-~R-mF`|c`;$9uIS7vWzXPV4S*XmvqGhi`|0Rm3MDr&T18QoswiNv( zG%^3D$KcnjYuIRSi^Te3i8=PhYn@YlYmqM|k1V5^Nx2=03x)QFdP zy*{U$8UJdgz6+!T61oW>+C$*M42zJ!j6{uY(UvLuThxd;=+wc3F@c7)3Jv_y%IlLF z{%q*|-!sc;Vg$p+z`#z4WK6z-0~N}U8h9u-?mhPB!q+6!Eb&nYC}_VnfJ@Ka-WrgK zVfb?dXc|AiD#5+iRaMQz!J{O$fQjWSpYV}kPoMJN9=()yHoBcCrYfIxj~xE{h7;4P z+lt}z$0%Z7JNXNyKcGF|0O1dS({HA{h=|C+gZQH3grx%T_kAV1!7ou)@1H&Q_e~nR zSN6Dr)1TV=0S9dxNGqiep`Q?eUX}1#+edPRLpJoWE;}uetm5#1{v|vzQeRK66SW!K zk_qhgz=7xoT8W8-tN@I098_^&liV4P)g+s;2)_|$lLXkpkh|15F#vT5-??GPT z{mu7>orLRBdlxRDW?>lo04gStU+@g?oh)N!#s#=d6hSfqeJq(1+GLk0L}*YNwa{PX zsvzXTO0H7@i?pc6((NY=SqS8m5S?t)ckFq<4)8$i*Y|ZT+T5T)_rZ#-zaOX*VLFZ` zl>+In828Qx9CoJILeo~reKY#PvzbJ!dI)(g@2kYb!*Iz5O{i#fHcdCfE!8LjrxcVt zEhIBERZvQ)hL0MA=UV1ZmZG2=DT*}0IlcT=cC7BNj zlc?j&=gLYOb!D|#XkKkZZnUI!q`A%}p)P=={C6G%Me+)kH*ELLQcS|Q3^_H+@#FO! zDNd;7I5|17sgP_T@A~atjBg1bKeRU-iADz49HquWe|F0nvF^S-?vNB!mjP(F8*io% ztRyF=JJ1-H^ZJiTs<-Sl*4-76~r46TUmG z}7hQna#$kpn$PIf(3V6}Se-z^|}iJovUErkH05hJJnyWqGdzcpepQ za21C;fK^3BOAAOzrQR!QtJA z9Na5_AwcWvdc@u9;logf#FR*j4e>8&Q6gqP&8(jJ9|>w@jb^N1cikR?=w zi1pfPYC?jqBJit1bN(YwC8)&zO5lE`s>|Zr5i%TzYoPs^#F)x1xS!!aSkW3U<)x)d zew&FoO?2RY;lyA%ngW?e*sm9jjjCnq@;dZDBycQ~P-wUfUckaYq$QjkM;-{bnBB95 zp1Gsu?_$o6%c61L?$1j2S|Oki2_5_(@MVjT-Gk!->g$KBuVASgr(Y`dVQl{%#>&Pf zIJqNr#2Hi8P42ypjs1b46E|*ZfoX(RDcFICp0}I=Ru8hWO8raz_g+IwNMeu!ffn%> z;e?o+st>N8r#G59lUSXC>}t zPPO%-+p)IJ4jA@Djo6C7VvcJT(V34=uxvUVL#JXDclfYhxq0T`g*qN}xyRQFeHJ#D zALQS?5-h&CcJ-0Wpwd5?JN9y4SV~*ND7A;{@NLFDR$ANgwr87#-k$6!!41jlV4|fB zgg>EU`+l<8#f!J_*Hcs0k4k757}_DSdjIUo1wB2YghGLh@&n-d#sT|Z;ioO!H(pfB z0!WgXsp=mKEe2f02aY%vVH4WoJrJgAYimKy#OwVpZieKX+(oQ9P^fLrom)ysh-EXm z_krCxV80CoC8Z05H1O9z56*)cMj)m~B`yJX`xJI!=iMIh3Rk=(c75|?CGru{R6r#E zC1n^u`mMXGAZ*q8X6Dq%iOhtAg^3J5mSMF$<;kvEFXPo$1bEc~gu-7tJDzT{lStqZ zCuhh#-;}u^xQ1E$xnj&wFSee zO9Tf3Le+M!YvbTsN?2)MJSwR4xhp(4*oA$9gF1^xsK?v)!hNH}j775}b%k52)@3F3 zJ#w2G{#c)G*zz*VXLI?d8^b@C4m9#oNr#%dx6CVV`gQFf|E1bl{VT$+_=buq4-M{R zaZ7kX2inAP2TV~4^QW($ri|X!|J62Gc>fsHy;ZrO%!nhS!dj_NFF;m+N(s`w5mHGpvnl%)uB)~P!V6U|E~7e;^(0`PXB54#Z>Kh{-50(64U zwN_weWo1R!H$m2doLOvJjecX|u*pj$l52luUA;=nz^Rf?%b6}qM;L|O%kSP?Yh>`V zUok&3a|U`%JCRku7dzm-aGmy131KqUS3wMpj}+@L2tnQkO;~AXR6@e%$^D|D9e$c@X9&#EP-n7AXXIz8>Ah2GRC>{F-bY#1XX(Qk;FM^mJEl)pEJs() zU6T?(MJw>LZQ*!D(cI@qrVHcDsXm{N3m>of9HykT`-cDdq>;iGo6Wj5j1+EdD_b&W zX6CNH;<6(=verG{Fmjf;UD{h^fc8u3LkpAS_UZ%;lfzWrQ)w6J{ZpE!$}E3;3!u!u z9alfqD&a7DdDds2k+D8G_nx+}>aMBcjo{(m4K6IYzM>uR)-ZBv&@{{B;bi&{-3#1i zi8hi;&RM-axHfZ!#lE>STy!sU$Ht%g%@;b`*h_zD{D*Twe9DxJL&JlOdeZ|EPd`BS zpdvsXE7}%sriH8%*oSV*x?2}^^?`Z-PlTc76e2lZ9@*s#{}`lBRQpbVqK!RBOkTln z6H3DS+a}ZYoWs$n3u_77@{uANpaRIg2L1!K6G=*0y!H!0NjKcCsltcl>mDX6yv7XQg)2 z&$?F`^3vY3^ZMFBeGIz;2Y%2lB^PP8zkk3@SMT?TWm+uIM~9z_Lp)}4MWm26IY@*2 zDToun!?u%S$e*mEu5vx2lWg{}^#l3IXky=(MYD#W;H;C-o|6+cpU*@JJ!7=-=Xt8l zw3t6+Arwpmz!>sW*=o{is~X*E3#pD`C4GmWsF$$gmzup%A3&1I)iEm|H{8!~BX|3{ z9d_IBEA`;}Mcymus(?Y4qF0dXYFOIT{n1l*V&7F)+--Q_D!R2wu)|Xt867cqq|VXU zlZsgn1Ca3dFF7?*xwJA9@aciXr)RW}5uiSB^L8nxW`4pHiD=@$dd@H1p$8`+;qpiP zJ49|M#!XR{+?VdNE9B1~o6fQsJ}}`P`Az62YypC&uz}6M< zW;IaP^ChOuCzGs0Gd*9aByg; z{DP!lDkeY2&nGx=<@WseUNwuivAPWr?FCEc%q|As`ADy)*JX^t#b*<4ur&Bl8GROl zVbiX6fk;yZ0qs2j(U}`BY#5pr@(_fJdhhbdYF@bTJ$E=eiD*eym2O;5>#($^qAlje z)2^F3ouiv>*>G7}1a7Ga;~n54o2OpoTPaa^t2M%TRVMRqIWaW zFJD;wDP1vD04h}WXU@4Vn?ff@;an4A{)%r0rJnJc6)%>vDL3_8$=F-6I91BrF;@CC z_TKyJ?i&NLH<*%V=L8pdNkc_5+oG+NGPO?VU1_SL(4ELPRSdif4j91*L_EzD|GrWA z{?@On*Dl|vc$6_>T1zBusJnuK#nJXhcTkI$$r2t0w+fn+Gf*FQ6iDsEqY1MsHZjuw^l7DOJA*rG zzX^XPN0p1)JzHM18`O>~|K$FHci_L0V{(jF#ic6PK1*V%hNw>xic9hkTn+gsAFZ`wGew;yrYtoV(6 zUS8l}k@KMoOT40P33hg&1v?_R16%f4rJcv8J!rM;G0@!e3z+aQJ5Q6-R}K zmjIPF?yfvy6&MxLaom+@+fMt%#x2q`;#T97N`^kw^46Z2jc*i|jQzGnG}AjYq7 zi}TCyYv|zvB3+As3u$XAu}B#i9a`uW7DQtI^Py9VA|p4qQIvAzwDrBC@N7l_Wg~*= zMg<@eucA=_WnE5w{9+lh{`NIX+&7DiY`O|*HN*1yjX#qh9BjGxT z#w3@v4h|o)hIpblv^w^v#1N|b1!j}(goMKb)-lBRFgktb?!(9rA(tnAz2Xbto3v9h z7IW9iN4tvj8~6hzUfxkZ6n@0d=Uu_RNSmpLUwj>YSQfTe%_-=0McdLipVN1r+L&P( z9A&;swDq8NN@mKdt@_T2ovtz*c20Bm@fcJ?7oUgcD{6%4lPX92>;uMSa+(@(gX=?R zhAySIXW?nE?qA~Fs4SkCRsHRhUWa$>j5yC3>`^`-bq$!pY>NUcMG+>Z6eNWCk%N49287#hnyVr2vbf-6>Deo==vpzA`cKqm z76;LN6F*<Q{EOYh{xQ#^W&8Wo>R zZrr;E;6Ru#Wrxcm(}|k~8HeDcmkIxN8~{Sf@g(vpd{Yu{ZBW)g>zub>MrEbL#?X*3 zDWu=MTfOMKl+-6A;Yh>6A$WT(MX??@Ax-yrj(W|y{f^mkyRru3KZ$iWbBnsF*8L!J zxeeW5$mQHCNt!-7eOs+n`c->y-xe14l;%dekM&&?Re5excTaa5+Snt;0ap9gyYF=U zJI9XC?ms^v^M%d=^zkBMAwF|E+*?O_r_Mj`+G(KiO~N`l-X$^er0-CSLnM3tv1x+m?!I`l5L+?w2zUji7H5xsBl zOm3o!MidtvdGpxYI3ANyQoJ8L_)=MkNi4Tug$raa?2>vTVfwe5S1%ua4OpZ^8#t&B zi_HhX4q8?$EG(4jL#Q}_PNR7=jbC{#Yzxz(w@4_76c_E+n1`&gFoEeol+OjQ1GPQu z^-MZ`gTlvRK4e?;93t!~kvC;s&0CX^(3p3yS+|bbe|7Bm#CZu7TRF=)?QFxqS7gnN z4P=XFITjSYp`O(bWfG>opGr%nt&8g1S`zk8){ecx zXZduJSZS3KPrGO4^&=k7YiYip(=V+Tx+O<(c`ELNt>??7t;g=Lr^8qv^QU~;q>W{8 zNJd0yLr1^Ds=&tK+k0NsyRquWbub}Ch@(Zf(}=p!Kf%Zf=sIRksFhQGeE<9Okg2 z^JF=@>veU;x~Dqm{+Yc-bQW7@lIbr`jU89HZkoU)o0zJ5R`N{6>Efd)Z$2GfLyk5Y z%HQA-C8SdQ5kow8CCvy@d^X2O$A-|Q)P_kT`W2S9?Cp9 z#C@iQEO(88Q?l|@bsagNF|TaC}$8pKT@a)aJ^YGMki@3Ub|Au1xA{dD{D>4s45e^nO(&+2`O2-`*q7SgW3qL*fEGf9KjZ(lIaVxLM0v5o&a(N z@vV}vRo;xw)oKLwfsd9cBhnc?QS=Q+UG0T{&W2Cy_XpcDC zFR(E^Jd6Hf%~}ml6SD_5(ng}?$6EIyE(&J_x7+pR_&^zI` z%1IB+0R-4~*RoLY=$;a#yG_r=&CPxA<5`*5fzT}O z7hDBTOXc2bHGbB#nz1%zw@I_B^kdeR#;iK-L-B%J-=Ec zar#~A%ew8U^DDlcOlX<4vEvr& z8_)^imrPtC-8elR4b5@1)0!WZaYCp9WOhV75Ztg--Ht-}qp!2PA3ygC8Bz}fgp(;WSs>@O zo{->BL>-cDW8Tv-b*87yQ|6)Sbm!q>+3>Fl;kEVKwdEeFep{e%mZ8a_@@-VncC-o* z&JD1(@KyKz%Z{(ZU$WagU0guXT~nLyz{G{s z0geGoq1;aFSjbtw6%~V{|T$ZNo`!*y%!g}9q zBvLGrLGb4mAD&LXnRHuW6GQ>3Dor*LwmNi6X`Wjdv(MHaI&h$PI)*W<3RbilJ!Ue_ z?rlO<{&rfb?O{9#l|yOYj=!f;)T3Z=uXT)jNUhoFk{V)|etB*~Z&pOS-o!`|m%kwY zMPX;<+zXxfJf+Z;>|W{E$Zwn9Cwm{|;3zjtxBta9qt9gK0JU@Ts?#Db^VT0S_UV$s zyOZ4#(!?ni zUteG!n57iPa}s1j09lI_U*_R`_o!qFy*eRXt^)@QK?xsg4_jpWr1*ww9=56At!bp{ znB{_%*N^|-5vtOE254vhb@x@<`yJ$-8P1GwzLay4zv_VOL6wVCdhe_Y-zlHHZP=4>@}#o5 zTWS_saMQsZPbVwG_Ah@?ay?FP)iX>27^R-@CA-l{nrdzyZeomW(AaxwL)xrjmiS5a zymE1wOz|feRT6*B*Ka$mY#=4&TJAU4`%_=(Q@r)7%zZasEV~_KKHBqH`c+hhAK2+r z6-j~Z){jNdu#wD^PDMT&lr`DDygkQU-9~8$chMUNX@RHOIBB2 z9?k65%u@294X-MssPuK$%u3glN{|+_mTlyq+~q}~d;`y3D7o*}Uiqk2nP)p*itv|z zml?!o9QeGHRyI^5&tkyPE%4!l7tI5|IsNU%)J@wi&gn;3Z;N(}J6HBFj?;LL)Pm-{ z3$>*55!3TJIyzu2qcaVBaWB`C=g&p38DR5A`m%is3#)87NV%UspCGL|qi}IEBJ>#$ z@W;h1e6TTp`JaZjW=boSXjVo)D`R#sLp-t?44G%><}7?+TjWiPB2OLU zmnk?R4ORIXNLmTsk#kK5HwMJG^YQ&152X*n$F! z%H}(;EHEqoGk#W7v;Z_oP(%ETNFBBbiH`qi&_;msiQ*m_gwj%DBO@4bRZQ!rQ>m$| z6K?=;8j2G1v(aXSb2i$6=t=OKk) zG4(iU`J-oZv+J!@XFcUQ&9Ic%cs4=)Ox>$Lnk?EL%PaW`ILGy&f_O$Ro>Sqbgw&_9 zdK3GlwFS%bYiRFqLiDj=4Y_3h7VpX4^(z&e_#8(@Mi!0!405#RD1GN=u~MaTT8N361iUYK(DKVLsJR_&;Y(Gp~lx_w))RpP&YzvjK}YB1V# zR8;e}f3+5O$de#l0JO%KX4G$Iz+Z%s_0uC4ajElpT`M3^=)x7r$J^VM8;pCfXOL|T z<4BEt`4W^k{Q786F$&KN#`w++H;v$YETHZI1yu(_?V}G%dJ(>)Z@9Ybx?Ip- zzj1(Gp#db`LcGn&(tJ{48&(PqjqMDAINBSJyahJ^%?uD-K)5$CGV;cU6uU$4b-8oJ z`pPn_|-B2(uea_YsXMdAg>pc&SBG)}42k>X@f8vuxXog4s#fdHcbR<086 zg0)d@)RF}E3BBK#JBf+aLRR^)W?DNxsH&@r!IT_})mAKnASq~PLq#R>*5M+dSFbi{WvGp8-J|Gl`%(y+~vVO4X$j@!oOkfT?J3UFdN4Wt$j zjbI%(y7l4s+e1e;U&&W9gWYDHrI&ASg+9*0!UDe1XOz!p&YT%}8u=d%1Hq!j=dBJY zkr1!ViCeg@^#d47E2Adja!N`q{A2hR=wM@TL#L(dbXXB)9QZi6!^FeZ)zuYH2@`2~ z5;RwqPv{tdEt3x^nE1+&63)^2>g>O?pJ#W3CM0tHPs#x^@NOwdZ@G#vrva!oihX}p zSm^PBgMv|T(b0sFytfO=d!Jl|O~n1f^9JjG5HlZcw7*SZm3_m7xh*1a!hm+1@Ycfo zikKg0d~muy<1g<+M~TYD~xU{zB~68U2FSvh}x*a@CTw?h)B0VA*|1a%({HIUVOMZN`0@*RLKptmMq1lTr9{AH*>uQ4t`&&W7te?d!2 zYk(jZ5E@aneMxr&e_eG;@_qZf#Lz0)MvK%IP2B^iEduNqgcip1D%i*X%x>mapnz9y zE9mYxH_#S4eCm{TLnauHjx_&z6dZbT)EI_~%9nx#Qy;Vk;y^8o{lGyi@bK$~78YYL z+4msG@l!`5u-mt?1cC4UdHJO7J8L6E%!kpKZ%e@fDp^cS49xVvTD*2l)<4&7f@k&C z$A3lrqxKdLl>gtU^@`Do6j5evXD}iIQ4dGvW)@uA48i9ITL;n2$3(33V8HfF*MwF2={h{aDjuY7H5VOtQ5E^ATWXz@haZjof<^% zpGRrlp8-XWAfCS3XnAHNb#Ty>r+STZ#XGG$xV!v^e0uE7sK?g4Gm?^n!Ru)Ld)8e_ z(L2|LF%(WlC%TLB>vcDBSh7(p%}<~oUx{FVxf8Gt_NX2J;VWr)tkoAyg7Z)Ttgg*d0I?6DEUV zRZp-jp_>D3si?ZW^Hz;0&X@6x>+CfkYDhb?W*hB}9o-|;YwXuz93lN<+u2Sn4$uq>91eCzHo#A$+QEB1%hk;j~`rm`Ij2=_7W3E z@mbAN_xIH-3>@^QM0t9$uR4jST7d=1#qzu6uj%zHW*aYj;RdPta&< z?}%$wf23vB#G#fc{c2XdaX46CdHt@#s=I2>E8mmw!MKXG(bO~)vJ_`2SX%m@hjcMF zq>iw)j@}Rq>!=!8@bvY~h^(n}VrTB>%bQqn{*RWz=6_M@D*pTF);{=mA}kDT?th>A zn$zt6mh=w(FUuhO-x>3Zo&Odb*c>SJ*}mp9CwgeY!t3Ab$;8wd9L=<1pPiizjep0= zw5ED;emmG`O3%m1udvfAkp1g_%5L|18eLSkEO?RS6s@5(tgYYOky=$k&^sz8Qg z@lE#1m6+(mJP*p`_H5f2<0o}#`ULNtmz><(V}sOdoS=N%v|&S*6+2EmXVO}mWHcDw zk|_SXKfQM;4qL(T*uuh9h=q6_Y+~||+w`5`*Rn^@`i&bwo-FV_1;qJPa%0wMAtAt6Y2K*~a#hlmy(Q3P^Kej}_wmpJw?nS!N? zG)K}4N9|je@W0IQ_gQV65?^a;Un9{&;h=3n^wg%22C-ax-HL3 zWQV8;Nyu^5{qxW7jPB^s*Pvaa2AaSz?KCqOrJBm@9@$Xo7@l|!Zod%HfIhwju+1oW@(hW@aVP+3ZyKK&l7T%@?>zfb^) z!as*MO*@_1D@KQI8PRxo%;sRW%Lv${|} znGpnZ4)R5u>zT#SUC)AJ9AY?IkBQ3Y5gLR^HHq@y zqGiufgFEkZ^!3wfHlP=^g@Pg>DQTx9%t)*uGYe9Hju97KNe&AS7e(_RsUtovZfHZ& zTd*D>R?F8zow|bQZp?IaCj5Kxn}7MI;9!i85?rMI|{! znx#F@edM7vq~ZnN()7irZ!0t)ca0AAq-2-_%@T8?Dr;(#{9{4GMRm)g{Z^iIzYBt% zReVqFF+pBlUQtn9UP9doLm{unkDs!BF*rIi(M5)e26Lyc4_UmOtTlGL{S(T^cTmS5 z2R70SA`}MJ$mRjMI)=m1IzF!d&%(d+Xs0_UfnXZeRX_$qf*=zis=++;^^sd|szm5& z3BC^D?g`r#cWM-ZpyjKdz%Ft91bZD+up!wf-!G8FqCXg1%C}GNlgkCa7#bo~VoOwW zjG{6e@*rw*Ot(Ufd79U+sf$ZNpWNcp4=AD#5)`|vSC<_swt)B3 zvXz)X)i;MgJM4iXhx5dVoYr2&2ocT5FDA!Bwrtr_*-k94hD`%iVJ+9SNhC6g68 z?d=wQFU42PGTM>C&qzv3d#IKE)dn2B<#LaRZ6K%1PPZRsgWJpY;ZDKFFf#kqy|EhapIBZ*0==DF$Ko^OdE^FdROkb(0BGcYh{3;#Wpgip4x zQ~a)tw9p;tuEmI-iiKtf9hdCY$AJ|!02R*O{o(W-S#IOfAzOAMVDC%sk zFi=^%B95!9CKKjiRkbXuzjw{Xc+4M<6dC?96dL-tG#tDWC_If#I@B~YfLD~E<+*}k zW?_ZGmy6dI7=)1d*tg<7GVa@#JkSoALsh^IKp6=&kzokltg`+07Z0xSpvIC8Y7aZk z-d(BSP7RuK{nq~Es~J}{w^LGPwS6r;qy6SNn`y~h#GeqfCOlnto=3JG8yQ}Z_5%<&GcKMaSuX~dh9=w@9?c!^9eppA|SAanR^jrAQ%3H&ZZdgSSZTFqsR-4 z4I)P~Epzik>S&jJB>JMTFGg3U1J6!wc6YgxynxKh!iwuL9x*=ly z!%OV)H~=+>ja$qXAWK0M z*;H6fQh5l%f2_Iz?av9GbAe*RE&rWTEVv*@QWZyIC?8QDZ}1sAq}KRl$U~)B(<)snQ6ux`e%lLFV~K@YO$TTG_ljW5O4(ZSm1>_EwVBg+Z7G1_sFFkoIld zMsIMGURYRopl-7+&aPD-+8xth8 z*(t1M(;AQW21hF)(?T{ZNg=w(XFTv*pK8z-k6#SVIUFs|Y)gISM&iFNYi z0%%uAmyu5|&SB_+))?4D>b$#XX-`fz2a~!2|=($&uvlm6iGjkAfHuvHalmHPc;g(toTTAY-p< zE}_EjP+>QfEB`8b+|ygqtMQ+Y;u)DasnqSeSLjIyOFcs!k>C- z+&O*fMJRRBvg19$*G`)@4(>ZrRQc?wHxHfpGNA5vYD#9QKYb?1_;T>&;8C3P;HS6o-KCAf_-c zS8{bADa3$aL3Da?wx(jYGtx%<1{lBsMhTHL23UOCwr$Uk83j@3KZPVT&cZtO7?8^f@qHH>Y{reowj(b`}615ob5B7+cu9yW!+`lcI~+L z)@&^oVS#|eVM^vvA)ap><>sL&-=jFpDU~b)mWy11A{Du19fBV#S@I=|d|lSxDM`T+ z%SY>4ME>@=`!(J)=!cuhqp+aWnjTN@p;yo)MwThqTn|UB3e=l1i-*nStg^*?Rx5tx@gcUA)l15W-#v8rQRGC3D{R;WdtsqKZZ;tK8w z>()I*!q1$OKTRBA9&=B6_dt@**>>6A?#IG+g{SQc{lC;2zR$DsFj}bT=@kNit*=*U zZ`vi@n(x3j6ygK348l$yDS-Ml%tx?6|0proxlUhM`7()J#Afh`Q}mQDqBRP0zgHeJ zzM23c%a}>di!)~PQcbrpT}A;Fd{Pr~TlF+OU5%DyUuof!Y7MKQAq#3-_o&N#DJiKj zx_Y?k(6er1*hqJ?i!VIT`Y z){V2MH8Dgg-I)FOlf=mx+U3s}ogZlVLFDdp_5EbNit6(Eq2s;UPHh5A8pl>$(lFEN zpmNnV2H*0$>2|(-6S3x{hrZ0!*dNU=C|kYNc{jGfs5CBbEJa6oVBY!7h5YXs&$|N- zPBA%&YkVG(zMs|k)8k&^9&+x-dEuxq*+ARJ`j7P)D@-h-=V*nSH;*y72nT%(zp3Hs zbiX&;*=E^aF?dhcHzP}GB9GDbY+8y0o|jvkvR zMNBgl+*A_2H$RXWu~Gmuwv-nb;*fSjHrz_D;X#n9nEaj863{YaZuP%AS5yQGG_S+QRHZrm^fpWBac3 z@kW+y6BBlWmfYd_KQs@@w{w`o_OZkO^=MhzZ7IR|B5;@g=~q4<#-?psu0qkj|qbSs!@bb3sD>P&xNdt;PlqWJV#{}Po3@B$$*8UIn-aMS@{e2hLOp~#M6iFyj z88gRr@hT_qqL8< zRI#x7T@cgf{9R@B;8Mx6^6_Twy$6GpgjT0EP_yrNTEi!KEYV0QVzY?A5|?a?*xV%_ zr!y;Ey)|LsRnfbx3m<;CMY?6Ul>KI|^=QOTj}IPIIpPLdchB3CDiS;VgvakG4cbc` zca=Lswkf7Y9m3QX!W8270(Nn%mIP5?(!T!m-7SbzZD|wFU$QbZ>|0rmdR_Pnh9|6+ zB(B`1_I8JFZ`R0a!_fat;~w%gwCrWJRlTj-0poOX?D{^VjR(lAml&+#?`#>Zwz;r4t;UZKLwK>1Sbe(Tv5$&HqJyEN=D;$b& zd*D5g-Tz}yht@_lr!~rL#c}dp?6qv(GUR7sws!%YKFxPPT*%P%PaT$k7YgAS2{fdy z^VGUg#X5JEBsNnj*F9KtWoPpz~6=6h%45K26J?-|GT61aP_vfJ4C6^agV;DH( zVR;L11ec17udgp4ImBq5arXE@w@|_=3%3wqVd2h?r)`CuBNn*wXW!mm^xSIgcqB90 z$Czdq^~4NH%!1sO!~`J7@L(_l2S#}KQvWI<2s&~}(E5KO!ziY`{po%=g5%&$E!D1a zAC)LUR>0)eY^D`W9ssUS68;UqV&`h~SDI8R^7k^ETvSP4w}r1bKF0ZDQJTc9Vn;jQ zCZ-2&v%^xxhfe1SNS6dmM2N9SRad?49ZyuAsG3bMtCK7|Tg@NHQ18;6cl&LJbg;3$ z!`DaSBeJ{7#O^isI?gQyL~g0?6OZ}P@=lSu>`Gy1hM#^w^Y5CRRlZlDlOfwb46qja z`}^r%@1Jt-f^)`5=h=pXip>#=&bkNzQi+9{xUHCS@V)UWP-8%7iFN>1q~ zm}y0+j%Sdo zU#MJb4<9n)l#sX$uT9~DyS8rpvV&JHz!no#q+G7|U z5fbdbE@*NwBuW{Zk#y2&CGY97J~kKqN+r4APj-Ezud$7(tgF6bOrn%tY}vntQ9Q=B zBX0H5_5Pf`kn==?5?-0H^4flAY-+rwbhp!$PzC0Vpo2j~3cXt~# zHLP1aN2%_y?h_TA13MH04edyiLvku0N0aUarUd7a8%{Wn-?TZF)o*cXwqa>&KHW+_ zv(vA|fThTi>*8MoN!X5ZME)4CogMo&+v<7qSJi58f-o^tOmk2=qR^0t@4wj}=*!y@7)|K}S@IVR0kiI82D>JM(&(#I72tN-`! zz292Y_QW;rVqo|&;h=M0X^>7S+82*xu`pemjhF8XN0`T4X*H67M@K!ME?U&rE~RQ4bm|e;U5Z(6JT=uGMNOIhLJC$EO#s*#*f) z%Wzc$a#|M3W<2ceT5OVePdVWF?$^%Oom5{x4IK){cih)`Q~LSB@Z1@$CFMicW5w10 z`M5oOpt^Qf8L#zFCZ=9{JCdmOp~||61xPVq|A3M+7~Gc9`ALRAG2Ra45Z;s_8^2${ zV~HLZ)Py3jomQMwM~j_raWVT1HCEi@-yQi+90g1JsS)y+>@j?A)}|cE@76o)sws@M zbieilB9DS@Sg5XqzcwkSH;1&8H(ttW8P8-iT`e_A>z873ab|~K#?1u3(S-X>Q)a)1 zgI1JsRU*vDQcAbprbrai7l-a_t9bbPC)ONKEG1IT^mcY`T-x(jxonf4)=xB!*IfLk zd8-L{;IDsuCk@RJlLj_fZKkcBus6w-mB_Tjo^{=vjH?$ypp4YF=ALAM3I`6JnZJY1 z`yr{{RdUv|;mo6-_zlyolXvKN5o(A1F`robOgM~wU1E2hI`MMbaExASTbA$|8M2@9 zD7{||cY!}cxnJ$;Hae%2i(@1GdzF9f88M-s&OdZoq+a*f8Oj*D@rwa6qMh=k#yZaH z`$)sNceSiGT|D-1D7p5=TwioJk6*Oow8r`09={G8pR@`cPl=oBpL?$uqrwc7{mKW@zo40pKqVJ=9XvI6B8*g;-(J<*}0#qQ|QQr4x} zrbD803+?Up-ka><26%#!y~Vt*W&Nh~kHdqzrWeXb8U0fPSL|ayzrULp!c(?~zu0*x zXW!ekEWe`%Pb|}{Rz65KD=;Z}DqNT-_T{cl*iQQ7I=i`%BeSZmdwyRytXSmuOpG^n z`MdQt_pZXr0qgW%)Ts1vJPd1WwzA1RDXAt)SKQr1g{?*5!(8j#L66lmw;qSISF*-; zJZSduW{$78doSX3?ix{-M*zmOl2iTOEx)h!GZc?U19>jZKWUV z-{`=Ww5j2n;s3}Z*979nl_R1O)L&|Mk9q)FML7%qP|P8IX28i~mB3^4CDLu6GQ*&{ zQ{N4{u5Piz)a28z^=vi%{<}>x#hG{&n&LQQnI**?yO?%Cz1P)vlp-?$)Hc=!Pxg;i zzqWCTu`SAPE!K)YLe9uJ{am0v%C4Y+;;0~LWhUrCuExm00-cyH$-20?+va; zwyIoPsi;gBxo%fx2wR&h6+cC{&`5Rv!u29j=?4X|UN%|rnDBlIYx>slrEQ&a-JVvG zzYajKkYBG)NIsL2tmP!q#(Kw7iuI)Wt@}c5>59R`7?9;{0Gk@-K$HnysDZ)u!~52@ z!G1#;o=?TVBAQK^pvAr{n-T;=(j~r^W&PfAr-xz_ic>XdEIY zQommPo|(P%?EwcXCpR}YN+pPtqDFcl66q4=YcdhsxFK&^)&U#`yg*aPAAJ7&*UE}1 zr~TFtlEg9CZ|xG143T8R>yA-xxa>WeG2814_tYtStuAEv25#mD8X;_!MDKLSEs}hO z`b$IXsn+pQvb%BoaPS9`dn(|@iJJ8pbGFINmD8U|>*7xt#|KM0nM>Y#TK4t1uT76l zclY!f^^XBgwG>q?Y#mE4Gv3=-Im~aQ9g&PydbROo+t$Z9^$H0GSn`6j5$_%`Jm38D zQ*OCrUb$q#6e%f&{EPNyTjq^0vF0r=`jF~#|EH$!U#BAPkM)Lg&-&?G?ihY3qO2FpHq|?RsGR--|LI z_7|>N-pqeHX8pq8*6Wvxjr@^$d&-B?E^_C{7CQQo92^|JSm^+sVP}+o;2S$>hrlE+-C8tT|b+_SQu|7wVI+*ozQI?H8I_pxY3Q8EMF9gj8ny0So+C= z>dNMG#=#|$7nA22`Cff~uOEK2aJ5T-LIXJ$E1?IuKJ&H*n{oNdS zr=HUg#;c#eGXo%(N*9Qq2;n6-zWHv}3b`T}djbwlP36seW109LE#Z~_e(J8< z5Y44@w4c4#a)!KZn-_`25VR?CW`#ka=rJoP)S9Sj=#jl&X zbJ}}wPwIQS+0Pk#uII$GdC0-u3I}?*e4=!7X`g6$F{b`*+^inxAL1pjBQ{%UnE837 zZL8Wg9hA$$dEYIdB#lBNJ)D7?)6!Lph z;Iw>3u@#rK&h_nJ0zN%9`NN@u-pMJ4n3;>tnVdp5c6(fqv#% zzsX_hmlU5$7S6r|one!Vb$;lK+kcDcb%2 zA{G80X=mT7*Z0Cf6S|^Yd_N$g(ykIbJf^TlKP<5TUJOP7ID*dKG>3mgjiuK8p+$YG zULW(q)I0WNvYByhWu*+`p3+FlNxgUF?H2xLBA?sZpp6mW4nK z2m2!;B5EyBnP_y)(F@XrB2U`2(3 zz_b87Z_ja?cO_(8MnHZ~3YZF02a)LDjgl$c5z!BcCw7eWHoP_aC#om?6$3oMQV1`M10hry%$zZL# z9p37Z*2y(_@&eW_ii@oouB|u#z|ElE@#_}1$ee|S9eV_D26&?s zgKJXzMO_^Xl@yxqm>;zjn1sDBP6Qq5Hp(hU8chE2G6V|s8)R%S;qcLwO2ZJt}lnhP7 z0x$$td?KzU#4=VqyPP{a=jg!^tB7j;J~;m`Tk}qw&gxc@W-xITP?Imt&&vfc(9*(# z_dA$dSfxR%6aI1)W9|ciroev)KUNedoN~u@ZG{fX4R<*J3JmZO1f1N~m1*GqkM%R4 z$CrA|Jw0xSD#7iY3*QDgB9P02#!0M!S45;BJ^e6hO))X8o`9&RsPJ%c*QGhG=-b&C z&jswS%HGYyG>`jv7B??k|A80>J2wc9o_AYN1vTaPIC$*`JixZPj=PjoE?|EWK~vhv zdt}#EFkz69m`NKyUsfEw^n;7!1%h2yww9ir9tP`pyYuugdGL4*a82F+dZwb;u!aSby`PXMrKQWFkxzojdP~cTX(7cgL?pv^ z*w`er_HA5VUbe#X$Nmmto9QqoPq=+KY6t<*^_9`iVV{0Ts0fdSp6mC84kVA}J7d}0 zeMg_ymj?M=$G4;`L-7M&I(9-(LAFQvfFVf@--7chuw0nkS#+Bm*@as<2TRo)s5g{N z)o>8t^g!{R+0_dBQx1=_^72g_wu2EsOf5UI$#?)W#V#l-KcR^oa*9tkJM>eE&o9dD z-<4jpdRpO;->##vLzPfO!wM11nW+R^`miwOf2{MvUPC@~fgKAumj?|dI_LyQl#?FP z0-C{iLc$^=zpAJqyaJstmg2LJMnlu&LE{}eNJWyQ=JIF|RP3n;JTGPHS`rMN$Pm6wQ0{1r-Z~`9cE*D)0J6Vtku*0Q*|))1)nf>XF?NaAEd zIwRta@!KYuYAL9GPwgf(oBCk6&@Zwf1BxdimvEnB_zRw(66H~lka|{T3h?CQv&KIK z^SKNw5Mf^z#U_E278e)z5w3%^^7Q8Ik@POv{jf9S>(Rxah0wBGt7ZQ3BsU=~^LMVO z^~%3wuW#I7!|}B}76j3cj`VDA_-q{IN{C^zV6B;f9J`0qNxlOIWG-E$*>T1+j2iUu zJl1xM42(4E5J7`lYV+pKe9Q2;o|`MS{Vu?&Q#&qPX46d#R4a8blG`DWsrYI*zOB(dC9NsO)h1LaY)m@tJFT$^bvH1u< z&z3D)wr-U`3Py_D>-^l-JSQz3yFbTp*?lDXWcyFC=!ME_7x$t7Yu++YA8oZ@hP-x~ zD>&!4t8$A7HinsZEG&Au>@TBo0K<-sL>PpD~qR zI*Hv{nuka3d}CfbhEfpNLn+fjXtZ|kmO%OaU-23?SiizvV35FIlf%z|DgB+k{I+Fv*J)TjBmP&D4 zckkW>2P)ri1P~A?STZu6uV?!41Jztb!&dS=ClsLNV6Ed^-8lc?XP{22$lxB6hga+0iHpO$ zV-%HXDeOcsuGUghyE?A|B?i*dg(h1HP&r9LxQQ9n`x^eTR4X|}MHgJR@LxBv>=EUW zwCQ=uENJl<^&HBRJ5e8E`a-x&6N2@6Qz1!@>!@4)QHXAZNR=qBoZbODki?7xi?ha; z4AkY|Qgkg@B|U%s?H0_~aYay}nrXs_7nLi7XM$fRamc9|)}0m(7&U0v*ksd?0^+fI zG~1+iW8gD=Ms(e}b!}q6SvO#pz3 z&04{9^z=kZ7d*e=6O8Rzn3vZAXfi|;%UJwVZAb+3*L%kyxHOi5;s*!@4{4A67fUcb zR$=(}lgbJ=qtm@Hq%N57tWZnMv7z|G3*PRcq7FF6m|uO4-q3!~y?m_tPzG#?iPSl; zAYLXVJ@oV2gOCR-icRPqS+>E@v>Q=}00+(}r)K9PlP55WtptG%VUoT$D<~O2xlh&1 zIUpni7mou{Qm({A_QkoQySDz%a#KrRe=h?=i^Y+df*qunL<9lZDfxeS0NeYknLJFm zz)W-!o9y$K2C`r-`{0iXg1>)($>T;5`abva8z{H}SpU`OonWbAWu?$+X>WgN3hv-C zRE@FR)i|4CV*CDGr5q%$(Kw|;Ns9gjMJ2Xel((!bf2!=;6jY@89dva2YHTp)hatc- zsXq$3kjH|GBZGe$LyAJG`oAm&{=ZRH{hg5b|GSLq|9#m1r5f(2y)6ZWma>*sXCphs z3=wJwDlN+`8`uh+QP!cOrWio6#^h^Rs&Mr`3b>EiyZ7wru8eGHYrCkSQM`YT1H)m~ zJ67K&8MVG+bjQrxSsD54+zXVNA@BMU?&0O`E-tp2`V0XiZlr*-E46cM0ppw%uMKDeRXsTU25=GxXzYMNdyz z+^I30|AXA^9d^INTDgPRa&G3fLb7!OL+fEOTwp-#R-lB^<`Edb?eE9)a?q9`-(fQ$ zr@m!fDas`&aaA40S+dIZF*AwSU+=GhzF%ViEJHltT7M#qHy4T5Hv{UPUJ z1s*q#9zM((c>Yg?1VSz9Bw*0Ub|e&D&^L@UC;CSkn1|V8H3pT@*w`4Q5wWRj>_CXw zxD6<6*wc{VaQWvnKZ^iXMs6rx_(*r5+U2dh5)7|JuNLNYAf7qLe94TXwJT4 zR}C!yn;I`7h6N25(QaC7iiR^Ql7OBD2Wx>Z)O;T6JfXx!n=EAcG$3FCZ88=69<y?>?a;|_3+nf+S-d~=d`YE;)7_w z3Hy22i#&!efQkN*tFh+(k*jrhl@QT_0i69mFROz5WtQ_>_ZAdT2v=o9qNCcs9Rh_^ zefTvRM9%`T9wsBLt!MeyEl4)ktXV_yLgox`G2~Q;65RB0pbP}&Md*l@OBR2MR3Y1N90ct8L6R4mEYavEQ7V8n~@268diDY=ZXL)KtrRhj%u(6U<*z^ z!3s^~jJb>$pG>O3dJTRAnITYZq=DoKaK<^^*X8-xb*LWBDE(H^lm+ArM^-ri% zj^p-G3O&XtDtbv8F?#IiX^HPQU1E6hS1; z&<<^XGD%IXg-}IGJ@&Pe|6Mc?g+mnis;94SdcOZ!0oJZ( zHMLSFlqm_n2Nc{cwXgpzjP5AKx){?OoUv~0J$IlPSaYWtp%cpsN`O61cu`k-yUF2! zA3p}~!#M;#Ou;j^Th%WLv8Z3ao~{=qW@n~gxou+wV9se9dwcsFJ1Ahks{9Dfp$iNQ z{BI()BKwGw?cIWk0-@({-$>y50|!7{R#a$cN7Ikv3IGAF4{=TgogD9TbbzGV$pFi+ z@C@*+4%;~Ll|)ox!eJ1i?H0Xpf)Zljhu020i_UCUgMX3xy?aw14z2X8F82Uk1fZ1v z_%RL3pSth_7t*}n0>qS@X*WFFllHEq)w{T6E%a-1x3g$Bei0b=#N@Q)q}dx1q)VPgpywi{@je&44-yNK?; z82r`6siD4if242U1WHUFDhF0g!NDW6*(#ft>%f7^4_aRh!v*e?S1|I_Urpp5*e zqw$YwEYL?LZknexWVxkd{tfbBHp|8?mJ=~o7|r1cIgB(a8)xh==u5EX6V9EuX^OGp z*Ny%`?7Qp2={#UQB4WI0uhU=*_KdoRMR)2DAixjGcxSq)5~r}Gd6|a)l~bVg5a=(K zH;5A4p#O#nKX^+PVp61*#wu`xu6X*JADl3bp~5^*CpCD#+{m8zX-ZnDETy!Tm)={{ zV^d|;FZz6>;fsI&)Vb8>su>Mq@@c!$!JYAE>}g%M>pQXmjmv9#ZBNgd$|{`t|5LZs$oDdCoz0VCnW5WHU#8OQuFZXUcwa;KbyJJ+s((oSdhbO(@QcM< z9IsUxbOi@?epoTc*?Q>CHjD}|qXe50YY~V$O1JNBEHE`ODMY+G#t+b7LB-;m$mYl> zX7I%{3=Zh(%F66=D(EsXk#p%gdC@!ezXsS?qmW>>0bT^Qr}dIFl89z$&e` z>%T;EZF_o!(6s~*MrzMqa?77QS&ktuVHvWp;KY6!k^&f^5Z=X6XTSNz2bfh<#x->} zH{CxtDU)XGC0wN6H_CB3sNh-%IW=cupr>Y&_vumU;|_Lb&)YdgI5i*X{T+Eq&E2fs z>0IX%uD$%U{UiE!loh?yt@ga|^O^Rw+W#e7O_$#-nD2LWh*C(Xh*`{<;dfHQqa3+~ z8aqQ~E;S_`t4Ne3yWEeB{am6v@k4qESo=3%2>y^*J{7Q8S>U_wqmsyDOawqqLfNaW?(C6=I@hpUe>!j1Bv|9RyPGd6A} zmh7xj=C@r!Y+qL|+%J}K{B4SVh1m1Q{oA~5xc;>8fv9S(Ofac>VVF<-GgH9LZB<3X zY1=xB|HB1HW%s4l2G?HvQj%jtcgMRdvZGn0uNrlfD#29_^Mc(fhI4%+p9^P{YD zR8cO1MvAAPWnsgarvg6YPgh0*IefPynk~N--zPSZ*G-kNw`=Z3Y=|96WF)t*tun5< zPqpRfBmF&Z9*Fmj^i&T;P8=8>=ZST`A$xj2==RN}iNCfox)nLcW?zK^oIW7U-y;U~!oFshKjGoQz!zS5XypK2el#aBzt4mq=`+`U060 zVKUKGlrz+q3x5F!l9ANtfFc!EKdrA`yQQb6cLT*h$Xs)XH`Z$(AA~SrA(XuQhGO~*KOiQ>`_BxPc*UjmV37?Rma80pXSHo+y1Pwdw3mX zqyQ!erI`p3U}?qA48M+Eqj3vHY-sBa``0P~=f(V{p$4M`qUqZ~B@q(KtiM#5IdQp} zKmSv_OzYSO`sy{Lv{+itKHk*<8IE%T($*u60T3x zSfas4p?Lj;YMJqNw}t)K12L)F^m*TIS`B+#9|-IVQ5y{xQNCU|_dcP*6F&8~5!<0i zFA%&Pw#E=mq3ta=bN^bTl#3JQ;EHGx2>(A+=a6w%TPo+VMrMwxsV^hd8#CKRL2;88 z+|^j(L@e4b$N42fJl#Yj>si=0q(c7+^7x8D;4OwN+K7NlGYCYK{1H*HDaB7fR@MKealqcs%dKN?ES`_76f}myFlze_3Ie z)la^5+d1R**tx|`0=SAtJ#rL-HTx>N1!v0qo`CJK9_HD_9alyn43 zGW#BE;FAnZX3|@dk{gKa#OE}NS)WXH=>5DAb;OD1$=g0Wy7ln_H*0;G_?4;xRa#ZG z+U3b}uYx}^9epp<^+03kbj>x!{`c6*Yru%WJ)Q#XmWEaQ>`7%vuO>R5c?IbFsjPjk=dhrO+NM2d(W@Y6%5~JeR_KH zjlwl4)75`6mT@o7Zegu=S@BUnzAMURHT$s7a+a}GR#|uc;hKaiGq$ecPh}5x%km^9WZZ8tyZ~k;c;MKv()=1wODr=C@tHD>nHm2iho4K zc9lAd2naMM^I}&suKT9H45>jdMenS)cqH*6fw;LwBmJCzZ_y%XvQHC#+5)T@9=Qc zC$J%gyNtFoBxOcFUCb*R56S9%f4`nS#}%M1pZk+DGH!W4luaMNG$l{_@T*=&X!j;R z_mem*V1pX_{LS$8(#*^Yy*>?k&yE_yGF88ysnovMR);PLQXaVHXQ@Jfp!<9Z3D8kz0zGzdPdy8zsdo&>s9kL#kP zDi0&?B^nwUwqAyBZwq;OqQ8029LTvNvsk0Oi|J^<&L@Tw2cJ!dF~>hGm6&*GcG*Ur z^M|2;)j0KRNU3Puu*%V_t0J#GIfMW3{tHi(eKPpwYB(itqcvFF7k^X0wn_<77Q)Q=ah^59X0Fv<+eP;^X#iqePr|UT2C47 zEmgfuU0q!$2R`EKll$e1)G?1F!eoJ$&rFCW2_&c#*EA<2$ghwqSw(BNl1OJSTxbAp zCLt-Qy`m7;Sc1tQMpkgvL8eL&a>08L9k*?g(${+TEXG@ycRs7GIr6ClB&xbd^MfZb z_XZSa+|SerpKZdaDf}USvRX0#EKE){CV1W$eN7Jz_RqUI>G^V4H!XV23}i2d zw2Y`Yaq#9}KU%t~GR7wFWSscJCtEyZv~0gy3%4VU(XGKUSJ~d>5Epd~`3RAo;XPsv z=~GTFioQYDdKwy@|7aC$q%>kL5@yiz4G4em<57B|GR?vL@4uOcg$++t>v?szR!O^$ z5d@cUCd?0se0Gi1R$wk@;@x2P1)fcjgJcu8jO#EyD0?yB)fp0#1fPqeTRdux(M(EeB9Z1Lp?p{?@1yHfU zEYIUH+-&(UgKjO^T5&Ey)EcuzBJ>=%H`KN3Pr|Ej5V1sFv>ZT>EQb%X2<&J&hv6wC zhuBVU!NMF@9s>`o`EUiOeE*(E&jLdXRHa}E>OxkzTWjb^XVYvpxj~YY80fIc`(B36 zlZ7jCb%SC1%ial9Te7?h?pIFv)^AX=nDyd`iR6z%-l_2-Bp$=-A@^5OG-Wop$rM?v zau)INl+b<_zCvRZq+G;Era0W`R)mGO?u^T+j3h1=Ir26U(l;8$sNBNx_I zwW;g#lkL?nauzWsDE8#C+?MG`PnUDbxR}!BoDyLgsleEGTRo%I415DL8fvOkVBrzq z)65}?iX(&}k?4)*xj(hi2z4a^=aBiHuK8e%rt){s-YuGUgqApE{7mo2bl>~9y^*QPpVm_9Y3bQ(p0s_n$5?ahB3 zn_~H*z~<|F4u5fz8)XdYrd%hu>-2KSTYcU|n~s$l&P~cQT+53gHsPD3#IE*^j#>^p zVhhzsG1}#+!lVBtXvsTgbYE11(Ck6c& zQMn$@wOfHBK39)<_*6^8&-YJ!Qe4M6LsK;3rdAZ1!rx2j4Ep_!{UK+PrO!Yw%MZOuVXrSEZ7K0;Qp&yDJd`TIBcguDX5djoUr5k zMEHA7YhIR^r`eG=p2@^rqm8xM-y%gpBX%Sp^E3a(3L1k8YT^vC!f&6O@Dwe0sJu^? zJ4-pP^2C*vZlaN0#jHZT@$kvqxS+)nk8Hm%@yG6Y^Y$&$_NWcglG9fy`M-_IKK8V=^XI zTWRB`^u70~IvEt%_(ZqC?O{-lTL)9c5tJmK`$30!`0xos$)+z`cOAK7kh*w- ze%8<|apL1z-wQjLeZdUq?$m+&O=V6c0$@6`Z>#$xcn^891`!wKZ3WFoWjC?AJ*r$5 z-0n#nImyfQTCBSHxblt^w{wl|(j>}pjYZCEkrswGb5`x z7*CQoBGTq$Ol~b1u4{CoozmZKG|lF1=#XgQLCvN0)V@w0;MUOr0`hGam$d3*Ft(w(`Sgu_d3iZppED0CfD>x+($0#0@f1hU zd$c&m>%3$5KUkyLv@DzW__n$ECg@%oMi1fpM{~%|GV=EA17|b~QE(M`d(32|xW9;)=6d8d`A z+_}G>!@YiL^-RRe)ogx5xWJ#irf)O$g-e}{cPst(S~SIU-JdQe(q6 zjWjLt;4z9j#{tLA9H;f&wr@qxqMwgc`@)cEWe9iu)7RTSl^uO_WWQ%s%H_=mYq{c2 zYAuJ2#|Jhg?p_Wr(Aq^)6;)?E9QKJT-aU4zrt*?dPC0JJ%8H85tfv7@uRr-XL88CW zuRGpZcBVzKsiksgPI~5hG!rS}>X_`Jr+}T}Cvt#rlVj2pzi-Ssj!_lv@T5y&yDL_l z!g(?Tq95B+wPY}5jBVJzjo}MrX=!O%F<-9wV420em73SfA-*OSG*n|(1az`!yH9Yu zV@uxQJz8cF|LG({O?H09rx$G_cp34WXK8TjjjIp@rxtrAc=i(#R#Ieeb8#;H_x7-Q z^%cTH1`Qj?>d($IF*7e9A%Xk$8o71vg-HVoD?6Z)869;Q?G%!Sk3W-VDBPBEQY)&C zoP)y`9#6PL%?G`H{Tij}4X;xBI@KgKx?pKbnZ)KHTQSXQ!>CO)+o`uLC#0s9WHsKd zPJjK{?Y#VEwJ$?@ht8irZ?VgC^I8uIdE=*BT8qtfG*>wg3vB^~NH#r>gS)Awlw8G1MV#So=qM#O2kC*M`N z%KTtOfyyL8T!a+Da@tgKPL}Sd*Rk`V%OPp+PO9Zf-#l~DottY54c(Of+~5%|Ztj<8 z-;lb3GRIQrZbrs$FnMJj?YcYoyu#7R2~<~NiUG1?t0+xPHYmDI;H&3pjeL%@=f!6x zF0SmrTnhsgG!t-k)u6{PNCFO@R8)1jbn8A?qcGD*X~j_K*bb7PzrWjH%!8lDxtfaB zdU(*J$qPvx%YHJDp`nrAnRjEWK!R%Gj%Cmp4vC$mVH5N@x_5XJ#RCWna%XuqR7X`C zEB(k~^m$(;CU{n%@03T((YKjON||dPQJu5nSZo%WQCm}_eDc?4`m{|{#rSiJ7*2Dk?RCdbRLA}F(~^}a*$Po0Qw-!6Y~vmmW&|Upaq+wH zZ)ZiF8#H9E6$N3jDDfGVaMwWPH*E3=DjiaaU}DFVl;{LcT&k;G$S@JhKlIv7(@Vt( z@B!F`{bVwRHAB-*d-q0T^od?W%4OlDn$lupZ!CZj$WGc&m7--YE*4AEVA-_`ez3E+ zjPP!h4N;2KU@-u16Fk-3O_Y&Siy>^3VAf&kQw?)?uJDDqxzn<;2+BhDzo+h%iy;_k zm@z{g0L&LrTdih<_Z|eUr)D~*CMGs-&dzI2O;67^Ub>{Fqwn@Sb4*RHDy_I!KCz~>Tw^*^sd+VzwP+KA?c5Jyh#VcZr{x=v9*SXeP8WK@nQa-;EUfD|1H zMyomh`AfuqPlVI31u12;O-h{Sv9mo%nGiJKSgOZ=JcErgJEVHW_}|ew-g`$lX}9hj z;U##tq$efKgE0bj6szvej*qU&gr8)Nu|MzmKW4M|8|jKe0+fzX7eDe!G8O$e^WT1$ z>IJ9&e%KUO_Ycs;0UptWtF^p*HOQNxfq^-g7=Z9dItz6VS`~!Oec^S%h!@%bH+23i zBG(<9oa{$FTYh?Wlu(0+`f+$*ZfgzxxJAdBa$45KonlId!RL|DbtLW>O!D$a>y%iidW~>8K(7zGN zGqj^&(to0vZ~~sO8{fH*i}B4#8n_M36LUl-C)mxV>X)s6t3%k8U~v9kP@Q+CFGqy}uv=9gtI0A!+FHL}P)LYKqyefZTxVXjgO+qoH|Tyxno*TGJDQDxva(f- zeBmAKJIiclsDVU(aVAMO%+gpC*+>IM!5XYfF$I6RqW_+|3=N)`2=phSk^pdg;iu#Ro z6;0x|DBNIk!Hb)ruC^8id|FW2oj$$3;s|ezr-w%g_)eHFV{#1i@2SQg zRwEAnL?_3ImyIR}ufMW%_QZ)hyS5^SdBFOs19z7Zxuhsi7#V z^uHjFF@^$VC?5M9HX~5b<)?e*LxlB|#nrtyGwz!pJ_{G9(jR(rFL9w4iHY>d&zywoO>(ln&U-mLR@46;->|M_rZSQ;yo5Mfhh_B%ZdUYrXhQC8K}3A^q5`!XFl zwZbVQj?w6`T%1TerQw#FUbc-OQwb3D1rU<}R6}~Nqr>a$$jeAe+h{S0>?-+4C0PPq zHV=Mg)PZ6Y=`-bXCs{Ji&TP(F$^*=EtL7*AI*-RAZ5i|=jOX9WJpe9Z7MhKF*8^8? z_axbL5;s+rJZ5d5vTNhou7ra7QXBo>mhVrGrHRw1CDqP*j#Eh%DzExS1C_7|h3^yx zcy&d&lPdC-=S(FyE?j+ft!?@q-E-P>My$jl#v*q>h=a500au_Vxt_U+r}r92g+ z(m7%JKGY8nk8src3su-S820|XAyGIIPYfqQ)s!ehrQKGrGT>f>>iwV+M+&NfF{Z7m zwOg?VSe)xnL0#qm>(ut2`V#e!UA-Sm^u2i1`F{J$4y~k0YVVPI3yX_B)ZIll6OF2| zj?j@H$TIBH{O9>%_rUfPXH<>srTs!e({SMH|0|IOH^(*611GpQhJumcyEi__6*Z7$ z|Mbkv0goU3{mdEjwdIBX;Q}zo^T4z7!H=L9xGNFIKuglPHb0aY{V4i#@S$wUSEE33 zL1{BuI;*PMjK__N9jdr}9yfN2!1kZyHTOIVH`COVE$Jh^g{8z6cKi(ZP=cy^?NB+4 z1a4s#B~cF^Q^Ro0A3889fz`)u50w?RLI_8QgyAv{2?liT4pIEPyi35x-R8-Rq_euZ zkp`>^)=%oe`>?*k#J>O7u>cxkorfd^yfkMA2gpi^UA$~*VoSx5N@dO()v$5=b>pj7 z5m*OOrcL3k9*Dz{NXgzP%iC=1>m1jIz80qd<+R1~12z%2a+hV4wgXVnWKR)o1BAoRm0&>gIC!+P+P3v9VB^U!N0zk6K5z5y2P5e%@#yZ-k(B z6iVCN!Vbo88jnv+-5yCR0QQV4Cr#+SD|t}f9JlNg9AX^GZ76qtMLXcO?oQ4r;v5<8 zI(?UilI9-M+#tn`ZF3QuR9no1_FcW~ZMfD0>@K9&Eg~=L@v$8@mg@ zPr$GSEwZo7DaueGa}C%pAeym$((||*^t~oJI$ikUkWBv=8X_3z*i*sC9+&9(c{(^z z(D379;%9e4#SR5-YV#1D?b$c$sHebN0?jnw#+fwYglstZ%~7xW%PX*x2^loOOo9Fk zZn@wd62U7(n$_1g0TB_z00V&!2P7p+U%vd<(<2SB4Ip#?$|6zUOp-lzMsf1;{vy{m=h0z6&t! zuh$)xlmESf{=aM-{@W_pMG17_@7MM9Ye;|poB~^~7@* z{Ps@lx43n0guX2@K6`uQk-U-l`UeA!Cuy$o_y=Cm4DJ(^4I#y#6~0g$5I`r(xaN%t zD7nB7E4hFIy(!%JCJPOzY5rO9%}B*2#5Hu z^%#RKn79Z_$-({s;itT0YZah!c=;W^PcnF@{P%JFZ*KZ0ZVwy5$<>Nzlz{sh+K5X5 zMi?ezy1dWB!O@WrQ6AfQ09krKIy@d@1WlMbR~qMk#b6Ly7$rNzt2wvNc%iF6B8~S4 zA`AzTG{~sXO6;(kDJnmK4tBk+pZT z_EN`QVIHVq|3n_wWvQBQmE?UkcC-f~E%@ z;I*q);qmVYdjK#Y0k(TisLCT(g>WfF$}@a`aHpe&#o2@wMAP+8vJ8&jS;gtjk^kH( z=rK`o-N5aEGFN1&0IZV#>gp-CZ+C^fRogRiYX^~6YW|s*^bu7Wk@iLMV)qaRQ9KwK zn&3kr3tFTu3f}dgH6X;ef&;{Nyl`_e<79yz&*&>-nEV6$Dwuu{AW=>U8h#=sSIjOs zck|KT*d7VH8#c=C(fx7me1{UrW%25-&L%de>AoTYvOHy>g~ki0!!bA|V1|DSe~f0# z<1xBvZ~nVSwGTd4S-^R5h0eQ@<(#*158UNdRGJ{?MK}7Xr3%H_6G)z_pwPfkd9Fev zg-0cvnA)Q4MWlwU*jE^`AZOM1EP5ck2s$pWc0TX%;~nRy{@%5ApBYkkL|`EUj<3_x z3D$6>!9hdtIl;vdSYN4iO=L-dX8rh+VADtLavqOyJ;3>Ak=^7As^daZViaAUHXn?# zO5j&qS9cC?o^Ermj)qOB4w z?V%-UP?DCSrJ*Tl8fmZld7S61sH?s6%p|PHvRU4%&-vn`mm|>!S)b@-(OpsuLM-}vfcOJ-$yL+ zqa`g<8Rpbrp!(lOd!GFCDO!r7lxHD@0(@g(X}LodZuEGQtu(AW4P)>?BFqwgiXo5+ zx**_G+UZ(gFcESUq&&ID=VoN+A!ix7p(kgAbKgSA50{ENwO9vUz`e0=ZjpJ{+`Nvz z3s;Xa>sC(PdTXNI1gT)R>_t@KkFb4A9S&5^ysSJ1MQ{z1i7txn7lgd%=TExL?0kIR z37-Z$aDNdx%8IoHfYAFcjUANhBSMlfjH9f^3W-Mw8Q%5AUex{sIUXzTnO^p#StUYL zjt%G51p<(Pb4QTkS+oj(|L`E*poOjVLfcu5J4LIb((4{*#<5W!dqn8^u{BuP*>U)L zFO8+*eisxb{`*o+Dgc6{dc)rikv3tj!pI2E{c3*f40!UlQBmz|Wvr65K!t}_B3Q++ ze;L}3y*PN`6h!hhC%IBkRMUl^D_DdC?e}C?Yjw5X1x90ILT7dbUu3Ay3!Cd2Zc5Cf zAzT>16}v)!E=l;Vtw13|fGPy80j}zi<)JW;vj}4V8)g=RJd^9#e)bi0W0&B%MMKVx z%|3@wdWY>3Ea3GS>2=eJt#;|J0Q8_;x>MUMIn#)u8-dm}7Xdx>LSRJ16)?7Br==}G zWTz^nVQRX+)`?&-;h{`zx7mFQa3e@4{Be3&Q=LX$gc1?VX3AwVnFszh z>mbRovl7BTh-A8aYnZIuC{8@!-MIjoXCNs#IXUoEUSej$V_2#s*@s2KxWT^tXtN|R z_z#n>5u{w}D2et5OIS$7WF;Vdr`5l2JoCGU8~vfTH^~gSGS#gu3AKBu`TNKV+qwy_ zfp!~xwWG*9bJ~6Z5xC&zP=$21wJ};$Li=CPUS?C|2?9@XR2VI^1urZn&I5lnc>8Vd zvx;tbZ@|Cn%`o!0iekhKSpkYwHFxKLHf`F8B1uWP2WZh`W!Y;f>$Ig8R{CyA0wv$H5&)ie5=XI!F*S z3`u^Use2Fa@-azgKh!>>;9UM+2d1EN3`xRI?;?vYhMH%4uimHK&ctN1_K@oJQMp|g z=~)Bs+z|+iGS|hFCU%>R8SJ@GQ3=^~UcvPtoUr0`Ws4Z-=>tf4udoTB8`r+ zUWY9DZF#=gXJTCqs4Cc>5al3N=LUHTJLw4h*-KhtkyZ;SwUV8+pSxe-*yAR7Q$7X9o z7Af|%Nw87!Xx~h3aj}l1RRJrdM0?i|(t`x-z5;=LhSgeKX@tWSHO`*L#G85g=Wb9=cjD=I z6}e4Fezv=J1$HhuBfI~-W_z!%sNBPn)|BMp<9p5@cwuWid+w&)8{?oQ!XUm_#LLmq z(Z}cbvw>e!Do)BB0DdBA2mu>r9<&U~2(ugjkVT03Bp+y_hsW|EXNR+yMdEXCF|Lq-tr&k#V)6q_Qzp%>NkC`7CwjVXyPk#NaeBrmPh5LD`F& zHARi}cE1t^hn$n(!`bZL%nxb@Kh-KjG#I|vaDYZU&&W6wKMHibx~dB05ukJyLm{i+ zGoE<-;^N{kC56x|NO1?e06=WATvwt3+XtdKgnWU|3eraS8E_h~&QTy?C?#HMC4#=G z#Cb#+wV#?=5KKamKV}Esxhmz?hv$R0J7;H%|2+a7f6B=gaj3qBxc;Kj(g)WD92I_% zl3{M*jero@ckP+ycpERC+P!DbVK0v?jNQ3eA*4U`t`8xJ0u>QaQI?z!)l-l1)Nu0! z55qAf0=bTgD>LvU>?!jokXjb0etY|3bPN5nNWJ`&jd)p};uI0%mjJ4(#~M>zqo88%DuI==2p#`uzN3ba=OD`n~6B=>5b|V`V_l9Lvq1D zwCb2X&4JT%skRxsmUWN5_INn2iyinKGVu6R;%64$;hP_Y$$O^*C+F;*mO}pBx2ntd zS$q8x$@Z__{j|{%!o3|laA4oJ;&W!Ub2RqlJe1bFN|xa@89Sm7+YXL0{)Z$v8O;`00hSxr(V!e z5D1a}!HQX1vhETpNJt?frES?HE;qvF4m}59>OS65a@8y{(#Lf~Iru|`U81j1d3Sr~ zd>yOQt<9to$tEqKD>4pp_fZ;Vl$I_=r>CHq1JHGcAo+u@+_8;@hJgkgA&$wx&ZLtx zKL;>vvIxC%bfsI#-F*S)O$`pJN{gl#k*MDA2ZIWdPDB0=XVx$q%RYWdWeZcck*8tnM#{Pv63w@)QYLUh*zrD_+Z1GFktA!)AJ2;GD{okEh`>L>*RP%Oj=IGOBW9ni7F|lu1 z-t4X4`mHi9V?DnB2Q%6ENsA+DBJ7pKK3#3sHL7FsX}K)W|X;Enb*&K-?i9Sj;OBT**AzATtHIP zrK1GnfwC4{zfj!JyZ7!1O(voo524 zBHq4>miMLiV!ji3Pwb#VR(7_O@0xd}HTV;l&Ge2};@pNw-W^hGCl)%p!Oh5F=ll$> zqO*0qv|;B4pM85NQaK@L?#3EeVVR>_dMmHm>E(Nu?9vwtmyDNwO`VX_ZPWOuQ@HA=( zK0n+4<=N8S^a8sMyCkEcC6Zyvd9VB~gH8=UIbNX3 zDOVp;cO-awyRG!Y()H&NZzh8Ix7btqMqK00&?$E+X)-ojdsI@`VLGL;i*xJgv}2u9 zmB+>HtuOvI4mO|KYZKujBuf%z;7&VRnlikPGEr6LKQu<=F=#^UzgkRN+-d#zvA*6> zC8!$u7^1k`#kmHDi6*{HPid|E^U?VgdT)EAp6U|0c)4YAS3H1{{k01vr0(xZ(JTUl=k)X*x~ zh%@+Lx-uk##COn#^uNO6RxqpQ;r;t2=Oe;ttN0twYDk&8f1RBCDjH0F;|IJfet5s^ zKhOzF51<&k*SujVb091FP*Xwk@x!c`<4VHl17T&fxzC?H`-w02=%}SSOfWCRSR5_Z zdg9cFEf>T21oIe7xX|#*N)k+j>tEZr+J7Nr^jVjJj+s@9KC9j3y_Z^dtN12!`qNe! zlvJ};^!bczuyW`Wh<>sSs-_wx-}a8TQewH>T$_-NOT<2h z1u~*LBbTS1TCh^t&f(LT(zDGn z3xl`WvSP@fY0%a(YjS)?)g~mo+FD=aA>B}yE<`83@uhVXu~qc~-l`eKlPZltI-!xk zHnJcx>`_rz=!%mKo(qEhzKS8N_R*Iu*g)u|r6F7{%^}k{`xNpjP474T{r$AZ^!{cD zVWRn_N>oV5%-p;Q+18kW`O^juufeuY#Oo`jgP64|Ng9D$0ozV(U0o&alyF!*09KmB zfp#e3kf30GdG616uW5lc8~8#KW=nmR^a_fK$B|n#K#Bdov2pBrwP8ftD>pMnBXnlu#Vf5Nw>Fc|Ar2B<{=k?V}ouTxv&#W2f zEA~>xItA7hTx{ESW{swHvU4=+bH@$tG}lrplkPq9?YSKm7g`+-t**`Yw1fF)d(=rH zD`y+YO_SdnUuMqEHa4ADAK`+&P-&06(_IID5{pxe;(24D54~$>5`)()>^UhDV`SFN zpRBAktZ~SbffjaSQJ?z3!-v*BG9{5vF&#a6v?pECS=?u(q)4}gES64`p-`j~tVw*d zQH&lN?Oe_89`MS15#ca1W1Hoj01cq3Dm z`;HJ~UHaV3(**F(cVUpUTHSrh|3Wls{o{B(hEjikj{{HY$nfyCoP#^nqWRh3Y=eaO z?DzW!v*Qc7G8YSbGv7sSqo4WfmdSo1JR8?$bA5|=PT*uC%1?_?H_OIcg)@<*HZ28} z^yGd?-3js#cc)BVsj{Hw_q?9bJ+el=`^QEvW##_5zT_D&&D13 z)jH`Z5vZNKO8NSW_Wi^|+ounDd60DPnX#oaZL$j?q>8ktSW`{%(78A$B&8! zhm}jJk1sru*zWwO#AWnDj-fdp<@ua)MH9+u80{$-z^N>XXRfxn$gZRRf;7d@Vf2VcIToq$`0{owQKYXN)Erv zDYu~AF3Wgpx0L5^NU8|SG0X>W4gcaA>w&8ZkP6ddJgakKttV3i&)xcup6eh|Oi%|r zxDV^0OV|`F>ZM_{1B{0S$UJO?L4c&&EW@+-AEB1d+QX2LX84+iMMSv4%oO3W%GM|& z;ZFjhBKxhUgyzfY!@ilQj?moP-1I{Co436Q z@9Yszy86k{(5=vArtY<}<`FmDb-|SHq$V2o+HA}Y(SQD$Qdu9`v-6r%(=!SIiloH~9}- zg2x4H!~}l2qosS=5sgKg>u&N`ite>!x90eb%-4PSyW}su?)x}BpUkq;+mMB$;(VbW zglS@;>3jlIXD$mXUt*Ox@3uQ``%&F(>aU{>yxI)6au%`a;z zbxqX@kbTa~&qqamsI5KO7kVNVGR=0IB6Z8zT@5@9hde~t>Vi%`bJ@ot>uk?e#i#_(ljA+T-(o|`tZTaQIS9=;o^$q0h<_rhwvgp(&nva zJ(us9Y*ix*i`4waa>zmH%YomKLQT@ARH`E+923}&YkmCQEh{_GuvmJq`{?=ZIGQ?# zUCXpfr#w{z&-C+<1S>8-Sr>#zgU;&4n_`ZiJNCT#V3kWtnYc#bm(~wuu_{EFk8lJl z$HHR6#qveNMtc)R2%r^Z--24IZ^2gl9LOa0MkU)l#b$*xM|(6M4h|0k3E}X%5JxX} z%2=uB0WH1%ZvWt$B<%0*U5Li{cDI(WR)X-$+B&^N?U_+-CHN&+DL8zN3A_gTc(i`{^E*Kmbnf^3g+UrajxR%DrW||>7BrLzI2G90So)Nv6U77|5BnXGcWh4s zncPmPw4Jx)&%Ph;P+9hWK+-!vsjd_NA*wO-x>eewnj}w8vk7n2+{!2~?DrpWZmX5h z_Cy|@2Iww}ka6A$Jbv!e?HiCy@EJizJ;5->5+K!*4xINGhlx+@lKC^qF9UyR09 ze}?$F8VI~5YapYUe=ZWXmf&-@@9+&;Cbo-H-x;)OwzWo7SRRrVzMht?KN2@by>n@Z zx}$%PZQ9Z;a(Rfpe1(o3YK8Sr4c)3M`*kW;H-v9ojbf)*;yd}EgKRzdw<4LzE&m68 zE|iJWwR`Jz3eN?!G?&uY|2CdBh1jaYLP=FsYa!o1C&PDrt>ehMv5AS;=;%-CBM%zD z%gSoHq4e?EA?&NOi;Zv5MrxMtUD z(<@KDi5?W=#!Q0f;{b?Qd{o5{8Dox;W?AtC^3U0&VUOm>o^qd=<}NtuGfg{WUSpA2 zm2^i)Ym6eGm0Hi|>xsjHY2vE|b(_Bz_a|Mg^7neF|KK>t$;>T2BM&InPwk_6mPkdB zWSY{riR3ip1zF}LK0LMwS*-FkZppr`H}IvmO|cxhccn(At(sB3>mK_`f!qgQaTQn* z;G>N9_uq{!#YqY8V5qK4w$>6Pa-CH(QCJ#A)Zd-2kOMpY30?OQ0f7(vyl=MSp2NFg zhHjjUvYMFq*c5gD;sJ+m8yT5ZnoRvV3E8`+6#&;v*A`pnL`ma zxXLgKC8wlpo0x^oH7FTiOMn#$0NN&AEo3K4YV~F-D^(INo{*N}Rwz~$#pNCi1u{?Vl%3Wp=xpdwuNPdZNwP|Oy?y@4T(I9zRkH6<5@ubh3_7T}{fhAO!x}rM_Pl3#p&rxS$D|i9 zk>3SI^2PLHA*dOyi&3DS2K`M=4W*AB{DWVw*I*quEC0Ab0cPc zY1eU89cXLvvz|W>m-=yUY!;Twp1Sp|TcH_RGuOl71r7>8t6Mc)CzFUT!KCX`f>uig z{+Xtgfi6Qo)E&?piqk1W4&C$ZWNaQ^=w1rT9$Z?hAtYM8C^W&(=vJK^9Tj!?s>;X5 zCqdt+2)74JFad2R7EV!T{Y}}=Yv$`Y6rpT7Qdk!0dhC&U=iIrSJqjLPnc_z>vl>I+ z6buhdKkxi4L&&yVETH{p3HJ8(1|PgOXpboR5Kz=G*Zq(Esrm;2uG^1%Ob+;4NiGOH zn@rh0V?28^{`Jblj))4y`*ch!CzkyMfE+8j7|$+RldsHF)0Z#b6u8xrARzx_a#6zK zjHZL|dc7EXmybk+7}Q)<&#Izx8mE{5iWuZORz9ulKlxI;`FZQ!p!>|#7JU~p_B17L zS9I;D6nvkdU-tT{*as-UdMnmr`l5V~pe^*XmR$JoY;xqx(~`HUhf;0*O*C@E1GdfB zd$Ra(>5J=Lqg`dF@O}6;-wu30oMW(NYT|(7KA5u>Fl1x7yV(LyGcw1(TaCFBmB$kc zAyZtOUGt5ustiR&)=QN)Z#j_LvV;|4F{(X|PmY?tU*wOYSdzk}r*IFpwhNg*; zh@Hl4#<2O|>FHG4$ztPZ{ig3}iGvjYjjKv7sn77fCmb#=HGlOf>Gr-$F6Y1D!Aj$X z!jpM^cP(~sKEKx!S7*_;uj;#WqcCgLRi~xfOLV?3-wwlv+@7rjezxHL*PUSvJY8kB z(^3C@{yF8C^}{EsQ|G>>!nyw7y~Hv1OU(+GI=7rL>YS7o%GtmEjpV}V@Uy+SRMdz8 zuv-pM`&MaoeXZePiPiqi%Z{&Q+QQwB7m04wEQTPEon0r(v^N6^fnUxP7L|3RFeMNh zK1rJL8=Vrowkq5vbP9Yuyu4^IX9Bgcm_}aa2-p_{@6vZovU&Z;mu4peZ||}a=jfz* z&W9WKBpmfw-@bW}(6@xJ^4ba*^6>LJVv9iaq`lDLmkl!x>C2Pnb#!!0hrdtny2uE3 zk;_;o*n#GFLO?+W5j{xbjG>`@#!35G+pnKmZA+!ycKo(c-yYIW*f<$YRpNoIa(m$!UrTUDK?yrxCC z(=k{f|M>CK-h`8Rt?QwOPm836*YWNjaclE4J?1$>p1?t$w9m@#sE^F5=x^iq3IeN9 zaR!BSvR>7Dm7VC11~`8tNg!K2=jAdU5{yC}>=OuNLPtHIL_@p}++-~&50(HS1n=Y= zsAA{NVWN(kj}vrqW2+M|S%=<|1b@YEW&M?0DL79-uerEVS6h4V*s){Cpz0Eh9P(HL zp9Oye9tOG}kctgUU7uZ*t=z26w3ohpV%8D|`^5Mmw`0;p$7GX~CR=1C0#=pj_6ltx zE}TmvO%_S0*ert<3BO|>Um6LC`ZkP&YSK6PxAgt^uwj7d$0~)Y(>O_j^WGCyTQ4>K zdy@A5ED`&!zwy5^J>Ljyr~;VkA$4dW;{WUPeykl>2_%BI5 zvKw;mb5zs*OTd=5>E8y|;5Yq$5wJzvYjzu{kHGHxG&xxkb5cn8a9@xZvd{nbL?=*F zYcwdl1wSNx(cs{*MIU_g_H8h;1P>fgK*#7)g-!VL-&!fKnuiAmKY)t?z7+>2ry!;2 zsj$b7LF-=uo45?L6HuMH&^iF%AWV_Kk3SoR_%AF7;75S8Kl`q);ysXA4OOF`~ z^r$)-kN#Ui_7ejW5ZQOgR;(@A1`n%7lqDu!1l)sO9;j7}q(}Y%hW}DFn8e3zkX7bx zp22Bs3~3V-PLv@?XN1%04#G_tHl0DOXlef6byVaUpFU!6z`(1grK`)m*45H-AJ0~N zJS*In5!M1)m%%SsreQ7t;jFM%C=NV6-TY+av#rcvd;@czDIKIe|DUpziI&WqdV7mBa>&^}7S_+woJ8N|A-@Q@GpdgcAoPFb@?ZlR1<-x;@ zrw31B#cU!Q#Qz=~riFT}%Ru62rS#KF&tq*1x+WKWR)r;I8XGM|!>-0ZYKE3iOH=bF zbZ#JIuOq3%0C2Zk9cWA^<>e6>MkH@+^-IXha|BZz{6Me?pvy3aHK+=WRZ?lGC%k|V z8HLXcqp*;B_q6B_fUpFsmUne^x~PV1bIUD0hd6>R4^mN=K(TG_U|tW_QYrD zn5Ye5W81ykx}0*c-%sR<0c)RYwQ)rEhq*$)@IJ*1pXoG$pt9S55& zL@FSBXzMS>&6N#6-FnRNge^Xh-g*^A*zNsrM>qZ}2P*3v`X;D|Cxo%`ke*9M$HHRo zvbo$SS1tUTJm%k6jfajL*(KZ7-Yz~r@XtG5vr1z1pFJn?sp|-kjy2_^4_y9vtP*W& zoAg?@d&ve|yLN3!A9g2p@AvuhGum0271CS%*emjxZ7Max#%}_=ej)@H;)iYQP)gvG znEv$hpR0N8t(dfo)%q*q1eiUiqoqa0j;TGf*b`xu;@sDE1~M|&Y4>5GH|BT#7`=%5 zeNa@yXrt4jq6=Z-1sBb?;IIPaG1pydeZ2PNe|h0b?9Fd=M8uY?0%2@vMS;>ZqtXoz zuq+X^D{}Qd_BCq?kRL>1k-$^LV_Gmyv#a~Og%j=(#(~>q8{?()Lz59SgI@EK%O85p zEA?qrT7qsMa_4zzX(@I{WW3U__0{`L%r1TTg4m$TCv35~AU^N!yfK=K{EUV7J_EP4 z7a?fRO>b$Tn(}|w)KoNmEiGdlx)i2BU43{$`9j-rlNQeHG3HrX|J;Qqv%L@ZYH80x z3v(Ra(Ab0i%E<#66Zaaj{<|R|4y!*2?$9=Na9mdq9GR4#e@0>$geN36@%b|z_jbp| z79;fzhP{0L=bv4y!NRf?iM>gSH4lspR6m=`lY|0_f*r4pyt1R&Y!?WlXh6dNiG*H$ z$Cg59Trm&DulB@?{@J!aRS&#%X-3BeMx2J^Pyg9^1RfFN9BO8JR1Kh>L}L($iGGnV z4k#jM1!5@3IZa!w9pImY7QA>g5A?e!mMgtj&)w#j59k*+9$O$dnDq4L&s?3|1Zr;u z?BhGLbO|FeuqpKC41ww)gcIVuSg~C7RIjfOEpen2L52&jw*wp;pLXzipD744-xy#0 zOxM)j6^G(1>4p|2^+n^SYa2`|Iu4Mz7X%w{nh`SlR`8=mAlKb}4f%;&9f!d<)q!Olrq5tFLukm3%ucLF zFdtt6>782tdKIEztY-qYhiDlW5Kc0PP2(02aKd?BWn<8?b<;wVgAX>|$N0)@Ql3I6 z_Ql0LaH0zc2;}pKdi7yBe}uR+M}Z3W z51WtB2;H(pw8jcwl7PV;s~cugQmm8b^!1^l)KUt?7;2I2AHvqP=Z>Sy{zexdOAg~y z>qaVG(-PF=?DDd*8@puwc>>*$K+?nr%R|~B2Rvt}Aqt&jGBY!e3JN0Uo5#Nde2iY^ zKdSwf?UVxDl*s5lVhF9arf(}a{haD?-pLn1AEn8`3Mt8x z-OzqaQ*X#ShtNorE{AELXyk0jKRdKxAo|A-$8w|odcV`Yh?=pIPY^iv=m?f#1Syn) z*;!ex%9200yxmADuJ;>TslwL+wThm{ee8Ca8l@u42GJLej(IPWew@YwrGWxuWSW-5 zYj@Dw4mN-gPr=2Tt=%w?hYhM|jr7ng1A>GV>6uLV#Re;a61o1hjRa|G7tN9i#5Vc( zc!;G4k7P1lr2prZ{NEnep)cVxjL0Y=}y2eN}+Lk zcK-7kBtbJLjf)!XdMC+V5DG;^DVpDEgsLR$Ptp`gcX-b&#-`@x$B4jmyZi&WVl4k! z&`;D}9F%d#I0i=qc)eD9KwM5<{& ze5tFRJ_4A5+A-2&TP+qjxZoBA$P~4(us|%<;iE?jKqp1RhusXED{Qa!%lH2&07&`= zKW-j`^5?PlFC_)d!l=s@qyUaqKdSIS+h3Fa^>0%JOgjyooqyp!{QUfQWB*Fy==ael zqob!Ugk8lFsV{V|#g{&3@aF0j)msU5)3=<7mJPrmWOe%wZ`=FU1g-~;-1pP~l)B?5 z2cQfA`fLA)rLY+P1lEMQQ@`W)_kQC@bKOWa&a+_qtQvx5aB`6Wlb~4&I8vPNK?t(N zcdtKNQ<;C@c7L>DICqN7NKXba38UD>HNbBW88pSjcR(b>I==f9L?eX$6IxQ#V(jv| zx?O-9nk))*ve0)xYt{V=7sH^QqBFzM0@{(&(hD%c{O zGoi^~<=TrMKHTs%8i!A@510j)VmIdVfHHkbe;Oy2O5cHh9=m}p-I6lVj~+=uM$^!M zGoU++ghRfd_r4L@x1?iwxe0V4#>@`do-K%_1^miwP5XZS5jEV+)I? zwEK|zVI{PU9rO?=n8Cq^2R{6)Z?-!o0p|}c*Zt=qcvW3wPgBKU?5){Ay^S5x`CUg0 zth#=~R0A!Fa4}MfBFuF!g{G`Oo^87Y;JJHmVm=QPJLH*za<{4WJdJpP;;U$j{Y6>! zH(2OJH`nZjxa&~ zC^>(`#{A0dA<2yUhip@3%+F{>oQ`=&(;mx}!6m3P+K_x#Z&1#mp@u^@V%y=A6+zAR zZ~J+S(lgZS`NEEzQx;?(UD-4n#qhIzso@}>>=P%U#1^h`J|T6@a9Gv+Jxae)d&2@j z^9a&5cyR{YqOt73O(kTHs_aCf!o#tp_4GcTx5>yrtO^?bl*Gg;L4S5V<(-Lz#d8Mc z=1TV2LvqF}70Fb$8K-ceRwKaTX+|SZL?@3~0jkxQ@Nlil=mQF(X7BDt8F|)SF94?c z8{$Gb2&aNgshhm-Xc;9f zk4Ik`wdS6`Yo5|wP%g-06#q%Xf6r$|ecMyg3}cnYIj4>t-uZB?{LtxK%3y89IW5KC zw8y^3d1z_6NymM>x?FI{cg^>bOfijS=*#*YOBv-7CBkCgH4jgSj(+nI<7KI^dSRdN z_7*qyxrVT%z-az?#ZmsTGdHIj_Ls6XD=${M=65RDtgHVD`dOcSRpCir_JN-Kj{r)W5A!os-K&Wud{=Z@)nGe&?X>Bx#hZ#n$M}7`)2AR)JES=adV|D z9I$7$;pezA>5fNMiABP5gyD8bXxwtkNi=LAGi35sqE+e^dn^Lh#Sgep*)$+v3a!S4 z3&rzB^Y{`-(`J{)^ZXPiq0eGwJ`qRyBPIU6(yzJ!Z!5J2c%?hTH~Ja zJ~}cU7!vXrB0V#+m%N7Gw7*UUGeu0`oH_OW>-5Mu49Zc)L z+Iu2i^=cqn3)~Y4?jUG*=H?^dnh}h@7}ckk)Sw7Zc?cu}!YqVyAt}|pTb-3rhxV*W@ubUMNj_0uk(~TCkc7`euk5Ye`4WZsCi3*3GI7Zn$37Q_?R)ox z`rDWKmH}QU>sYL=c0F0MIWTsY+k|yCDsVivW2sr;)55hxBX_#02rGGy%^`O3uoMFv zsKffkP6U;`_|sZrSwG#}{#e;NIe{8bb{(5G>DINE z;r2s62i#&^em^!Vc9VRsd3cXe@4iRYzjrsUA9~>$J8dP?Q~PD}8TEw0 z2nPkV8Pk_49x1mNDt{K$nZ(J@uX0x#U+Fn&9xy$yrP{Wq-1H;8HlxwH(ev+RQs%R4 zYst1Nb(dc0FIcQW)aM4Y5uMyw=^mHA_YJ+>@nhG4qM~L? zi(Mt-V+Vv@OOeYeg%UZB9^M0RLV-;}!I3AeV=z0-@H)pK9CNWY?NIPFjP-iV_Tl$%UvIp;eMGu`cBe+?fb)z zcVs0fR4i)pTwhuxZn|rE->f~)wa3?@%&K@L{bIE;-wXC>zjJQq)$ zs&d-3+F|gSCUuI>;e5r}(vNK}E!S?(bS+J=EvHwOrL5WfR47@xkIn5wl6Mi^EN>9k z!It$$OAWT;d<>asX%O=Jt&aSae^*!gO$5^N6say5HGralYbDcFIJh8;f=J_ercNwb?Pe>DU=2k6zYg#$>C z@^&4=Jgk%MyYrjf1wPk$>6Zo$nris286FZG>twU;Iv!Q;wr6U5!+_$)ufu12s!c~k zOBpf-@rA9v2T(hzwY}vT6E~v!_+| z);&s`Z`}Gai{z5TXB^|e(SSE$f-{0MB`z2kdr8V-(_wHdO zQod7Lcq?`!^j|(B=(Xh6bHXpVA)<6soAJ8h>c}o?k;HsHB?bdwrGo`t^w;;B6F8Kh>%M1R+6hQ_^7FEB^GCvW2)vyS zk?0_Zas@3j;&R6zJ_q16ZMBKrVpdBd-P*M5c~^E?BZ z$>r2O3=>naiPeV8UdC8?nv*Z&OjLFjm6`^twk7mTeDLLAXdR(f-WlB3%(MFC0~G`5 zdX$>mN4oFPt6TEer;^t4yC>umxDwW!h3h{2a1Ac5Aem33n>DtQUlkqavt>2%eQ_Ra zH29`$lKA@7({t_9LGig~UJGo2fi2w3YeJGMrmj3?2&#oyT`K8t`CMV%@<8SMp~5R> z&V_VWILU6(V$cP@hk`P1%vgDou5M%JP$C?xOhcqPK24*=_QaCn`Aj=e@O~Y2VxG_S zk`l27XFa``)d!m@-u$%uq%YQTH?YW+nUUhr!H}&k=>}pVO2w8}qMU?p=LQ9`e#SAj zZhnNu{opv?o;Ks0gW?8!?sLl$Xq|lAf<8I%ytyYE*!&Ji4J$L_OGm+Tr z?@isgIMv=lU~Uld=j$x|;^O9Zy2R0!siV1#WoauHfVYc#jh{O^-+lr7AyQq%i^vLm z7RaH5G}Q*2qS$?j^hX#19=^Msfk70mA%%s77-^jfZF}|1BM9t5o4`AD~mlhp%$jcvabTK_a8kr83F8c+I)c z=*RE*EkcPqgSbvE*_TiTagF|{5h-r&FwPk{-^|M{uN^TucVt$Zdi`19RSdG?81L;6 zU#U>#40GB4(}sForTA;9^M;Bfz1;j9^Br3Hhec!kc6WTZS~i?S11HG82h*Qf$Oe+C znC)Jr4=Rps9WuKhQ}s+gWstQyacA~oqtr^sh+eSoJ-T==douR6A5Wgf$5_j5jv_7F zF&jlivt~TkwbbxI#4pv@|HOX14*|cv*~Tk|yNInVH@HYEKF+Q*ILyKErbeRb0@_^a z?-O;te78+0sHog7aTqn7TaS{Hm1S0$^;lSTPRV<9;`V%yRZWGJlqikBwpaUIgB3_!^40j|dOYE7 zGw$GEzgQi|64<(6w>t8Ptzj6=r$DfADQjPVvydc{W(RYbWLo9Q2~dk!dVXeP<~U#C zu!=Ow4Y_;Q*`yYC8#qvE?J$plzxv>h)WIL4-m5>_jNCe;6vpDxHUilLQdZd5lrQ3S; zF2(OjOfoEHhzr07f_IO>=IY=)8TPN!+OyFGi@M z_WB7Xp4{_hsK_-`uwsPG)UM>1SPoU96O-GCgQUk;xraR|q^35wr>2>V+}hP2n7ro9 z(4ST5T}Zb@sD4nftw<=*`*GYZjn!F)cw^reksL~Lw zdIIkrF@=K<*;MTc9OLAuXhMA-wd5O?DsU%x6>CbqPo(^wFlX2Gjr14GCKj)bp4)Ym ztjWcWLtHbz<+Z;L{1!Tr81{uUOt0`Z6H{8Ra7j?r27?pv@uyY~;3%y>xJUEE4l8+U zD|z9)y{A@Fe%@Vbr~%B@@i9jPHq#vf({U0Yy}=nVPvNBjDr~snTuO3}yNP{7F^H)> zu~|Me8D#$=LrHZygoVb$bSD}jp?t@7J)M!ovcAE=q>HBdg$-%~0-f^izqx*|UHdME z=z)aGfuWX04!$rxHY-YHxL2Li`|t^6@k{4z)cn$-RTS|XJCwd!p3Jx`9hXUcPjmW3 zvD*G$iZ)hHRvO8c9mWm~FLQ~yIkeu7lU17H)c9uUV6F77`JUC7)bUqIr+vv~R!1x< zJn1$DCD0w%9F(Sr%Hex@r9q>WP@hvR%N&im7|;=1`RCTB2qV-TlaC{K1qFW<_gu1V zb&spE5<>AB5ttW&Zvw7>gYP2je{0@|37#$pgs8Z?Utb8B1Hls0dp!*LcWm1F=BEEA zT`^9v1)5D;3yF%#2gHZz;0-6vqWm;;*;k}-lAZi>LSR#*)-)?mztpE^yRR6i9Zcu` z?0Rxr@{PMsDKZkS{|Yp1t3Y#c&i3Y>_Ui(rhL$NzFUD@v3xD`}Gf5#Y@aZ-hYU)d? zg_CIg&DdV3EdJ zIfkJ(&u;=b(c`mAI;{Rnnc9=NmEBwFDQeI7zKH5hO=?jpm1BtE9aCdqH)ycjqbcep zx_(_zEAt!ChjmaZ+lj;i{fpbKaWt)RVYR&k?Fi!OA3+GM9lJQrh=fs48A}$Y6~S2n z_Y4e$CLcHOJ66AnR^Is$iWF&0O|Wg6>*{_M2DYH{!eB@#)D@CM3l(I(eMVYau$un2 zi=iB|FfarLk9oXW6l^^(qju4a()D}Yvh$Eq7#;J}&9E!!KApSm!DH31gY)xG z_vl~ubkowfpp!ftwddu{1j^tDW2Vfx>4BjZy#u9>LOTqP=^rw*TG*yJ8!BjiP13=C z(~j$6jW2zkY-4Ymo>I7I$(?%o(4_^-5T@-o4JZc!k)j@aPOqi8s{sDI_GwB=G{_e5k~^&n}q=dGKOE}is58b;cTj* zmhQ&;mE|`pos>+Y8!;!Bm07L%p8M1lumEaU&;xR_vh4CsAf*Zu{I|g@DwkWP;C6&c z-2m4Z|M&Tapi2-oPH=|Jq0EYqoZ0s~r{6e~qo%gD_RX6(i7Po6kG&SeAEOdeS683A zJNX1e7!%C)^La)=5{@L&10G{^ZAU@+(T?i7GVM0@s*M}px=kCLKkJh5lfqfcptnKp zw&I?k9CD@md+kEoCU@*0={+nh-QS*?72(#umeu2-hwrc5hD+6Q!bz=Aa?-|J)q(C) zH)r6>Q(JbHVh;m!_=uOTD8EH%T$|nIqNTY!jk#mod|!4eOKIBjv?cdUnd^%)Tp467 z>E})!sxuskT@FysiQjSzsoJwtQShDW6rN9e5XPM`U3;{YO?`sekhWVbXMm*Wq}PEF z8*>A;?<|ta8EN2ke!zQ;7}06F$&%6S&f<8(W&Js#?GkUS0EN#}^a|OPUGl3VJZ;8E z1m=@1AqnCN*{9t$zUC$y<@9z3ljc@Gc3ApgGMSTeir+%<1^eyXui@y8i^x|jZI3B)!@9Ey0hNu}ePF?NU zV}^E)Kd4wF2rH+khR27^JGSZ^GKIeXOT_i^w0B zk$tcLqI2YmXkwYD%mN#()?3nwV`jRM=HmSPEruIj`DA2tbaZUpy46$jF~65PTG&mM zm5mk!v{hbVH>f`5ETS+GowJ_n+njJSF+)3_IjeJ`cfMWbMjrX`Sq+L;i{8I-B00*e z+XR=#IOyt|Gdj*t9UBbO6-v5JdSk#it+Fg2x5N0j2abXyx|=u$5}Z8CD`xi}t`g=@ z`c)^eL_qJgXZ)Cq%fA|`9#l%GaQ9fCG`6ewGi|WiOfLIp&Jlu*<)8mrA^WdCD&dk@ zfZ40YL%|WK9&^MEcTlrRib2?0xxV^+n)=W0Ic{P&K!!mgPXnAPzPhS`6s1Xj<>a49 zYsvLg8UfG#tMeYGI_3U(yH}*{o;OZMnm3Pm{%f=-p`r1w@C3(weyt&3ah0J~NA(mD z9!_|3;EY34OBD$=dN+Ld;I!_J2^4f>@Ffpo7XJmkH&hj}0T?(spoz)np4}J@Z8fY>=ttzz|bx{CdNj*fta8| z|7wX`fWOAwx#0MegQeQ7|E(=O%zPF4zXGU}7>wL6U}8sgOH*I}CEy=`6*yD&VKai* z4Q&RSpjiNiQlub;v*_T}RaCrSuSbA5U=)F{)C@C8QPr|8o_8>{eH&h2fkuCMS_}GJnSiaH_>5k1S@v3k)E9%ddQx3PDJW z09g!9Jir}8)vl#oG?~JyPu(+F!F%^bap3kbyl3n8jRo8rY5B1!%y3f;)DE zm$%&rV<4DEG7>MoI|Ak_bvs z(8P=?AM*r2b_6_$M zVfzulpP(%fT62@N{QqC9S4!xGbby;9AGY33gg_p0UQXKpKE^df6@w*axGepLNUj8?cjL!ghO1~mTyilT``d5Q1UE4jwu121=#>- zSyhyH4_iFiQ{dZg5dl6bykk(wz(u;y?eb}|XE?#(!VOU{JQe<`fRbCsP+~rY_Zxhz zcQG*hJs9Ymz(|zSA5`8CmvQOwhGoOS61YTos7%LZ-CJ=kG_!*>N=}Z>^7Ke8Tt)Ns6Fz?hzry85KvH zIQE)fzaUpQyVC><5CeBOT-X)cBwgtPv?cOgAv7655r$+=KL%v%n}8zRq{U;}ZnTE= z3ztvyO0c4f906KyJpscRZhuC^jI5s0(*6vvlXi#*_7XQVF&RLC=}!xL@H`_2entpy z?5Ojdh85Z0e7@3e@3KPB8<_tj5%@G%%iW8NjKtZpjs4=qi*_QID1aW1W#5w}8ry`e zMH+90;T}-$zxB$@KR4uBrK8@BJWxdS^OOv%!DG%g zwz>LMC+qx$&x7A#l}PYJ2%^eUjkcJpyoGDN&-*+lFi(OxV?6YvyLMT0*(mLXhU|Ql zJa5WDf1LJ&g(IG370mPZ?SsSOZBQ>5gWf_$yp0{Ox)dIE@EQ~}F%|o4qH&z}8Geh5 zEUi+Qi|gHzD^o1%!;URXlBdkfIR144k{#qE@boD8 z|8N061rC@z}!+DPp;H8qV5 ze!bnrkMsrXatpryA$_d9vUZ_WR8X*{0CFx8Yo-g^^OD&ZoG&;5QBi@{1;g_z<84?c z+t|^~#Y$<~&J{%qS)W0sN8#|P(C~*3Utrg3&;dJ}s;$c;_a$M{h)E8Cy>* zTEh6<<5x26P`7Pvdx!p0c4GPY(_%Bc#2*c5j{R%F3kgLyuF2WAQZs-0ErAW{Q8A)j zEDF*w=u;TZ{pS%x9$*HG-xRVP(L?f z5oC8DzUVNRkymw|9~nWi0bo&g+rfIS>D@b8S@7*dR)Zr#jTJGnjIF;O`T=GQlgVN6vs7 z0t8?|Wq^br9BK4)bO{+Ymo80Xbbph!avEesD06|x2mMX3%V zlm=G`Fn?^o;B#aqU{!#p%}hrpfLacpSz8-#ZUlQ;_BYtih_sW*Jul#W1lQ1;@I{nY zRP1wYRom+K+Dz~&h{j-RhjP?$wvzL~)p49)DYidmZ{L8UJV;7&!z*FeV+SSW6j!&9 z%%MbmE0%Hw@eu#!Cq7f1=wO1@_5Wh;O~a{d`?q1u3Z+4ckO;}pU?^i`NTy^8p%fv~ zWELtUk|LCO$h1t!n6Z+C%=45a^DKmAc=z+V@4N2*^K9F*J>TB}W@aXc6 zz<(&lM>G$He3e|uvoSlQQOEE=yt5)N0uC8AUtPW*s2GS-5>yK$hwChV#ONfso8}hA z;)K$Ni)$Xfk}1D}|Lld6r&Sn@H11%~LXhuKBW^?IvSrIz6%_-NP5QQ7#=rK-+`T)4 z`iQ7v5V7*`!x50l3DP3chKFjSN*-*W0`CXPBJf9Pjj;KnIBgo4C6UbEDG^uW-&Aqs zM_u4i=-qfHOPPMP9LN@frLmIIR~YEbqXe3T3r2hc9s|CE2m8R_?}dSqX7M8C?-^QJj$w{v_WxC~d|$K2sL?ioly`Ti9#!LF2%3YVka)K|`debEo<_D;0x38kAPY7^C|kU| zyk5S1iOK_#wNIZv=ZOmZd6RbQcewrei!00Jt^bz|*8idR`v3pw|LfP#^F!Af3R4fB zN`es{q!RDXo*1yHCwNom(?3!Of)LIIM>;Mq^#}Lmdy@XGitu9}x|=fgBbLjHL{fQN zrg)Or1qs%-Eez~!A5pZzsEhl=iCl1VfT<)U-8m~pv9AGr4fIX~+(|_-D$lmX=ja}4 zP^MDE&VcfRB2N9se_mom`U=?OIHdLLpP{Rnp1w^G%u$cvtCPw7(Scik)kO12XVGjua7Xw$rix@Yj4<4Bc*%`D+he-;d5#G=r5kI^yshyEw`6ncsSpx z5ndE(TD}u$S70$K^e+!EM6sTi9;Akdo`L~SheA_^lTFQLRCN}GF& znIsZe0HEEeDl0P`x#X%c;Pw;u5sfcg%FP0H&ycYfDFQBiUki_6#Q>Y|%H z57-rOPf!Q}h*W`3D>b#ny-R=I!>Na$Fz15!Z+RI76$n&tOZ!q?y<0k2FDG?qeGh~+ z&1r`GZZFY~Vsmf1M17Z+y}wJR`2dTAmK4WkePY<}pjvy=mwFv*6i(IK~g z>!d7=Bp_&HS)DKQ^4c;IfF_S<|3NCS7U{zHAF(?991?%yao%6H6#X=a)d4r(tUfep zRIJe2BovCliV~#qon&r8s>D<26)m78r>VI>FBAf?m0-wdWER#VPV@eOffh;q<%dZm zT^D0SAVF2d)AnIw42fj{&@VxK@n_16 z1n?y=r5oB{oFyGU3JCmwAQ(xfwMas-jA|W4iZrEv`CX_1VAt^*XBdf8v^bn(RlJ>zYS|5!aW?3`Btqo=2L{Up33q?gx%t(DVd z6OtVY=irPczu^Xs{T3gA(bKbFxO6G-P8UcLsPJZ~&iJ8fIbr#Z_rDfQB)GmrGoJkn z*qQ(X??{Tj4Ww@N=-R>@4HUJ`Q`WUExo;DPar-v$#)_S=kc*P-1B5_?hs9MQm*6@T z@Sv?8e|G?Z)D#C=BsJbcQbqgQ*!*ygBS!$&XzG1~yp^Z2S{zBTuEzGj*Q&*HWdU7> zO2|i+Uwunm@pmW8bk81^!ortI`E3Ak4+~9dg!BJqBG)TAr)&)Vq-?Fg_3VB0QK!k^SBS$X_7h$v}l`j=~5xIpMz5ZOT}dtr6uV{QMpxCo6-+2LmB zH07Rsv%@k6YK)K+Y&Jpc3dltH1oz3YPJ()ZA10zVvWSXXH_N<9u;t=$qfee_;L1KD zG-I!Y&qmJenU6>$kXu@zVg@cFBk~vf;cMpD{1HS6ATE~nC~#;(n=b7UG=CwqGJs`D zgkMBP?&sWi2%ED(GAL5oO^!ZGxJJjK+39oK^3ui+;z+k#lvE%kTVdA7*ip{rlew#&7)d2n#^>j7Mq(AUJP3 zw)^~og3kLg`T$Eaj6nf~0ux_owXsX07c9nh0wD;v9hyz(J4MiP2Zpz-fxmbAGm^9? zgg%|Pt2Dc>N2{`bKkJu#R)_v|Opxs5SX$|ebkTDo2^^nD8^iKXU#CkV+#FRb=EsB`0r~n(eJk(8aNCg07E1_lXRoog;>>1QNM=0nN4@i z1_@8qEmmrSY@r6G{|QM_v0uLD&56|Fi%ObJ&_5IDGs($Hfqfveq829h!HpYH2n$3e zCy%1xLgX7(1@>6XMkgTc0!2B$*PM<|I{w_QJ{%puy+LdxkvtwfLa7srMiq!rS5($n zTq>kdY@wn;rnA*+;mpv$`(@6?_Ik>gNi6tm;@!@7?v==yqI<4K zvdafQS&kUJ>+x3t8CZHjmBAoiE#Ai0FP`&Ixqji)HB#p9(4O9V^)=cS!h`Gwc@3QR ztNBpb%3m~)RkPN+L$i8fKVLw{jV+CbbLIq9^P3qbv=og6^E5Zj7_#il$}SvhdKxud z*us6qkxJYadJ+tMyk;6smTZYgDh|%2JiRUe`G~TV0&$P!G+b5QWkoD7r&-D@|8iY9 z%%GNXdf8nL>^I1baT6fHXtg-DhZ-jz)MN?7IH#>56Vfx(7ZIrxoE znWCcPm-0643m04(o+YIQ*X39hds4Q@yKg+Xu!+P|2^26Odnw6qnKZN-2&<=0rKNZq zRII2fS}r_z^H{|i71iziUr^y<-4G|Zv?p>R(WJ(tSGi-c_ z3;VZomM0H(m)9;A_tIEtX@H6dS;ouNXK_!4huUik2!)8ttZ3KDl810hMcxeC{qFE& zy(4FtcJ;Q`>dM+?zMq`dQnTn#YB>{hn5NQ(yL-iAB|=SXh`RfG%WTU_gK3Ll{%tp- zcrNv54JqAv$QXOps*v*jXMqM!lKR~d-}TP&Y!#{?2?+^Uo!8WyMUaZ{OUoU_KN+!0 zj~D!^2B7-xgiz}_P3y9!OazpFMc4!f@+?4WU(7h-R%^Z$mdUGi`LIYH&wtm=djC}J z-tp~}wzBUizr=c-dO`XUQ@_$!2GxX?nwpo9OS`Tfzi)B7E|;A>t&EfgR}Mu56T90p zT4~SCn|pTbcViE!GO_LdUDj^d$sW>Gel;V1p~GN=EG2A zK9RdYz8=c`iPR7u)Ym5%P*AFgX4r0F1yIsp zv4EDViY`S0s2Pp^%P0YroBV&VMV^{D$-VTxOoH~n^L<(pg=b|DBlWvEO;|nPn6~5=9ef7#7NS&-UMfVS zJ>I!$Dk3^KS)(4m!pT|2xF+hP$iU~K`5#o|hp4QZRX+;GI`5bQaF)2Lfud-18|s2) z(=I?Fa-2U$VaSP@Zkq++Yd)L{uKV03j2=~b7xw)w5Fp?B{v7ErvOjyor!oiTZ)W?j)|S4Z@#;4s34DMBWY@|bn?UYg-Z&* z`bOQmyEvI7yGc{?2Q~MsHo$=Aw;~AC^J9IZ%*KwB9>hWEviHSN{<^fj( zp7V=3&Lv;b{bWM7YnMe=UY2I>l)7F`_;Ti(+NzHuUS2{0xgFWjvxBep3%pW#8rq3T zX;&inq^S6X$-Nj0Hx={7q7|9jgfW2_95mER(UnUs^=$JQzk`B)ZWA0~>|-K&@rL06 zzhVankLL7S$yU_JPk*mHp(5X?7nfdyLD(Xs6=^K54IXXmyxe;8V-^2`&3Og)f^P%Y zK54oyjZ2jGf5H#$sFr1;ELKAlUVG0UZ-DRSAqR@r}R&=>OB^qpCLDjME( z&TR_{ng5=V=9S$up*L#%%2@U-nUy+h2B#`EkA4cyaD8-6$;J2QrU z=zLazcz1)YM-wv)It?$YQ(reNcB9QSw3JupJi8*G*tjqwOPPDAvH7vcK!g9t*&tG% zC06)ZiL>O{b&{sV1=VKlM{KVe8wUhA-lW<(gAl}?{`n)1a4{x|e$6^cVfHmt;&oII{h;yf)paKQ1+o zv3)zs6Ko3U4=!b3b~a{r(dB09hf8+As>ghJ9-D8 z-b3^S`w*({EXmZA6rwX5K#ms_`uOH*IKRUYsk~w_>h}UHs6~W?niGjMLW9*}ujs*m zSTd9dLl-6{A%z_w{0gf>?GaYbubaswDym0$6tm8Qih-?ES4~W+g9S&Rc5LXYb-jLm zZiDbZ>7rVSx%b`Ci`PHJ(3|-N>N&(NUvqLeNG;oBlk_%usEP7IpWylG4K%Z2d4}J} zKbNlwbdq?&c>MY7=VrG}ZpSb2m2+G==RKFCyxx=?`)=n!@*K*oAyTtSqjSW{th~fI zB~DVGY4Px{skzEZV&E@O#Wql9$3NJIBACIvuYYl7I0U;$Y49}dMYmZxaLe<)2VFPG zc()R{pXK$~u(fuLWG0L@{6eeSRRDA6HNr#(M9+X30SlqiO<@s zhdOyHF+q<>Yyc}732%sN%)xB>sg#>*%NI_8tzJQyS~FW0+&1|$pEwhcRjHrwSP$duqnn3S2eVGS!^v>6b zW-bR{8DWT%?0CfaEZqV5hTyPwbaX=f=PcCa3T+5WG>qp>lgN@#fh&s(!XXZ^0@#LU zAzs701}X*g%gYcHB5%ix#iylF3Z_iNq!_?GHeE;@FkHvb5sFsAHx7d=!k?9f#&S3k zy{F?)Y!JgebHd+^Q|0bG5md?e@{wqQR}LU8bPu<{t-!qK@zbX&5M!d3M~e(nrW>sv zdm%botHRl44C_H=fT&!bg{s#5ZfS3ai}TI4c&nEIsiq4DINhG5zHDT!F7dwS*6+U~ zc(vmGnY`qwrrMQ7$FhS}6Ocg6A8b$CNM}}DpiGt>>=@V}AnV9kQ6|cGH+m+raG5(T zYI%!AKmdcbIWWk2;T-~%7D9T0yqn{=yvna4Gp13TUEj2bQc#=v%>Bh<**}kSLjl%( zX-Pv9Bak?~^%h>YtBfAsaTGc`sFJh(@(5vaLs;!C{1Q(wJ+cSp4kGA|wJ6W#BLfvi zX6V%76W0O&#aL|4I=K08L6nr>1?slTo+qsB7mRX!dr8K6rT)26Dfv|YX6dbnLO(rKwsiSz8XU_-eg zmkoJ@4FcUgn-jVeRGT*+WypHM`P}%VSf?$&V-k3{*xVlwYdX$clG+&??S zk~TKZVM!m*V!C>mJZHZ~xo#^9Ly@u7%KO^Nm`e$rlj zn1^Tn=jD?BZ~>C>wB~K)ZS&+or93iai)bW#+^F|{gua&R(y&9FCl ztBQq>-AweiPIbi;Mn}qr+gyYy{4ABW(ac&1o7oGl)v|c$P&lBS#=c9Lzo>;vc#igM z*)n&mp_p)SiE84ZOv738I_InnV_^xb`nqh)kxgdfGF@jlxhLzKn+AHvf`VjaWM&3? zrYgA<>Rs2Y!?)xLf$W*qG|j*#t&c&v_<@hT@74qSaGaYN@|Cf4=>z+meEWPS`SU^Z zg+TFCBvE=tvhf*FNbkAY(RErZ<@M5CPxG0f8d~Es^v14_at12>Jc2$}zBu_%e0fXC z+)%>PGv={&OD|>(YSI=I?#WzZDazNO%({|$EH*7Z>jl~Adb+{;b(w}+aXQL&$ey_N z#@Eozm8$t&u!!1YiSEb2XE{l+rp5b~BA2K0fE+mJ8bbk=9{f2b(j6;&cu2_))hppm z9cwr9QMUE=TOQ2WtHPV}U~)sV@ib10Ag~ARMLw$q8q;WNTp}yv+_=Tc%B=Z#^VTBe zkvAHH?h~;T2x$?z;_9FuO73f7PS0*TltS+-!G4)s>chNDaV`JOvv%IN*#q_N?FTDt zeqY+gz31x4l`8_BJDFM=w|DJQ9lxa&BW`uq8x=GX_v9tN zGmHf>jcGI1-`*al-%XTb%1q3WVF{ZCWwQ2+1%*Xq8X7bcDH3uofpH-I>D}y)`&7VN za7z42sK91KAf-ZOSHRG7@hM+p-< zRQ%ZnXI8ixFWGgLq`xfQb7a4CLv(!YcJN>jWc7f?TJeVUou@p4BA@bpYHk0qDdM~N z>iWLTwiAPD@-`XE7BWenW9U1UOS&TB$`|K-B#PR#I-a@B3ioy`DQF8;WYcpbh6(VM zjVYTyn#?Qpmwl!;mfz0SJU7bQuC-&W(}pqnG3^klT9_aB$%k=0l;I@lM=kGgZgjQ> z9135+;n8mzw>LiQ0c+za^zvf~wJ+dI@J{mFC`g`4%F32ac6XzC4m^20H7mNOpvF{y z7+F<4mO}{NIB*_tLYtqG`fvtd#@l1k11F7mJ}-Hd&9>&vSmh;r^+?`Y+Bqe%3W4KW=Zbau_zhy(v~ZwC7a%n?rnF zhZ+`jB3|uZGA`m*IA1omgU)23@t}6;1DfV*9HuYtoX;SXXTO&q=!(u=yfVb`B~oRS zm;U1OvuW?RcQcyki!_yK^N^pbJRkOCAG(yTam8Bg?B4dKxe5)z`WCBSG9i#+nl7*( z$u#8sd4nui7%^+so|bJ=OnH1Aa?FJ{Sa1&0iKy`&tbFUSAtLlbN;41>{U2t#$LPC@ zgN+0jz@WYh?FLu~xm>&V?mY?b92Ud$Wbs_^crbh$=H6g zk8j~n7G&_be3_Zwe*BYB`URfdct6da^Yceyj`mI)XAK?mqumC3pbY2bg1HaS*!&>J zgm!qnCh@IYplCOM*}t~$we-D36@tmtyXdNmZzk5UR;WH|HHr1S6cH{`pjKbXUGiw} z?ZqJvL-i&Jrj%du32fpamYwon_Qp<2ZmYDXDT=G`JC>d^yK)(hj|aiH>SGE5!jhLK zqW|NTcXWj)6*UX<^J>9CrA>?$w$vKq3Ysy?X^gCzIzO9UdGM=J-g#)4yCcS8Rq(4& zqVP_Zs6!x$@UEsmo>QdHG+bdn#V>6qPhk^nLVa$WX*ew`L9mRP@wU_JoyGzT;=x~2 z)X+80ruF2-EKhHBSsFa2DF|;LAZoxqRaD{V1$_|=8B+T=Hx!0Sx=4tN6XPnHh~cjM z-{3gF(Pnr#LHElhercDb72_;j!VCzNjEC7rEGF!1gn!iFqYQcMR@V5fpx4thxasHJ zeFYL1hJpoqRl%36^iKv&?_0i$HYKafO4jwt>FKR60tB7RuR1NAM&a{oe6kZ!lL=gs zOAm(VFnl%~7-P9GX5sbXo|J?dhyKyctc|%xtS@m|pNlr1oVfcy!}9soh=A%B-bp6g zce9Jga+UZyxzu@^D8#n>WV=@C&aGy-J^iw|?*;3t!h*+I$u7%SF zk=$grGkt9Q#uN~xzvR68gtxMUgGxm)dupTSew|P9T&H(5Nlbn9+|aT#MK!$5w0OyD zBGb@e3-*N+AC(t1Q?FEBL~u}!nHKAYtUBLKAUR-FW7vfWWk?kk_N$jK?YgX67D_eT z#=vLNbaq}r`KH97Zf9qA@!~~XmZjwun}|Hh<=^C>Xf70V;E$&dM1XuqK*Hb+>CE}B z`JsFyqzcU~-{HfU?`+w;8Mx^XFyjy&k-_-Q()ow8vNl05s~oq*`=H2)-0ODpgKlo~ z*?Y?6AJlHx;pUolJ%r`L8s(+mRNStM#Ge%NheXs&y$m@i>)PiBDb}v*F0S2GtU|lE zb%z_@yzQ~SmW8hJXa0-zKK9>4{75ELEq{g)nQRUmltegSU|=?gDJF^t>^_EUdXT9_ znD60F)UTLKq}Wn2EvVRmm5aZtiqC53ZTdUuv#LEVg;KK0M15Cjm7i9UW?770idINf z9LaoyNm{;WXE@nK@Xsr*r??Wb&l(I-BzaH%KAkC3GC7Wo1APyn1Hhw^Vta97pRBwy z{gMCtjH~Yz8fnAJ8EwP9t}`PsgI^}r{u!rC-uf@cL|2>FnsIxw+AQFHfw&Jo9>N)_ z5{_9I$j-r1PyRk8PO$9E!0jSljfs(WcO^zuu=ufugb2Gk4g)X^EV29ev7a0(E+_z3 zP!dG>Rm_B8LuL!IyDvO56P1=v6Xr4)0Ww0~1(NBoMlwj;0IsMvZ(aZ$k7nb;doh^t z!%DTl{tlY_Xf99*7*O%Udvug*zZ(}H9}&!eL0h=Pa3V^z6oWz>qz#0hFS`PX#4Kia z6TAWtZncuK5CM((Sp*!Zz~qeS!AazZ&zIe1A?*pq)rG)5;cs??*9&&miT11!kP$|9 z-7zru_R*aRCl`hiY&t8frH-KcgO!UpJrSS+4_qSHfKz3ks7*3kzZ_h5LOTZy-?SB; zG)OS1$vm{Uk@`P(fI@gEVn0UkOZbIg_l>Uda&s?EmTZHC=`S3dn68u*sUgy;$|Os7 zq{RUm1dLurR|P|=z<>Zk27S(QAfG`q7JshzTwt?gLPeGwuNFWpxp=uuMdf zq3?6TX7uzUChU+?U@Lpdi|dCk&^Zau2GAN9J+8T2bjc@?ggbARApb=h&$EF6{Ipw+ z^hYfHOzf6b!DX^Jay56T9Z$F#Kn>I-knHtfEU`*=ru`b1rP$+0n7y;>(Bv^$6mu54rdV{ zR_x>=*@wN}nl*5+e!>ATrUymFwM_)Q0;!e7Xwpd}f~0{JB6;)sdn@N(A&?KWu*l}P ztUBui5+r+grMIdQloeb!7+%>UkrIq@E;pnz6J`#m?FhF5bp68A6ZFONrTorQ-tZfN z2m{YA84D}hY624&1%-0loOtCBEy;dJRyMO@pnbL5D5H(wX|R5(fejhja1VF)LteL$ z&=Le@vX&m>?V{e7j6LmYQXgP!B)5SOz3&mKS_7QM>g_LhfmgMy15s33mq@WlmT zk{zrjjD9g^^3hs=bqcm=3=7~NxSgIJBJB^Cz~^X*0HB282M9l&qYlMXoeVj(7mx@N z*7I|IxG%)o1F_os8OoRA*PhaT4BvY9OHEB$N{TW-5cq>zokWTm4gxHQ2>bq5*fq}j zu~*(k#X{&>@Jt~(03*uAZWu-qw!jb|?t5H=IXo3BfI(0h_!a!v70?@ln{ofcr4xvw zM?dYopJ}xnR9r>MDR}NS^V=-Wn0c4(U}5P7MTZ=S+xDueA5|von?=RMI#-54rybHR zUJRi9fj5%s`{UDcp_zpGy>)*!L|6G4Ra%u0p(-Y7Z?kl%m6q~E{%XKOCfC;r8GY&DAD znL$?Kt@dIR)7NTtO9L@$?!FDB*F~*KfBL+wnf#W z4j{D(cfR!VJKzRGVbggH>=DQmqm2gUN)s^#QWEVCtb1us9OB-k2i$H_&)<1w{LW={ zrLC<^Sr!8aLC`V4yaXkIARtRqVz*MIZU1#s8gh;?N5Xe-UnTh@_^ob4O7^l3&V>}o zUNEyE)4@Y$i{>9h)LMLS5K+yr1>qst4CseDI}Rdnc1}+CJHu6T9#opFvNy(+yQe0c zuH6H0jM*qY9Ez*MICmgdv73sHm!C1U;uZTJH!cJ>4zq0GPE%Eeww?D?_MKj2V7jv# zHEjauXqMfBFZ?s^Fl4u$1G7gx+bfJzH8)+=}D7PJoAaSEL7raZ?+nN zUIr0FIvzoSa86Wb38vs9{o~@jx-zi}N6e6s{b5*HWa62GL8GxMEy6J`@C3py3-%lG zd&#t4!Psu}eFqi?<3-HF1$pdB+B)xc@#JdMq`Lo)Z}RXk5tnV*1@Iq{6W|me_R@#x$L;shj zg_9B_yfpoMEf+<7{VpUha=5*O4|T~jSoh&mZ-WxkkX!*xptL6r5*@;ORsg~r)=zL` z2|YXBnqCr~c-s=ih_ok)Atp)Zia4A8*n&)Caq^Vemc9 zT>cAh-cNO7FGAIT2-)IQoQA``Cqjj-3@~no z&^wmUY(_|^$0@zTECjW>w=Q-Yglgjuc7BJ`5@wX8pFW|$A~NN`eo=8LX1tMm8TASl z|2L#KmKrH2E5k0j9}0Ft=9a(n8-zp1h2V2D`I52Q@ zWT$Ot<$3sFvJ3-vDF3>$Ej#d8QQPj=xf46K2;K&2iTs2U_~@X6LNpmt)!%qLA4Tw} z;^H#KVwUsAY=#@n@K%V;5EM-Oum|;Ca0_#uFbY>+IzrUGn9}5x8f~Vcx<1DhK3-LI zTys~cNk&t|E$n<9>slt5_D>GhAKzHpZ;ESJIVcl#$dA2_FXoWbU5fknYwQKwYwUCP zXLY>xW`XZfR{U|ow`*{4FnjA?!mP8>>UOu=Gy5>ZU;F z$`eR13xq-}$2;FyGHw6QkxsG1%tm>y;Z!-|Uh|Dh17^?5BVBa0=I;Hyc_1{A}&h{}w#7|2HZ9l<0q(>YM)G_sRdG&2;-O z2mk;7#VG#Yyo{ddDeEX|pI}?g-n!ySd_a8yfmhw>sA8a7JosZir zFm7%-$Sg?*)+mAko7LvI2y7+^o_`~>6t;x(=MohO-j<^Erl4zZlC%=rl;LAzv3R}=HZboCUrquyQP|cn@*qtt&sa6xjzfq z)#iULu|cs?Q+T$r`HIzlW%c=t@gw*TP^ufD_2!MB{y+xPzZg|3>n9K#`fDO4T8S+T zPmLnwEhN?uA~H)l=D!cfuCSdLq_$fKwHR9nb$}!XT7Etp0Uv&NgIPOz-eE?$>vuxq z8je*z9jhw(^Jr6?3Nh_HBxfnq0bAJul=#^+f68V2v0vcXfQKD zs7y92{#G;v6#^oZ?%Hk~DeY@YDS-*c0n~P_A|5S(DKcuRx%E zg7B6toIUX8@=#oA@nWM45d@8oaJ2?PCG80arOSRGjBiU4$pP#>6uk#PuEkXV_+ztt zjZU__x*9vT70w}?-s}oE)zwi0p|lkT4H}E0S$yZ83-n$!)A+$T%A-#6lc-LqSRnxZ zIn#(?0K95>Sw9i2FSe{=@c02}zmbH^$Z!zq*(Dgo6M&&hFF{KPJ(+QjY@-uM1XCh7 zOmu7&X3CP}wwlf-cTkwlmWieU7y&LI?FmLvRfJ@BRoI_*Kw+8cv(rs0scB`ugv7U@ zL+DW+zXwW&gP$2x`#SM`xROvD@@<7Z?kd@3H4Oz8=q)O$Cl4~{-{c-_^Lo`>hZix|Em&Z{lsA#n>( zw1tL?z?jU~IF|MRpgD*PND-eQz$HSyu$-2D`m3VTy-MzdZZ-V{PO|0wGm^CQ(~|Bb-sYr#re=gka7t8$duGm*FjNS@rv7 zLb2R6xL;DiFHR-Q+~uN%#!u&GNawjTy7X=!e55w&A=&!x0*h$E0%LI)WG%ZGX+t83 z@V3*_)BlG3=ge3~%WUcm0E%)s-RL9vS@Hzlrz@%p91JMj3PW(rFM6xp;UoC7sbiBkxr2t?-{;|Gp*D=)s#h ziwJfF_yh?NUp6R&pc3tY7Umy60rKw&7Pw->%fqTN2tWKL{zHehOFIF74CkO!=4iO$E3O3>`PDR@ zN}`)QcJ&O_79hI2cQe@)KytL^l)9j%mJVkRaxWfnLd?V*PV?uNf8W$@M+3uL;%-Cf zY$fdp*+1&Bx?mTgqhS*zw5}MOo`m_|={;W2OXIf185elwo*CHp(Z!_u818+q-PpKU z)AJ_p1W$Kt!uc#&w+)GM?@~_J`CrwPQ4V9+&ht}l!yIl>QjAsV?;F9SxZYxD1CS~ zH9EfXK1aXXTUN6P;+*UOH@)QlZ~?Z;sA7dw^~1NscgOCVM(UdWO$;}V>{&Y!!*(h2 z9z!w?fTv{~{*VY(RCyr-4~6|)8EkSV0ZB9R0?N$6cT_uG6diayCMhhCch0Q!1t-f1wy;E6Az9ekQL1|! zJml}!)YG*z`-oce%d6PwxrX}Z8TH2gv>5GYIX=04+Sb^?CCfWMB4I2nx+0&7rrU}C zX_$X%(-u~lH&P1Yk>6TN=KNO z_O>YQ8GOb1Ju_oYF|1KM>rlk4SBSP>7#gCXXe(t40;;jSPB_NY; z-yV}ybECyBjP@A9LIw8xp~kH1tK5f9p3|}^ijz~*)bZO<@==bHT}k!qF#XPk=DHrq z_7zc5lex9n92cWi#LLDT=+%@7(Lw5Oe~=A93h!XSgM16rqwz9|_00CRQ9Ws7m-)o% zs5q5sE;>v(SDCYxf74XB{S@+2NfIfYtT{bRf6{ME-j@1|wmEgjFPe^DO;V4WId5p? zFX)ij0}PHAWXV^H{fYASCr4k63o9`xyF)_t zIBr)`Uw_&*&S(xgLxj(DHTTQ+vU_5v)hrW>bx6qpbGynNy&6}2vQ~yUtZy3WZJ-=X zWy&`EnAVXdY+G4Yy=t1Ynqyc$E`8-YxX%s26Eamy=K0<#8u_~-$LQ~>;Y=c$V1n8$ zm(-;Dk9y`URx=?u+I!q1;`==*nAxEDg{g^w3q~@yb3~&B*Gb6EEpxj1ZHELvL%}sN z!j}E?-SQ~Noxqm?_rW{iIJ|r?6w1=HYj3qcvzE;yW>*_dkC`Ef*u)8H$lR!X8IvtK z>GYHXGgK|Z!{4f_RV07ML>#8m&mH{i<^0G4-o-?`_0H$}*_N3LX`O{H7KMWcV-zV`vmZMj6@b=O(JF2`T0 z+deE>coj&L&3B}gHZkyj?z@It>!a#2wp5~ZFHWRHa_ffWZl<}AvgHR?HJ_x72`r7d zHf+i09T5g46w>Wa=ejz0_tQ zB)a1nO4NEs!z-e5dreO+cw3Z^lx)v3NK_qj3T$eh8kByU_*uI?Hk8BB zdG(MP>3C6QRg=}eA{mi8f`hSR=QaI{FP`{fAN0WB8{cs7XlLfN`luZxjNN(jeLo}* z+R`(8`_{`MIYjz6DM|jRzo9B9^U&vI2@u1X*DbD?{0TSS#r_M2R?i6;RG_GQcehQJ95&pdh6V)O!P4S zhj1t3AWS47Z{10MvVU$=SHJ6H$s@&(kXmFZ6vdf<^3=ClT^UJ}h3Z(d31i5bTWKGF zDD|n${+qvFA9sUi5tZD<(H_EJzNRKe(s?*B9(Eg}Y80pTKi^X6<03ieY+v&B^!b&( zW^Z}^>0zazmq{U7&YvsZZfEmc|NEm850ChecLd3(V=gJ`Mp!vPZ)O3Bj%RLn`#SdD1$5ZEWozS zD|V2M!B*N^UVhWsCznfY$6dotGaJ8r8gtJs?}2PRJN z-kWF(%l4@8Rn%U(7A=(aOj4Xf)4?X*?-qW%^H6u-uBP0jH}ZlIp!Dp_@i16ZR6bc# zl4k|7#-0ta%7|Twh-^PO@@h8du9%p{g^x=fIADYm5jY~ANr;xcqLol$ zA!XY_C<55J%j$2JF&`Zxqv&M`cZ@$ApPut$*VHt07&=&%O5~|wS?5Z0qSew*zDqfu zGtIEzT6X;BneKoyL8K78ErX6&_$B0*Sn8E@h5RjznIn3v z`&M5kcvQuUrCJxAui(*q#F-_1YVq0Ah@i7+_PUDS0<9cPUmVF?e*4L_EcNBjhgGU; zd)?-eQr4T0V-t5Cbg^wNgXmEqSX513v1xlz*JBYyunDQVH=2;$1JyMJ*-CnK*1##?!t-keF$+Ziuf{^bi0-jMMRgYGEg&_~9r*mu%k3|S1I{O;Ym*WF%w;}d(A zcA2T{J9g|CVMWX^HJ|8WmZ$jL@ZiBi>FIf!*8R>hlGjq(rFSLNs|zw%VkOrl9MnWF zw{kwv<%E0j%4`}z%LRCeDMO)Y-2q3Nn}FxQSWBn>^7U&dAXy|NbGV(;%*y_@D~Ci9 zVjy|Hxqr%NemptbT8h2KC2*F*vPXfF?`(^c-Ih^vZ>OaP9u5r7r?*|IPgMLt3S>Pr zEfrC8=)9iYdE=B9BdSRi>$ZBZ#bz3=Cd!hVc4Lu&m0VGroW9fgOMIIB0&N%<)0s_c z)0ZDGLmHfwq886ecR#RU?(IOIz4PIh^ce0u1GCR2rA|WYj>Ywq6A#`T!Ymgg5*Vkw1D_fRc67UU$JuM+ythMF3v$Im`$j5VOrOQ^ zRH9eY&}=&DNqZ{hwci#WI&qeypUUSS$4zRmhNWr5oSxY#&rgQGWMGh) zi(s|6Q-yJp(ga|y6 z=Ojt^@W)u?7}l0zc$KLMD?z`a=aD+#Hjv`>uA-~w+3j6ZU1YQH_A_1NJIaxUC4=UR zptHNjlZNpVetu5257t|~B@J&-9;&ZI&n~3r`61Hr%uWC5%^$&-!=P0vNS`1SfO}rG zdjx`69FxKzoTJGB5&`?P_}8zWut++ib3~22;*k2G8U>&xdK|1@~*0lyz4+$z@%P9k`PzqS4r7eL$+!rv1|TsBw%FV3R~ZDREmc*WiI2PzdW9 zXO6SIrYAM8%kn>_uA5U&+{F}amVLzP9NW4X-#&dXyUVp71-2(X|KO9nJfb2CM*N}n zG!}m&X>j6|wlsnqDorJ#7PTlB%`c+1Si7=ZY1_d+ctAj-m-CdJT`qBFcwY^r9JJO8 z>GwWz;DKz+>giv4^+9PgtTL$9vNc#D)V4mCk_sM?%ZcREeOj8ihEnk8*(+6ix`!R) z>3yj79}Z)irIAs6{9L*rjov(ZTf~ds)=dtUrp_65?xb1X^1d~avgAIuxk~b`sun*` zk58!&%AC}k8LQ#4qWXev1S5xs*HYHvskDDtfsWq5VoYVWD^84z2gk;FW02yAF|04-`ZZw ztxsAfYL<9{lt2DqoPuDQ*-SIoZ0ZL}nW@ zNZPr-@wt?>V?K8G-Ds!9he0YY_}0E%e!zzj9?5d)9Dp?IZ5ykG8{&OEbU{#Q7T>{i zYx}VDFT=Jn{%w{$sQv=h#LfDs)V>X!OqjRpV!Ka3@3iJ+H%osRCN{F_yZLrgim^gh z?zGeESZRx>OLii&d;)`UU*OMX%Fl*-ug+@u7XM0MDhar(ugA8pFdhENgAk#k1Wj)S)T?y)o ziCtnLpH98I5*hs7Qd!cd@R}#(Z;QY?AUVrEQ_Tq>Nxc>S-jnl|Hs+YG3h&ZOONp@{ zS{%x}W?rmJ)6~e8o$~5m&2Q)b~c^DO{pzK`*B)YjL}65Zd2=i zRcEOBmL@eX=tt2w;QiGOobcU zT#xUe#xcig-&t$5k!E<%hN0;6nJ-qGXv~}JzHco-Y7bdcCGG1=_0CU@yba>s zmMySMNYW<^=W#s=&&GnCFY8Pjvz<)t?e>ms`~8;hpVNQ5mS?2y6gDaqn-}OeU?AdM$fL;8_{bn^JaRGCVQ2EM7ee>$#gZ6J7X_zECor*MYn~ZiHl{sX)EnWO%^QwrNANxSy=8&|z(Mvp1 zTx+wJrV923-DPnygvGnGtD2Z6A0D!u$pkH!O22zb82lg1ESw8Ud5 zdy3#I1zfl}C$o1y&jkRBZB!_12WM z*m9J+B%eEHM(gwJbc^B0F3onmtF5-#H}6q8~iCG1_RRd%k}1DO2G-C3sDd z8#r>1Q4QMs^2TpcZxf?C+1+Sw^^jOyi;7>R{4n>S+_vL6>-}Ga8gwYAb@dM$mB?IB z%nt}&7@}`nJ*S~|Igm$4kdG&F`WldAgP4uw2ZVTTrrKw6NG?AZK1J?Rb&6T8*MD!| zW-5sD`^7e_X<;cSV(+&)FKl#K9Xz+I^y)F}C)ig#s25LYYVz*(F24#370BYIVlH1! zUHBq9h%32E76Hw$`lv8&w{r88Hy5HGK4g3H zIeYQ@eyPE=Dg3S+i5nF@<=howG~8vr>+zZv?laOpTesIe39{O&v*Xs~HFmv9Z$7wK z^=uM=B&bG>}X(e><-KG4@Y{H$0j_HU8O(NoF9XlpsL~>a{HU zIdi(I{v8fTly(q4L6$-xfM46Axx7NGyABQ6uATqu68ByGiGjp7NZy1jTEF)_{5$A; z148?sDZ~HD$QMyPiX3+=CeXt|0TO2E!s1&Hha04-;QO{x5ixB5#b6n{usq>1q0wTJ z5gQ*5gn>w=!VCx~0tOZxB_$<{%%#@lKYsk!K(!m(knzO$a6+4j))+^`GO%Gqzedxq z=rduOL=aBlxC81e4%wqbJg%YhNjA1C7*C=idg`~^+Zftau*cy!pn(w$sPcpJ>gNt_!(0 zFI!rsEEi)o3tHBD;8U7#iU$_+lnf{a=5DR4*M&lU|BIdj9WSqbo&XPz66H~|`qwZm zg%39LN%Ar>jcMU7>3eYV|wKcha-$`k+=ynEZn0)kOslCh7HoicejnqhlAw zJbJNd1Ry>!bV|`0K?^j28ReHRU*KXx)H^^$R1Q{|{324)kG+CH9zhqvRg+co^7e+Y zo#X&mXaA-Fu_M}B0WC4MYm6=+__5Z|-enA^TiO%Mqv7cTxp!>-+?G?P9a5!oK?!I5 zgy;ec^A3WwOsEs!c#F|1Fd6V1?$AB@Kk~2{&XP!r;GR8&QbKmc0>H0Uk~NAl*eIRD zX2-68={{ax4@7A7a&V=;UOk4~Ac?6@I{`rYv7_BV^}P(MYcEgFFO`*|FrB8A_Ir&p zlah6G=+q;quyGwg&ieF8M&Dq)?SI`9(=%9B_jc3~lPfHyr#=V^2KEfC3FVe8x7BPh z9hn>6iVhfNGdZ*b2a@3|gU@FW9`a7$P-PUpu}iuWBvchuG}GxTen{ZBf!>;O3bQW} z-dXy6`^4__E3tk;p*^NIx^5Hl5unD4X$9}yofeS^zTS47l7j!^9lZ$O{1NwyUrek3 zq(ZQ)=bO@;;lD=0V@f#Y3=@qwjWAXQB`ZoTffu9gSxMeaD{izX$>#PMfMFgQ4%ekw zdWmH49}^R|OV7X*q{n#?<4(5|Z~$2@MgtE$#BOPD0vd}z^lIDb21Q8BKX0bzbKBpo z&<8#YtvK#L>lFhp4+REbOu`KgG8Y$kA`mMTs9x&GKSbm`3=G_EZ#W0Uc+CWaG76$& zZpNHu9P#~m=g9_(%&REu8ltdO$`tCz-MM`mAr}ymh@YYFblDy(F8XIlMXHGmyn^!Y z8YmA}K2!f=W(Tj7bnLB|jlt`Z$dOg~b*)+3Me985=eSvAu+d}%+^ssgRuvG~Cov_>F6RC&B^N&NeXmFq0m zN3~fX9wQ%cwWcPWG#ENKrs3*r1*df29?;sHy!{C#8>6FfLL$lqL(U2Gvz2Vc->Y;=D{wcgYH_^rouC;deIf=4>omWCv({DRMN z+3cHiSm{g{N*-8ld=(rLY^hC=yLdPlW4B*8b!geaSN!oJDRTd0Zxs<(LZIn{%mfcY z6Z{)6!>i{Ps5h@A;K}oE?|^A+*H>)>=t?Y1Ii<-BuHk^S0yq`IHpDsNiE>>WFT-Ar z%q<*9An(ekJ;j+vz??GovE<+m)*x*S^G;+if=U1FioLN?X z=NlZT;D>sxkpF;X4#-J+IS#^2;A7iFYx;cq`k)$&)7+uLw>9cJ(5 zd?UEUQuUNGIWyn?}!|(HzdW`bs7I1)UV3kTRW+5oZyWudB+pC{!(gmT| z7r?sxuR|d#Hnc5=o{5m^z`YO@4fse$PpPCqj|ZfEFtZqk{?%}YDIzGlhx@%1I+GouhLGhF2`QE2+}q)~A%S+Fa`!o`PBT`JUK=o{v6bI+e&uO{Uf-R^2M z!-d4|*afDudDhjdS83TH%scfj`|oRKk8{(M`7m?4BllSLch|g?@UVo;s(7(mQ0Bh) zNKQ%F_&!K89`UrWCCN5w;xLw>R-`Ht2g~CSYpquf3saz|6A<& z|Nqke+pb2RGYi!%uKiv=2I+2HdCVE-A^$yu+Bvy<t`-6RbsFqcg5BUjq`D=?$6G5(BxxX@ z02Z7nQDf?ShyEept)=^y<@a%_@zQNkm`@bL78o6j?`~0S=8!^S>jlA`8a*^bSg5$6 z#1M49-2;Dqa+Y~8-381$eI+LDrqRQIQngTR5jX_GMl%Ab z#|YKPr_C)8X%orIC$~rf3hc=}Px?4P_KKVpWBBu@-q?nRoIoti1Dv6xtKs{&xjRs$ zGhQya|8j@-BC;NInE{ znTyb}3o%uoOU<(#ypocRqX5}-$}vvZtUc(!hy;l%e;_`gjWys*VE6Oq&w)L4|NMy~ z2|o=#3Yv?U=x7)s@xdP6qfDC~gK(Rb<)z@@V6Xr&e1O;+p5RDnriGXVDsgKBk3nq( z_Az*h6M2Kl-d?;e=lf=uGh13i z5Pc;!VbeJj11gG&X6X-pmjYGg<%BC57g48(?9f7u@>|Uc6_^Elk>s|VQ&l|(%L(>V zz}4WM%dGYB^XK`6juk_QoQ!RJKd6-(mH6mO%k}6%@r9<0k#SoB=d?gd>>;9ge&Rs7LcRd-ojR;TgB3xs`!M!0eJ@ z7Kn4Psi7ek6!Byz(oj2^cAnS?@r8r){X3G#J4BwZ2Q_HtXb7(>N{sOq*iv1C!2dk# z7h<~{PE>+$gxCLg5G@QZXsA8KgfPAvEz>48skUG(a3QfeO_9O&%IJ|&;AQU*@XgrG zcO1C^-Nj5Jk^le*-#Wq2=IRcs zaY;E9tkaE_n(YC+K~ZCUQ3GUD-SkuZ-23)PLR*3d2EuuqvKu{7x*?+lKH<>Bg4v_; z>>xW@C*3lH9l4_NODcovr3}m!csT=mLesp0v~-9TQQ1mO)vFUaZRMs}vt_u=g`DM5 z@FRkX;Q#wO>m(I56&m~v>}R>^@KqZUe*zm2M%VwJ*80Cy4F;1m3Hvt7n(H^zv*Jr47N#z09VRmWmbI4?DDTj>42wB_YEYf_s7-4cq}?IE3bR0~pR2 z$YL?I!5M-=!0?n#Sd7&_t{)1by8t22Kp@!s@p>?p%rt&N8UYxC`dls7r&2c9);UhLpszl7h&?P7J5{Ik`5x3N&B8|f$}^JC3qI`?0);}Hprq}SMGAhLDjfVfVt9r@~UH@E2_|B zVF`c@BqF7+(3F&P6A1vOqQH`C#88zJTHl<)y_?MxZ{4UV`Au1kzR-}Yr3ICDKdvUQ81NEK2VQ2}tm(G& z1x+TkFpTMMet)xyV0ybo?I4b-zUldtlTo|g&+kqoA82c_68u+^Tln|NwMI+NRZFf; z>GF782s7?e@`~Jzh&UKiAoI*WzgByoj@p9`58|UEA@~kyCKpPqT-CZswe{OsX4;JY zSp#Y#XX(HzHfshyeEcInj2Gu%X8=CFP#d;TsI-w_Rro=gWj_y(8ZDlGxMYOf4QZq& z0auFJ0YPkt)6J{)v0bcUe1Gn&L`B&8caI;_nax3yI~}itUKQVbQ+5P+3IGP}=7u(F zH6dCPXw7wKhv$o#e6E0Oi|%>=&hfByzyY>TVIH4D|Fm&K)m<@B{s|;h`B==n_lsv8 z$mumiQ}f@KeTevWcK|!6drT|g#gUp@fmV`aeUo}F7aJrgLzlpWK>uqKHmG`gCVm=X z-t6ZsyZ(1?^E-DOmc$#_UFZ9`Wz|B2c}JiY;TNS8E=JZE(@#9X<>A3w(w6a^n_WpD|bjDZt-O>?*!h}YS z%Mms7nqD0nIV4U$c<}fhr`My-qRj~(4r~lLx$lQQsGjgL%Aj9hX0x zP`<G$USjp(dJdZC~`G0 zAgpmPVF1?Z>-Krv+cMZDD%0EJPEYz+;pU5NNJfbcd0T-nu-ms!v|}25FTW_#qGFi8 z8A^{HGpXfts+e*tq~)gEO!a71QW-<}Fz@(IQ0G#`qpm!aE#60p^)_qsy5IK}>Imde z8K^s;&J`f{Ek-wF<>&g7VHK>R!Y7LCp0!Cga${n6W2j*I?pdWC6E#7e6aYVceE#z~ zS6+Gb9gJAlMA|PiS;sScFZi5m@NoDv`$VQ9zdH5(RIOtwEyj%&Uuu@csB+6VYT2^g63Hl4w9kxeTquCDXJM*=%k zIBHFWqPl*J41UX3JmsEp;qIoR+%y1fE%+f1a|IZoECN?yz`#mcHU|ZVL3sDEkak-x zAc00=HP$oIvStz^BQE>iw zZJhA8skl!PNlSy-=P1L~%f(I^mB!1NA13bI4y5+XejPDTq6(&!})PCDcY( ze4?W2p{D0#^9QLY-I*S{kZ(S|dunC!Jf_@VQ&awu;;y0dQZMJEn0mu( zJ6W)gVdQbjfJOMxc+DQE+n%d!7R59oMKvzuqV8W);_-TlbFp<`wn0u%hU)sNt{}U; zW=g;SFg-CJ<=zWL8^9tUjVRJ21q3R~(_X#=_}~Ck#z(4N;dfn(^9B@&+~t(F%f8-p zo9XH4ouSB)iyFSx7!UG(0qSw!5<8_`C5|Yud_AXMAi-VMro&psQQi?Qn$g>qTQ)ya zoMxsyTIT85=FEdYM4o;7Wcf$GrbQv#;gcue=j*9WVkX~0!qTDEf@YWL+hvy0-Iqq; zh>yo7Y{Rb^c*-Rzq=SLlaH0ZH66b+b1JrAUb1*Hp_3i(hUPb#1s;MQ@K8$S9YcwsZ z9PBX5h z+SkWV#Zmgg=X?u?zORUC?+~){?$PO5^O((VqWn&eMOqL`X00=M=dYhxo-;SrBq_h} zB@epQ@Gl3BmuLTH7ctK0YBFMDUpCXMJ zN1mQZ2#eQGZsKoaANct?VLn7dXKUsqUw^W=aK{eQrYjC&oY~c`t~wc$IqI4_UOKt> z-e_xX6<+OSF=trugv<5I5?L<8qEp%Gr7&N(b4{#Xj(XpL78lNXx1DfQ zG;0#z<|UFQFt}#*DKo$}tb#CEus@%lUFh!V0n?|fj-4XsR2AkVEm-yGv)LoI4x8yj z`L5eX+9LK~o-l~)=~sbB0wjEup_QmHyez9$TUQ4U<=)w6keiskgdt1u35#4z9?@_+ z`Cs!O;$)3=I^k^H}!{Rt{#KA=QoPv7L2YOVXFU> zXLoq*3WJ-9n6q3)NiJKQCgZQXEqcEa^+&5+?Ntw$mA27Wgl`WyJK}wcw=u9Rg(N>? z)_Q(%f?ZqS9ltA_#c$E>EY?W6v^bHDoi;J?%Fnytz3bH!WQvrUiy)tZqN_xhiEH=P z8^lY!m!)x{mu*HE=aIVSi72tu)zmt3ACsa;x~{xOvvrjMdwW85E311l+FzoyVRl1o zo~F5|Bv+P-SJ0e}!n?h1%2C+hQko9Xc-Ueuq0TEp>aSO%u~0~pp%cGE!8o-w5bKgW zzh2B%A_4-UD-AHwgZ(}DwSjNXr)D3#aq852pntCeR}lJS-kPQ4@;MCvC%*v-OB3F&NDP%NG4PO`B=i{C8Av-wSCyJ=1SwG(Nb{5#44JQc{t>LpFVX zOI3y>y>Gs8)77JPJ-46g#fv(Zo*d6S*P;^QlAV6V$uPs(QcBgR+Cs9iO-`VI{#Nn% z#dHy)#fcq^g75gGi)mK9S@)2vm4rJZ#(1R_IabwgpRTc2*3#0lAXRNsA7B>D#6yShr3bO}#|dp}H~i!jc}j9W*#rhhOvOY~?QV|di$`xC-U6{u9O z$qZv+6|aX|Iq<#RA59x^soB{(flymyhG->(fx3*=$%76SpB{{csd3eq;Q(~Dz|rT! zx&rYk;l0WD!@rhNY_46C2=w7AYfT&mvvUO1H0#U zrQnhyvm~LeUxYAn?kbflu_yY`BU=ljx>;s=j5<$-6daB#m^0&EzVg_*lIL!L5KrT} zkWpF#ig=_l=(&H#figr z-&VJ=br2FyOn!AO&)sXv$ucxXr`g1s@N@Oux2ifiU)!{B$=^}TmNBn_^F^A}vyI6~ zjCs97&2dNyk=%K#YVQ#m4>~k;sLkCF*9TDlM@|o>b}&zg6DS}Wua*Q65E1^9HsIy! z>jrIkVpXYGELs`R$1!C#$$<;8`P)0|j$HWyuewxaM>BK`{qR(0MqIru$3+)EP9i0p zu)=7P5D*xFO*^cx)3o27B(+;vvNA|!5}q%o63MIUI{BYRAhhs{GQgaS=&-KZ4F%kU0j4@sJFP*8$Y=Ig-z` z&TQpqjTY9<;5~3j+I5g-Eo<-zV@#t`U`>k4mVqM-%(;q+3S!I;jvYD{fL280I6xLq zY0Q?O`-6VGRlEh-WO1W+?!=G>HY)&!FtTI@IRTx$K>pBSAGy8m*Q>K&9tjK!JJTQr z%m_zpMyY`5$3vt@@$>UTWlF>T8;t_M5EvYcA0Ld+GaGlbRRkW`KE6*O50dnGiF_q6rwO+JCSgR^E5!uZVzMws**7gYjr~4d zvK}_Mlg>9dH4l1o_Pw7B0%Uhh&1EO*HEkcdJuYDzM}+Xt_Vc7Tri^Av%z6@i_XPuk zd!<~L--=6w?$P*ARwiC~TNJ|Ier(LR%uWWz%&e@xJI%V7)&#m8J1FPhu5P-!okK!m z(GxB%ou14jYr5=7)w04d$9HD*L9frPXXn&rcD(m61yY7%z(x~3-dq^(s>gWmz0)SOnzNs@E@DjKo zGA9-M7H`hK_|D;eQlFKdaTon7n|`Z@ObT8X=3l5h*|T=fsg9jZEj-C0zcxo(Ch{h| zyL&2Vv&EKDgQv5nqh0o@P+jwDD;v2>TJ!kFK*GyklY?gM-NlE4s7buoT#|McJHPEV z`$Q=|Of`EX+Lzbz!9()xJ%G0=dgGos-ui1~U2UjiM8&8lb6^yvP z#Ibv*iIJTu=lIQjme6rtI?&VdrWc&R?)b*KF{D0)EX_!on`7gJ69{GZ%ZM3Ti_R9| z4&Zlg${;^$T3HEX<{b`P;BtBBz4Mmp4W_vs#n9Hzhiz}s+`Rm4eTo`H%aURu*#Vaw zy}0jK?JfVM=<(Wt{4yY!rb!of&-neD${fmF^+YUN@)(SUl^bWbMyASe-NVWoScVeMGIRE`C80mP0HgIxs z{!A|ym{gS*v2M`R7S3MJXmqO&v~K-Le?5VMB+4~)KmXQs>x2^&+?DN4YHN?YyUk48 zad)~vlQjYESI)AZ`_b|#d)DRLiM_g43~CZbttV`ko`zQ}D>arswWyicv=mx%d|Ti> z0Y*~$n45fVdcH)C=*$++b-E*LBWc&$N}W3{M~d3$K2_}MuBoWxS-pIBT=Bzf_sL{^ z_qO0wFO+Ie2R85BSxhR&-irOE_^00$=TVx?n^^(;_5QfM27_c`46Nl3I+Ul!+<3_; z4?3A&gH33&d=OlV#P@)ZjOuQ{OQ>`t8PA#3E60R3f7z8iGd1$9C|FcBhb2OriiX{t zhW2o=(A(w$8J)*-AI8tv^V(AfQGW=%`dU;k*DcXudIwP$^Bt*sdZJQ5=}PYCVTWhD z<++(weg~zUD5tMm$i3kFqVi~brvGmI?CHrmZSlGMhud0{M{?6D3fY;)1Rs)dgPQ(s zA&q6xMk@FT0j}yZj_G-J#_tyl5N-$SNeN+R2Y*IP*8vPT6O+p2k{Nca`D##t0g{U8 z=7QUWq9jSa%OpBkMY*W5zw9QWTc0-;=5v?ahAkT0vQjQmeLoGnE2y%bU86`s*Xes^ zY_hGY;)4eVS8UT)I(kf^fB9;Pd75l~&fxXqMTkUX!kmW{hej{u4e8Y0Pf-F-l(o-r zyV>YhOnjs%uUGWQ-gX?Y*9rcj8Tpi^^7NrV`w+TFe|a)T_jCWxzaGWQt_>ycZ1QTP zIp^2HyL4UafZpF+fD6%VUeRLSQKl|pp^PdFEG2~uJ4sKl6DMUw53(J0r94Kr_tD}Z zxrn$n_UgO~bn65$P$A55B<@2%yK|$(gBXQ}WapK1ZrC#7fWh1e9FrTW{8+tkZXJXq zgi+>>q#q~lXzPVEIGu_oI{+}(xBF(B)U9Cd>a;a0eUyBS$czH*1}$FlvE9Wm)3ZZy zk+oRZq#zcS++Ak_@WafE#YM%6QQ?01#fuf`i;A)yT_M>wMy0O4p;EHBaxLo=Dh{xc z>?#`uFcM!3)25!?Y&7v+O2$uUuKQi>cV?N?#>2zs%Imaw$A=3T{lxbdikEBO^shv= z8}kCqPBxQp7mx06qog5|<6=G%1KXm_p8hh+t^Zo}fm*Nts(}eg@I{H{VQ=OMf_c*})>QHYOwJht>9dX@!TAV>Hyc!*cd8Ij3v3-1}&^ zwJ+LMGf;NJevqEDj<9f3xQQ)g$@IhkE(MR9SLj<*h4R4%xR+2TNxK7wxgl`=`Np5 zyuTiJIf@BSl_nb$2C~p|(y;jLj(NXoRv&@uTPjUs{avM!K1p&1FuT*8i8&bU_FO_J zO}9#9@?>qUX@&2H$W$416ODIwp61+~sOF3&pB*&3pr$3{aJlxj`Z*IybrX-;%QH=z zC%Qjz6z!@yX&nS}u|T)$qDc8I@$C=i3clPwwyS?k7onBUzC>QkY0L0`-Y9!xY^Ha= z(97D`rGE6)TmpTvsmlHI0x_k$celx^4(7bwUW9X~4s7zB#clV;6NF8o7}+I~rd;pe ziNUxSBToj__r^jZ^LjBchg=;_wO^4>tAe|f{)E_KhG+`cUp@}2|h&X5LAkaii%T?E~p40 zGs5@@RyqaJx*|eCnHS3f1&$-FuX|}RjpLM>X2bGiui~%y&v|*%Cr&)*?9_f=degG0 za=~&pY#^RINm?{}T}CWrmNgQjCyfj1b{uzcKCzBUu$9zo>+07VGz@2Rb$H@yD$o8HPQ zbK8yqG_3fM=9VEFc$rOZhpPSCKa+$O*07W{sR?MA!LXaQr62Qy3?%`2V6g4r6`7^IVYgur^@*0kYM9W;L;#3R4O2Gr$iQ2lE%ke*5?L z)hY^xA@Kl4fCJh#MSJ7XJHWmeBR|ZZ2$yXL@$sh%2jUYlNlBDSB47x;Co=l(!i~Hg z-r|7BU_#Xbc7a)PZVyUSnoXN*dsI{n3<}X^!ZBVO8Orz;U=(3A;)tdb9YzlP5kUhK z?*d`rf%}-ciX;U$58Jz(9<1Sr!6{hE zV8D=0V$ zs6!|HlERYc&B_}{$yodyFV_Y~B!Jf&Au_+5TTtK(Ph2=56I2&`n}K`tX!1dEhom+K zk1CT-52jjh!-DDsw{98SX2y1MbsfE?fUx93)lSN1H9;No7?yfrw}bW5lspsHAVCd=kR@??~Q6O z-2&5FQ1p{{$|o|aq{Ja({yjWLJrH64V?8@;aXsFiH|DtD(*xJAGfGOEHf-?YxSj(PKSp*TdQtx3Z!2+nV$OX z0!2#7!U0Oy$is2o(6AjoWam~=D{s6?O#EUjG(FH@2Cxl#z`&u47ca6o4IN^4&pQ}b zaR`R6#gG@MCN#hWh>dLp798^O^5g>hLwom@yycyQ(I}kWK|fu@g~AGd%E==nbOS*; z1Z~rSC!|5X$?$q_ms%XpCppGrfA+*%%(+NY5a5`{l3j*TZKDtH7+9RlfT)6?uta9i z3j~T6twXa+tz%!mE*;ke)#g?B#{<}iU_8A;=4*KbkJkizOgFGYsV5LhUyqGR1Z*Q< z=#&<`%YS(4N))U=X3GLKnsHdDGCCtR{HOT=~ux9L{E z*GRX<`|V(2GF8vO++Ku-L%|Q8NJ?D(eC3a-4udq|$qbel;t^0xWH_;dj`)wv<665o&;Yj>jB(d-7C`2V>iu{oh*v)%VL7gX1W%ni^;T{*BN~V8eLT z{B|qDx*;?SC{z=LaUYmO?5wqP;W%01W4gJved1N)Cug55%DL?%nfvqpEJ-LNn8M!$ zKZ^CA2ARpgE&V(W9Pm>rF_FkIn)KWyZ1$7O|C8ZZ;~S<;MWBJ1%2awt<&E7DXWR1U z6BT^Hw#|MDI>9O8xX`n}&}&4-R=NXD7Cw2=k_&CF{a6F<=ROu8u(l4iOJJz`-V&50qn2#BG-$ zy!=G+AuG6;rUO$#9`|ZfimC9F@f5UuS1w(mVNZr_P|sh9uYceqP;-ll+>c;;*{CTD ze)Vv3+@PYH$3)754kBT%= z*yuG9A4q8zKH!{$p^`;j|7%d}(S(H$B%{Y?U|?u@j(8JOq~lCYnr>P+21+Z$9}YNm z(EI}xi=z;shexlqXdt#y#L|K-%;BxxHeXX92hh(U3gjiH0)XJx74GF!z2BStZxvgO zJ=gA)m6tzSV6*zxQC&7t+qQ>rtCc`Q816aKl(*;Dv1eAfNZsJ$(yRUT3`?{APni1OYy-jEga{2Das7iS#ZqXt09SLJeRdF-{(OYjCEY9?J`wK_ zAjR=8TqWpBC=vwqbAadxLHkPlnZt+ET`6!6B`P8?+P-}JcnpFl4UKxJ90?s7LNc19 zg33xulS!0;E??=&j~}5ML)n@Kf)?ypq8m(!3KV&(5beQ+MRySHL=JeqFfk^4^p&Aq z$468^9!H1}4|u7|M$E%qk~3{b@Bu(oT?F$CHigKS#W}Sb@u>oMb}=k;25*louSM$n1CD>cUe0}y3g&0)s0>R9>T`|ptjWgdvA-n`&Kqy7+4gDY z3T%v~-4?sp?Aol#(8-OImVm5+k7VHY3VxS)-R&hJ41c!f_fSR_JolN*{^wNLbDAz|ADFb;We~^!x@;3sr9YIrh*$%7pEI(*CwvXQ|FZ)e z&zaffK9+=hK?Jp<2I|5*IICG0l>;_AYWsgovgtT!TDK|qh2yzI(TZm6@^9&4Sy>tR zX7gnXc@$2c-t&*`FAY1VteP{+KaRarf}OkE|7<+Rss0@e^*@N+|Nk%jf968f%!yG^ zDFN`9APCBk=Ap18**`|upKjo{ZCj|CPzf6g@fZ3${38{pF$PDBWKV~d|KQHHZCe&M z{Ea(6MSyYyzXV~Qp59=fJt+>AB}ji{`OX+vyevOj#hP?qImT>`Q3K>hZSCGbd#W5R zA)%*&u~grc{=I@~q>w z$NsGPF~$)Ux{!)1-XDJCJo<2f2bJgk{g8euqF4h_Xb!%1+44U?Lcg-pM~?kH#>-GW zxoo@3z27Txc}K`1u+;y)_Pm&NeNcm@o=B0#gkDNCzZ;7AafQEs0~OV+qq%N>y=*}9 zDv5p16SWd3-vpaF)##+7V;-GMU^k)tzm&Qhv95@L6&cY%%EfxMr$=jmjI(8I&6z()&~DCp>=P-bK3 z0FD&C37RCw3gITu?!{~vWYsn!9uT+>!~&=SkPwxhu|*jgTp}Er=yo6`dH!6&`sc^> z+quw`iPmZTKM4!I*1i3+-@M=-x4MIl>2-AUFc3BxE7-hw?f?}75(Yx9H(x~q{wnBkOp^;0r$~d4%n4GN@rFjlfg*-;)D^kU16N6BFA2J^@eQF1Oay zY`}toYe{q8g;4ZH!og={?n5MV9aSibw~U!fr?S=QK>#pR%eV`cE*b<-xy+lA!}!V( z9t%2!1$t*dB-c`M`N~m&sg*-@4j#WTz#sTSxPy`LGAC!2u!Q{ld7BJDsoTzV7CAUG zXR`l%=IXrWWRw2O>3nR*t{>-zNVmQNW%e`{L2UOuR!3tr0bnx!7R%uKgRE@0y8wtBJOMEksGGwc+mt5s;m(J`aU zP}p=lp#k4$5|1Uzp$qPJdq5L_>S>#dyN3q|Am*5AV2}mC!jGPnwH@FOX5L3iK#=Vjv7lnu*!;%RU?IpG!-l6CQTLI87%caXTYpHbgkMl=I9G0y9z` z2L@_MTYXgpx4iz2aEwmG6pxGH%^&aAQ*Z`QQ8$=74&RF0?^sxrm^cKe5KTWoCvmv{ zmT2()dH?UvECYA$ka=HLhOYHINDyc}@@LHfl>|r7ng4o1B~{f_j@Ucq}9aC zIK0K(sFx{;+0qD806qo3$oXgd87<$dHGg*GYHQZBbcQZd4QOs{laho_ z?A*S+E9VNl zW%X=EKLm9a^se<-9q0zo9xD3%0#@}e3F+~SS@Q>KWr?3;smm?ne>oOXhL$e9EWF1@ z3KF3Nt*Aq2aBwHnHwxk=%x;e1eKG9S_s^Akv$x*uPIAn`Qa~+(_`Vj4;)1wM4ah14Hj>OJMuC=fj!|(N2OoQ)zK^4I?Q-pz9dGRvTDG$>RJlgsiU)A;Pz%xKwRqsbzK(U$p*{QpVJZ@e^3 zfBi|~=>@M$CvPG7GqMgobTT&{K3@3n5i`G(A+F4#=UJ^Bj6NY@#ir8{_0JcTG?-*{ z9&g*uzD4P2*|dhE-L&oXWPjJf7jx4&Tp6dHC;4r(-Sur$Ib!q7)T!4>PS1kM2j$6mQ+jD~{+9v1l z{u8VX2CRPdUeaMRvRr2G@ejUwD{J=c%CdP`+?{dU~z3|Xd%P|`ao0^F&GRB32FK1AFvo0 zxgQTV+?MfoU?*2HL{ZPdED**e7;ZE^V52#UHG;_@stM$zrNB~Q^X5nYA@`#jhS_RL zl$mg4-&Dubr;FE@7yCm(5W0Bt#0iDY&Y-gQXHTCwV{Te;+TK56xvv@Ka|Hzw?C~4b0U7I#Y-}*sWiRpJdfIb!~%F0@_rXkhg037AAn?|S5_74fCNIPx2&ZyM5Ygl~O zxtdX%$=-M&{FP7pR6s4TS^Hy&pEG?}!X?=Shpd|hfz?e*9i<_pxYt^GIxoLH-b-$- zm>8ppRySjg5a_cVU3$@M^TUqW?TE4Kk^3cADQqm6ZDJp-%{EBOi*fDH>}#)loL;qT zaWU8MK$Ds4eg-yHp^l?xSv&F@qU(gjea$+g_ZY+;J*RW2|CcQOs>r(d`OUG(sJ#J- zq}C?UDBbM&Tfe%uewuL0{&-rGEABJr#OF-23_kw_9oC%!?}BaE?t>HIPwV(Ce*yGi z{2)j!T6%tKFy4&w4xSxkDW-Bl{YCV?Q2XcxCNTq@e0v^LxjBHPAbN_S4nh&l_E-NM zSVmw=Vc4;wp+g7vjvx*M1%-!8frVIKuhHYPZjWvtyljZ@*U+HrLF_E0IDI-w!Y%PW0g|1%`! z-gQ~<99$GSvcq`sm%1XxMD;Rqa-LlwSWud@2>6;&J-C@`Ance`WX&*Oe+^pvf^GF8 zKoFA~3qXS!WX_&_csEqbo{GIqIpdK0%ojIvx3BT?Vw!PIuKCm6=9Y2|=RC!}d3PAE z^u8G_3R@1pVyRYrF;v*$a{I#sk5}aKyR5C|p)842sxbzW85VDk`Yk;=IeF5mx{KU$ ziQ29Aj^WI_U0(L|dT;aWN5xNe42v(=Do!s7h|fBmtIU1DT~zN(?)#8RI&&aleMq)! z#-VK2n`3r->C*{OCv37$_r@18GdcT?m%~@CdgT#Wq&T^;Nvuq;qO?uK{x>swVAi(a z+9UEUNpKnTfhG|rxwN=W1YbESI30?qrhj?yTmV$0GaYUnT;e)pTiYqZ0bzcC*NH`5 zPZL^EoU&l6HdbwJIfjB6dSEw`fBed zv$4a{N_3d@#UqYdhPvbnf>W-9mDu%12jw08Yj8=h@kZDtSvBn1O?}C@w+S9dP$Z1a zYs8|XqtWE}jHFAoRKK;M#!&!a!SL`p{TJV?^k>?)$XJ;6lVpuoru?t89!go;a3MW% zK`*Z-hO38zmi_&@!s+If(^umq*!-Dsb?$~Y3j`dM^wF7|H2KEcA+mIJ)9G`SqQm9l z?lmGWwCoz?Cx>q8&D;N)0+ zAoA4Kt!2A@Ze?pzm6zSBy;ENPx!MVRdBa^dRO#q7=+>@!;zHfL_2gc|Pv4%xZ(F6`K6 z5#;mc(eA~IEZ*`P_i@p1ZOK(?!OVZp>S7f&5^_*V5m)~DH8l7W_&&lv@GTNaJ!DR2Lr zFnFq{;lq#GO7+hi^Sm18&RICj-7{T?)Z}opJ>Kr8o3b@nrp4j2&j`=WhKY$SQcn~! zoKrPFU!PjYei{(-CGPUt)j;*Lmgc>t%dfTw+NsEtKj)XS+P3=EDj&*HdO=>%IE8$C z6WGBH%Zm<-w7oq&u;D5&>^dZitSSh|3`QAqVDqzA-uY*zOno!jv|n?T`bHEIVKc-e zx5+%cXSdkXPMMpkLApA}7GBV*WJiTNnA}nQUE&T9eMFo!Uj?RTFfi*YC;XMtSVU_d zBXB0RTIN{KRtadAF1|i_4;VR-=PhhL2Y;n%Mu4!tAVFn87ojp05EwXu!5=1Xm=DES z%&L|p|BkRs*8fh7bki?A=qlFtRkizEU=Z2n^L9ID{_8KTx^CmoWDW=fNq-wQ47ivp zys4$1<)_as2o8$l2A_H?ymIcae^GVuso<8=jNh0mjtA$Lw2qQvL%PWp(>K0ke5Ors z-CXK)np?0u;eg0{VgIJxcG3ClOsg)pSC%%rF5R0}q+v5DZd=;6Zzz5uEjz-U>ijJY z4Y`Hr$p_Q4v6a|EXNH=)v;u%fhDi<%->oA;3sb8LoA(1MEjb>gbi2XSpxCugPZKqK z@M6N9hj;FbD(4<_zvZt9UL%5wW9+|k2r9vG`q zaHAcIFKz+eih1|0Rz=j%zdRt8B&fpmv1-(Aiu&0gn?8Btgms6B#^A>>^zBZ#l$hI~ z6Kl98dg#zN@`USyY;0^GT?+{hcLi~RAnFqh0Ql#0KY(2(r&|Dhhn48BZm1@Kfy#_7 zT2M&PZcAg(h#izn)7dBFF!ud~Qr+q8?+)@5YqWo#PC4w*rJ%O7@oM|=`Ee-17T#># zvA?`4426#PNOBoeQU_xPJXFlvbjVXC8N^Nh;s~oEITnC=s)>G`5*lK1R z=(+g?3Hj78z98gVhKAvEKVVw{M|+h6j0CA0qWkYVdFl$Rcg@-ZUWXw+ zNBq}%b{r1}d}$D*Du!Vg+Bog(any8WA3hY~JvAS_UxK9?(zsg3J2qH$zP!g8a?t3` z^3%`g76~nbRxFWl4rGOBXlc^G+t9PfB`gmhmf<{oDwG|EMCxYU@o|(?2>B$)LWJS05HAsw0rXG84;U+C7J)ZMk)n;G@_v>pS{o_ zU`})MEQ3xMR0!o3>y?C!x-{>fr{lrcd7D>SB`1Q}N z8#iol#0ZEO&|-7GctmjZj-BB&@}DOLcHP)mB&fMPrZC$n|3=oEDU(T8?L)j(zs@@c z-S|8vI{NM2_|vyz1@jr^+R;bkr2J~{_H8u=i_Hkm+tK&ao;WZ)vKqs zotdA`Nz#~w2Ui(DG=YZiXjRHG_)+tXx}{QL?V?`TwHwr{#Xp>FnAtrN^L%|ZR1}Lrs!Ze}yq1eS7 zlF$RiE%;v!lRf<8B4-S`Fw6xf0#<}dN=iibc0@!3VbKTQBLbpA=>wEfQ9;3~-gc0N z>RsOu&AISamNCiC-6lVICw|OaUVP>>9y%e>b#FXfo2|5Tu4qEhqh^(AbooBgq^f$< z?Vy)S@o&7P%W?V~W(xnkKT;JJKX(`pU2Upr-p5d!3~m(%$Gi9E&t95!$rZoG?);>) zE=nLGNRF%c%kZI6mWj`wL+Z9>blQ*7MO9)*NsPGw>;2X2gd|u3V)SLRoDWo460y9R zZSgIhj}F#;7~!I5($4lkd=p{3^6O3Y&z&rlADqf=ZFCWyJK+lRWo8t-5XbZEB^hxT?sKCb|v=hGJXCw{G{Ph(MG#_haH8O z_VKrWuTkxi`Qr2blALt;yS`Q9Yu#i|G0!Qy_L!%Q3O+x(u_dKrazG#h2l-(~0f#vS z8mhlyD>k`mzFt~ga1YV3n}n^W|N84MEm1fBAdKn{Yt@)7oO*W(q(s)_ud}n|)=ysw zIX{aaA6)P*9X@Siu39qKjOdJnNCED0rWbw@`GIZIgd9ibyPJOb~m9h(5 z7n}AcSt=SOp4{h5J=`>ETH`A0Ug}6xA|Wq%MTQx_Ujsqo>I*G6|8P2tZb^kRQ`f3BYhEc0>llmyCdWq zyl+A-jLBx<8%Rpbaw5dfKbk$56x!OqDoH)Du{MG2)j9Q6Q?FLCN(1|+sjUwm3^nDk zz2u8Cxi-koK#tbEBwVr7A<=m|-marc(`nz%KJ%+=PdOI@=I@vBwb;r@1;oE3jUB>{ zNx_!sp5~u=@nO*KDj=jqYrh3p_qYv8XU=$)nL0d-+z+OkUA7kC;+>vNj#9M)Y0v+1 zc54b?wMO}aMR2j(t5Ul0n$g&8#b+LCja(E|R4nIOV=e$!2Fkh zJ4x1F@bRkd&-4tf@V>W62T0k+ z!domj%twyON!7TBZT(0RcuQ48YSCZ&5kyex60pCb6R+_mo-azs-`7JVowfV zbIdD8r@v0qbf$a_KWn?xsn36?P3raoW!~BgGUi)0UN0% zXk24oe`C#1X6sJ(tO9C z96BVkCNtS;;TQ@3bp--d9`a%)tREJrV7G^1oX`yhrPlf1Yr69v4p9(^KCu4ePB5XHaNt|iXA^;8|m}>bq z##LwRFcfsmDiQ;~9JEs9o-vx%Z4XH zNm-UhshBiw-puN~`Fj238mVsoZ;^R=W3K5g`j!hUk}iX#fltZti#B%)_GRR)#`Rnp z*PvW2;EswH?|&m&+;sH*k-3H)swu`MP8^!2);jXNf<`va%?dJY9znsQ`&hL<4yx?8 z5mB6LXnXhA9gk+~lD6X6iv ztf6qQ1H?!WgY&zdGq27qJBZz2DdkNc2GSuQJ^O%XG`$xxWnaRQUwwbeo^f z<$qDveyyo4E5#bBsSz#QYAnsjS85%o)OLHp5Wqub^K)C&6zjav!O5Xom`;%_3=O33 zt~vi@Z&<-AX9v~x5FUZ04na14o--`=SMp4>5wZ= zWY5qpq;4K~!8uY8`cB^Mg-GtS*>$IdsW9W>f-|U@J69FW@7pj{#kk#FtVM9k_uAX@ zuT5Ie0icw3!8{{>saWL%k5?^rOVqXqRB4dEC&$arPejN=-U&uouMQlyYr)9`2mC}C8m0T2u2v-tXIZuHr+LC7h7bX~486gy4Vcxx^D_hF{Q+fD8{`Wyy)3^K+wZ#fRfJ~`WW zSzFfP$#d=ab)?K^bX$jL@5<#xzS959ALvzHfB!g-h=w{-PfGWov}*Lb!%y@$KM!$; zo%Y^r!5PP;=`XY8@=3URtai~dE>@Wxaj{YyKSZx#y_nHVC@;>O0T_U`L9#hN*$|HQzY|*- z&{aXap*pABvAoo9IxapWB7zL9Z*MOn270P%H0F)(RKGl{X_M4gFfnmmy7<|fH&nSs ztXnuv=-idF7N$NgrEouX4}W~Jv`E1t4)2_U!pSw14JTYl2{cO&wuCPnJWD&p__C-u zCHb%PS0CK(Ka*>^_JLeOa<|Ef+TfG9R7?K=7z*)VQEhy&w3><9`@^{zJ`ctX8}a%T zXEfZ&lg=j(i(ol9ksEu8D#(F)YpJu1`wptEiP&BHXOKj7>(IY*=(+xvkZ|{Z&2Xqf zV9O`~8k{CLo`4=r`5o}Q!Egq%ebJP9p<#55PT&)QQQt@y3~N9AGgdl&jc*gnEz4@5 z3XAN7_<;)w2)r$@#t;~d(W9v2h3@}^f#=*>`|=Z#{R)6iY~Tgqjnkbl>4KFWw}Ky! zcpmyvBnS}h@SxqQi~RY6w_e|3my>+?;>B?qFz_yD5ed!Nt;09Pm$v@-s^1y5xrcAx zx$|o^`=5{YBJK$Pi5vu!_9xDDae+uN(X-#ojOFk9pfwx3%5FZar zNxBO#ki>^0q(&B@f4A0>UI52niFB zk`4o*uo=7^5(}2enwl)FHRO0BRn)$~?e%cRsqV?Rw<+9HauJfNdI>4wF~PK4Y4j&=7*Z@7_K1i4<^A z0JeQU9&p1nkkG!XNG}oY#=xP#w4esIUVz>0b2lczj+jw{V*@&!J$?fs)75Yu<4;96 z4#a_cabQb1tokTeXxQjz*`c2x{vADTlQSor&gyUp3z6SRC{+-@LM%DlB23K@W$EwO zP8bQoXjB*CK)N5WtRVugK@Ku&=lb)-USMg(?*IJe_8Jd5jP@Iv=y&XZw@UlX)@xuF zphmU%Wi1Yp2;Kk1-kZi_*}iR~D%I6wNF*7f5y_YknO#K@G9+ZmJY`JMa1{|sBnm0X zEFzhekW^+e6Ot)&nZ4WD{rtP1wVw51t@UAjSijeYd*B+*^E{4YAGUqlxBVLm$M|GWmZuATv^rKN16o%QkIs9ek&IQD3comq5Kz9;v z-QdOqw!&Y%`!MIgXQ8bNflK^8e;zUr447raZkU|u{>6K!tzqSFZvd5nnLdWn-+S<2 z4+Rm7?{XJzSCLeU{SNh({EnXwSscMKl=DM3jYrV~VYPbDOb$|*AuDw=9I6Bc=HKi< z140V-#RffXAjEKA>9WfRLhJ*bAI=sA&qV`5E zoWq`X^v+~(^0C!0;cS+l_Fo}cW($k!hv3--1v8-4C?If(lz}u7N(YFNhlKm=I`Xth z_fRTL8bU(@>$zzuy(&F4BNlbzAkQ^!Ji69Z`=l8&GxLvv?7Za1FI`6Rva%Xd3jbYT zDYX0sc^iI?@ zOUAEj2M=r3uH@6Y#C|#!mL!2bT(U={b#}CjPmyyNWH+^PGC$TT2i8_7r#hSCFnntL zR~|8P&wU08SSVS#e8fga?dK*P5~jvx)ckZv8GwE80pOU-E-W0@;)i1idHk4<0ugPs zo8d)34i6+Rj+fFhX5PkN3TR|9$$@3j=waOc(y3R;yIso{<;6eZ^Rd4}sQ*5!&95Hj zA0da}Z@`k-2_0QooZ|3Vbb_!XSpJ~T9XyE6 z!E~5RjLqqfRTfqA0tyEcF&G!e!}b2=rtRp?^TFBrQ|mB&fSD$$Q4-k*0OkD9z)=8} zFtBBChoYbo8e03}oI|$Yi77Rts!F@;2 zpaj&MSig5286btH>1KQbuh7Ympt8Zo<~kZ5NMa4f?1=MF=>S>G&2R@jJu&}azylJ9 zXk~~5#e^IZM~rCzjJD3?>M2V+YI$EO4?<`e|1P1<0tNj1<=b+ui%UyZ;TmiQ*^c#= zxUx4wJQhFJKlXJ$|Ky{PfXz04-SqUasOVDQ%z9{7yU}o3yYY|rHkho2>BlugD1>Dz z@)g}nzke>h+az#@^$4M%`}DN^5ozgVpaaCD)!uL4%=CVK&FDq*AE7gfV{J{VJd<=s znMBXPKtPKpA0CQ@`g$fGFtbZ=M}(bLP@r7Q0HF4JuwGeXn_B$=cej5xQj%8Lu}Fxy z?!@#09Yp-0bS z*I23mLns3=kmz&*Nk_-S3U>j{zSZ4?zn^_|zCMnD0-YKhHv&#Tp#-&7L^8d-y-;N( z#8jIs4sz54^ zKBdY~ndEq@$Lt9-@^}7{_^AF*dr;8FuNFp(7iB8Er;8UFCF^3!>Ard#1JFZ zVLm(blaeCY{~dF1btALMzZ5k~xld!VKODg?#@qy-#NyGvRA4Gcouj`vLwORH#r{1; zC08$v$XYU~5l$`#aUvWWhIhbDSIvR;f*#SAxGMbe9|d#pIx!X`7AzM;ATz&zyE_TM zp+ot$Ezi{klxU=QAfNs-1L~NfhIQJ*@bDZMUV@(o7v=vHeP2KQdDB!*oPdsb(wrJ{ z92>C2;S0bjvq1y{O%`8t5dxRozGmIuyWc!sPThle4YPCkx!$9iB7j^7|P-j>Cj@sj{)Xe@KvF!B@f@tJN6B-dXW%6P?1}~t9lEZaak}+G3X^ixngCuLgu40qqm5#kdPr% zmJwQuHxV)rAfTguK8Wde$XVF8ZPV(d|98PRCslAMZK)@6ytdI_An+sf7SFuF2zvDZ zCQT*ZWpjy78bZPB6_$a;O~b%|n0quZ^5hkm;ox;8CF%Dw5sb!-EwUHT@m5B(l6o$| zZ|*r2J@S~%g}`*-7Y)%3mG}AI?{)17JW66CI@R>`4^ZBqbcql{PF|jEiX|yY0$<@x zRFqNNv_`6S?ATShrwAJ$%Z5s73%b4OzM*2x0)1mZ;bHj?g)cxl>^7YVYSqZBph*(K z1ATk6BA-pag8PRPpT^8OxiUalcAW)!Mn#Z*QVM2gV_)gQA1c@Nq zuP@OLFK!K>4?@Kq^uM@+rRs{jy^yKv>Jl^th<;Vr=OAyC`7fFAvTuz(Y=5`K>@J59 z*&k0XtiSL%(iLoMf^V03L(UK3JQ(LBF2EAlF{wyP)~;K3oalbWO`v8Te=4REgVhSh zi4Oa=o6&3p$%BwJU}W4~l_?$26ChB;ynXwNaJ_styPNu8=NW;sP$cyaM_oot42tiH z8j#OKXGzCC56xXJRa}z@^aWFl|L+0Z_t(Qi;jFlr6;{zR$U{Mm@;Yv z9Rr`6Is|o4fCuvtAh;0beiU-P-gDTamv(8AG7zSqHaoP_2Mp<@#l-_!5bO3D$*7p_ z2f4#C@{Ih~zmF0+{o;Q8IE{qVyM6%X${BwQ-QQR!0ndA&8Fs)CVV0;vi*@$UCPcNO z+t8ZamSlh}5X78^JbnMC(`XAdLjT3F)QNvh zp?zWnJH>V2p~A~Ek@3h`!J~;r3Wvw69K?z&GB_zI`wX&Ob60^Qd7C$KJwDw3u=Wrb z;wFi|s4!#=;D%t(iUjRNmYqA#%B#SfbRawYljMlr{MWj^NoQxEfPnoOJ!j#0wy_^8 z*G5@IzD8do`aDn(?g(=+HviH=&70snqTVC2bRH#OgRXnh3s2~F9Xyzp$3M(^43l=2Tg*z0jMguCD%s&|QT39Wm_;Au?7ECFc+xZ|tA! zJ)|vWf>TpdGi!6cEA#E&@5I-9Xw)Q3^4}Hv^euVAJ0LnW_t-9@;R4;@CQk&6*>b}S zLd{z`M%!$~+>@#HgdE%!AV2WD-Gobop5dvhf1`;!i$sEirs&{%#r?e@E-guG&!SkB zdbWTx-N@UsvOl|0CWc{P)AOg!^o$9w{To6aZOFZn?{rxDv}~fwctNPe`5-K*^ zQ?vcI75Qv?uIX7Pusse4=PnyTE)>Q1rJlszkZWWaCjU4OFq86Y6V=uDq(4@US3scJ zKF9agt#W86y?@{E+tTcil6GTCN(wd<_Qm?OYtbW`W8L_%B=w|2DcRB=HU;#K{(%9T zNG5uEup*cNXV%CzhQ(W9S&yQ@6X62V8#HvmIoU*Q*)pyu?RoaCb#|KGDdgAr87mpVO$VM{+m+0)U(zU|_-yAxu8+&{^xb>;-eqxR{Jqs%KcU!0Jd3Gk$gZPtSklU$HhS7k+t5{2Z}FAO z(C;;8szM?|j8{w@QqLN!QM`3PaR<>P7JU1_A zYiqm3+sh{EYR$vGJ~MBkGa#+`xQa^6c7)N0)IQU0N)S$b(l8nBkd&W4?^EU$ohdb+ z{UN8x;FH&f{8#+JKAJNf+UvD7m%C`pe<^7Qumum_>|f&l=-ZZ5ls~_7(B$@HpR=cA z^=>V-Oq&PWi_#AYPWhAx9h_5J)|c?C6xL{rayd}#FqxV3>c+{we%~-F-rZ-_UeFCa z8r~agJ}JZ@vEgV+Vvf;8t75eH(W+~^LIAaKw*@OWPb+AU08Y>;tPv~mPxtrvuohWg zY%F5-DNZGOKvIzS1~rY-r-eWVAIiKz18T|&k;0y0^B5|HQT_fU>a4k2jjVPfv`2k_ zFwclMb9H-4M{s7ftw?Uw*fK?78-VPU}4GC?sLws0*XugvAJQ| zr|r+x!}^u`p06L4{cUzK~CVzTLSmva)KR4(yxpathSa zJ=4Y27^P)k@7d#ZZ{YMsg-$Oe&kHBKeJL#aIPyHVGBr#u6x=>7+V5bq^ZY_s)Tsr< zg|$@2Of%6^;;oRT9~K%)1VN1DS(LrzW@dawMeSwp(pJoOvhF?Kk(e}(eiiXcV+|vq zm;z`|Iuw-bPE;cS%(0SvBS%3X23|YEwzYc)o{`JunMYAk=Q;*{L0Kjl94^b+ceNFZ z2yv1#F@u}v*Y6;rV7M4Yxeu8m%v)yaPsya(_oDL#cv%zgud%MKk+0XR_uKf~bB}T> zEXW$Wv*^uVz0q?`eV_L%_e=x>Mvrxf&qxrWH&N9`|Y zTx-xLxJRl>JLPAek#6DEO8==Yz*ZHOW09-#;!_%5tC394LnWBCZ5e!gPb=^ z7lW?Z#!Sy}IVHwPUl$C}Qcrr*6`(TLu)Zke@bSP&o@tR*f%UCGp}WgA^XNnL+E7f-GJRfyQPC4M zkWcF}K-Z&`Ht!AkD!c)y%}nrcd*v3T1Ky zOb{!0msD5X4wV5Wz@~xp`~;iOza``;hI?eJ!c$r5gM;6m+j|C>4s8;=%*a* zC=>+hUvw0$=_RkHWr;|atrmaCzPbB60F06+MDHaAI$m=4jTS-z4@gxfq+L``oOmQ+ z6+&l%I3cIqxeBma{K+WmkDcav#H1z;G`hn3!aor8|Bog>jS~;4{=h7D7{6)5V1R*W zxb)UGFGEF6Qb};F)Ui(YCS66Vu^qL~b93K5RB3fDS4nN(HK}*lt(}{A!flt?g17e_ zyYTmu@fE$#`j*zM|6rFj+T5}leh(ihOBkN}=zTP|-I@uaIWqT0?6lTTtXfgV*`ft~ z3lu_Y)!wx^ZjIa}T;JWB1UxtBiF|@yzcu=B2VMgQ>`eQUF;JMudWPk$?X#l^t>{1Xkd7I6Iy;6#*CTd7UnAislV|;uZbsTi35Eikz z`V=8Xjr;9FRtw{ad>7+9#fRdF>gtQ;f+fteBa$U`PUtS9DoXUt5~`kPdBVsmL}>td zkjjmt?6{eRCN1;9pYe6_`ueTtsD6!MbA2D^wYAS?S=J%~lfAJH@XBYKm~2_oGxmM^ z4{%Y;vYuX0>0g)6s?y&nc_i$N#gt>25;=2+^x=X$j({`v!c7#*pR}s}5<-`^vJ{yd zKAC02$Xf?vN|kQ|7MOY0y1|3XS`RXjW=>Bxq@hn z5-K5)=eicna?y|inBR0PBrY^`+d#v!0H{_WJ3E4_ocG)4c=lD&R7-Z<)7781yf@Hy zxf^;xRI9do)daUsYjFI{2zZD8SD40b@Zr}jW55}xv-M#1PPL5<*j#{vMl+zKLwRZO`O+%8FTH@t(A{~ zA(5gpegN#4`Q}h+>OIf6M&7{3TI{AH=_%R%HCs#7XxE9>f0N60 zQ`*{ScxCG)=FxEBKbI3l$e(=swpnYWA@Tm;+b3trY4Sn5g`=y*5LF)^A9ou)z6U6R zH7ZM42>#L`sQTN=rj4m+r#c^zsH7^4GP>H!I*$LMk5YW1L#vsx92<5m%XjJZ&p)Ki zoIH;6mmj(@9@h1#xN?@(J=xOxtTkDw&Y%6+?pw(|jtk5A*W_1eGQVj}@Mb7ngj@0{ zlMeYcT>QtzHWSi*`P!(TqQl&f@J=9(Ivw7C`kp)LqYau;7Ck&El90FTrtJX4w8l#D z6mY5g*NU#L`?9ZO(?43xLl2{Ws--b8nibcNwj4gHc5igW>-kyH2AkHza|KIPF-?L> zY^`8azV=G5;WL0tbK&d4*8NR^8?R)J8ak}J<)*w`sb+lF6{!3*>(}Ro*vgshFkm!p z3p`m}ZN;7MCbYARz$n7neg^61-oqc|bTs}?3He>(g;38V=AoPk!x8gbF|&P4i6`=( zj>y}GL_|bHMYET`3Et!*G935hBE- z&Ogdo<@%%M?Tms2Sid;Jdg&8X?9U3&k+g+v&DBU4ZV$^-f?U?s)aGsb&u2HPAKyg# zvQR{8*xu35q`Lmn9?L741p})5WtZNES2ORCTO-AweNNBJPW|lQ*R_MT{4ac$f(u98 zf76HbMZ7_RzPQ*PLp$;ttP*IJ?T*%`Ua(qozpta^baZyS36^j|c8D%!(Bj2zMXS_Z z{dRiq(i*k+1lxaooYCK8!C|YO7DMsb5ob7%d;bu`M1Q{8Uvs7LUh=3;HDz*#JQ5D0 z0aGybWHb4EDU=2$Zm~zHadGcMJ-W3o57*Jt(bb?%iFgpQ%gySl8lf7`l+YzmzlJMT zB80~kU<~B>fUk&#tCGn(M6E;9KfV8+y*JijX&DTDo3_`ZNR2Tu+h!jV`0;?Hn^FT+ z(>pmqsNbeIhxpTmpO}VGa*XzU?J>*s`ZI`v?m`Jf!5(W>lmD%Z<0 z3pds%s-N=#m+q<}Tl-0cbssAZYI&YA7-lw&35ll}?yk&FE}B`RP2zgDx`C@w?m?gF zc1odtR1!8}{JG>ACsWp~4o`L~ChF`aA5pAJEI45JOK0Sn*VkwjMnTuY7HWouOIrA7{jeJmY_K8V`{ zIR{<;^#_)z?(-xzV$#jv| zsn2hT`xtYc%5b-&WZRTv*g>YMvzhO&U92lJ;i53H@#Tr>tkfKz8g#k7C$2YlvWai7 zZ}yAb41;Q zfm5Qwq-NGb<80-Ers6QB^6lK|+>Bx5wkwhwPT1`_pnJmp<^4mLbo%q~BgqJJeuvkP%pl41_8O6muK+ixHYRk6K`7LulfAyL* z&z=p>Kej;uPC2G)MjEv^`_EA=aiHH7oVy`1epRq&VGZL|;jX1?8>yuIK}?{**139O zeU13|!lp(pS<8)EDWc;&>yQ40QmNhqQ2$GFtN9;HzP~U0pS9gHHc+}lf{(@!%OA&7 z9si;EA)@v&Hj0cVqIH7S2IhaBeTM|?2>suWN_!>ZLe+Vq9>k%vBy0KgCaX^s5wj(V zroKo9EnPAW{`*QtwheL*ZK3Y?XY#oz@bVBIQI~%elxEp(G2g5NC4oYRCReldf9Ws< zbhz<=l9G~JG=G0brF{t1MPvZz=YkLl*aoIx?xVqhL;A7@5{amV`>BwQLy}R5>QEX| zDsW$b04zeB04q<(?1G)m+xGyRJ5XIAPzW*Pgd}UYCQVJvbcp3DYOtCkGMhwsAM$D6 zAdGwzN}gbaL6LAbMTJbx1x*mEd4W@9eSXI>=wK-ZxkB9B+#sKein?L|B|<;O>+L9E z5ueT>ZTqSP_U^Q%CIE33Iy&{Ko;q$0sEdFo0@!LCTC3m<2|$dNm@a}60;6Is&V=^%QUOpn#cSX^Y)0Y-0%)H7NY48*!sK= z#)JGLegk#73S{flYu64Ai>ke98^z8Cpa8BVAv1W3 z`@7#|Ac$ZWWfV=~^b$JGsF4vmw0mPNpFWj)q;uiFhU&U^WgB+pnvuzsC>lZjW&!OJ zaBeLn_fn2&Ybz(ryNRe>3&}M|) zj=_v-RJ%8Dp&MER`oDNEzlNK~@PN~Mo($eQ5rQc7_gB;4nXxV-jZnBWA_Tg04If7c z1!4L(o(SRtUY#^p&z0^QwC$Igg9=Gp>`3boD*_#3WMYESnGG;ZAm3P>*o$PH*LqR7 z6;_;OP?136nvX={2>d`Lgh%hsWL_|Km)^WpgcLPFR7gT=!^U7e=DYASps*g7qPlZC zFmPsSD!Eq)A$Ex?L4N{y3!&QZ8~WtNOB3G|HNRcc=^^-CX!>ydV~w8;zqyA<3qq-y z-zGF*kMXmG?Pj>pMH)gA7C#_jN@2`2oEl`K{X;@Sr}1nNFtLIMk4`_ggXu{_UCY0~ zYRcR1zl(hO^5sR4U@^5GlyaP6KFU|@dvKfx`VS=L(cS-JM?9Xn%+x1gm@7=mszbqn z*LFPOLi7$XTU}%0Cp=_a1r)HrHiYc~sjd8asJSP?0@z4cybzwU@^V7p2d@GZ5iY;3 z3N=1Ol2h|JcT|7yz{sZoDB$2TDz>1W$7}RTM9m9h%?Lqo@Za_9oDK9Y&HrVK_2G)#E<<&SguWfhT;y)pdAHOQYXV+Bs&#s z?&8OXN;Nv2D%cps_L1uV@=)jqRS-iC03n#2^43!gC(1Ao7WU7}oaNGTyErVGZ8 zLU)Ntvnznk1T^hVeL2+()itj7Fo%zI zh!T-=CE8p`T!5>#tJTApb5d%Q3sTfo(Fdq-lS>HX49z29M5{2AT*D93nPGz*2nPo? znGUouhIt=Ciw5GtGV7E2SodUJ3I61Cil@A zY^9CLr^iAQldNxsq&d{PqUKanapNz~_s_%EH84mK&}un8t1C`)yeVIY8;V z5CYpRKxpVX8V*X`i@qV%dBKh_wQr@K2feW4b+Y9yHEJ z7Q!ZFP51vLpdKiIwvT!`tpD6;_gqF%H!$Qcw}_C$H!Ex!JfOnLx4<}S7MRx+-kK*Ytt^EjNSXbDjFVA9q9MSKg%;B9SH$!0f zb3;;irpPTiKwN>#K1^4TV`b)0VY8N+TEvBPRatbxf&bVu0xV`P58tPy{J^}`T+5dT z=^dk#UAHN6$=@1!7(oz=!Bv5?CY0LY9;zQ7VW6k4#dU$o4oUgsNlga?v7mv?6GURP zo`4;c1FRF)nLt>Ut+!&704^*Q?JeNFA$Y)<2O?YngG!8JjNk+Zm6&sZtR4!>2lnpW zd*p~GSa5I}QB%IhH9`>}+hN>1>V3o+fc^PrC?TO@O%#0`)U>tpJ31}bvDuk9tpCsc zd$Rv7iy8AZNPm1m#|J?^s|cD|?ED+FU)@thk+uE%zf`lxVE*%7s{fgj{5{ZAYJUHN z82dx^ZyylV|0Th7e5ECS>}q_PjQ!$X3M0>ESuxOj{q}K^_I)eSYtYYf%?h$kK4ZtG zy^20{^ZM3Q*Tz=xD%2gXjuajyWrmL&k`H;bu+S@2z0|)!WxPJm%u{T5R4A^fHK?uG)zsPfb9A($rUt!Kt)hD0fbuh)9NqvV95afZ+JEthPfh*Y)+Q+|{Oak`cP%Xu zCtvK{y&G@<60*R!qJ1&d1Yu=;_{8*vm zAl0%d9UWomxtYizxI&DgA4u3==W0U~b2cZzP8x3IH?y+_`hel(2k=_io*B429M> zkvDGHLW%{45UqE}^(QkhPlSj&~&glq8G6jMFmoXeeZRa%xVFfh>D+Z%Wocj8oq zNe1p$C4^Pj;!Z-_Y6Vru_LP-9fBBN`0tLa`WoZsB3@AgnQ)rfQwf4!YSFZ{Squv^; zOuxAU^>ev>GBO-oTH4x-;PutjU8e)k84Yk4pqXO3^tc-<%?n5nzJ7fMvY+^A_&g`z z>gtN!i6XVe;XWgD(tY^&ap=d7k{#!5ZTY}ZbUV--v6{EdF>z3E!Bd#pZU0@GxZfUVHCVn`Fn8-=@1g~Ib(HVr8Iy$~|XrRM9 z7i^o|WFgE${#jf?k}uusLPf>AjU0mD7q}kuFnb)uv0R|#Uvzg5pB7IoEL_3>BGjbK zUbT?8)~vZf1bs)2+|E0t z+wl=cj}{_NAUIZLtgSD5rsk@FYR!&w6(yPqMX6~#c;{T$$rsfKt8vpI(np@5 zR~fJuHg0NaDv67iHyHu9?%A^exMPD07ha^N3%G_UkP;ITsHv${<{OQ8JfsW_3=9B; zpFR61E$t_Oz1>T8eU9P)w)l8?OJ$N06Qhn?Tqm%3^$J4>&qZkFyn2vRtVB|d3}bx8?F=b;E3?o#(etx z87_x%b$qNec|AlIbqYk*FQA)abYuhx`kvg|`l$3^(%6hCb`07K`q*i1@>Bw^LQt+* z>W1A*;I$bhH~W5xl0!Cc*f0m!Z(RQrMIDt?#sFE0iimWVpi7j7i)V00EQSq&Ptkb3zNyJVDB`JIosSI?X^5?N zbaa4}V`XLKxH9uQqh$oJwvw{4W_5R)E~Ex4M>WYOPGH2M1NKV8Qh0W@XxxpJW6%MB zQ>9jZZjTfU92RgV{a3U^kVVilHIVRl-9?GwTan50hOR4=KC~Dp8+c zb;eKA%EB|jw&8q-ys_f&EuEN|DdXO*r7T8d=BG|QE$#jO-NMIb1r;aQ_Mu7yEzH@>u&*;} z7e$w^32fYid+rox^d%%Lf5k*&#CgNDpX=myiB{DnK_AEmnT>LB(4xVu#)U3 zdN~||7$I|i^z(qngmtiN`{mHr-;ezGVWi3Pv@{3>!EU3TM1R|GNCSZpGxLYuiG&LP zu;J64tc=DF1Ghwi?BedeNqP`s&Vz&dbG2v0S-${YbKL8H@>mJ7Dzgd;3y z-@(=J^{e>mqzhYU#U6PhTSgz=B6rkDjvc+e)Uv2nMCPR{kVHjA1tV1P5b0kZ&%Db+ zu#|wLkPg-zBVhxgza6HR{?J8l>Z_RMe)TO51VG)M3VVBdv^PVe!1>gvV1-j&4wjZ* zb1&}oEtMsKFYShEjfWC*Om+AV_E8jq(hLjzIV2?{05of35Au+IR4se+kkRiUAtuJO zk`Nb1VD%XLiQNwrxLZ~pkPBMgRKD@e`uqE%+APSt8AcB@L%{F5D3&6v5c$W%&;3J9 zOAK(PSl%a0mq3di9vsZj<+BzyM59wh#RW9bJhkbSar*u9oYVv>mk5v{5Rl)!e;=2f zjVn>h+H#+hhkVJ)t5y2D=s3vIZ9QtVlk2_3D_C}Yb@gMp@PCf#%*Cs)9F9OOsxG za<()cJ`I5YEcLAN`t|EC59#K+rD9odcDlBvY!%4OR?0Q7NAC(|xxlCVgsM`;x=-&f--fqbU%a!1o;*{*><79#m`4j4ZXfoR(e=9v+>26*o2`5NS{i$NBg0 z@bJsB+S&^WrT2LEsw*k~1d9?;%hQ9J1(E6}PyR$X6fD18cHCDPDcG0Wn3#yck+T!H zdZ=G8NO_;CTYnY8ymtnce+&(w=i_?hN1j@DC*&7!Wfd27i0{D&JOcxbr?l3yyLouv zLfB*sQHq3Rz^fu6T2N^i9UVoW6GZoIY7P+}?B5wJt!-AOD;E&ozJC20F=bt2qpOqC zem=hU-Ub`aDXWCU6&K5}mprc@M4NamnlhxMq|i2CZDE18Hq_V4(wR#fT17?dT|4o;(T>gD0lUAu9^=W1%0e^gGNy?NsS z*bm^Y;C%6`S_q%Ja1*? z^)c0_R8+3IQhe5jSD|E8+LI9*+XJ72ZU=UOX<3=9k!ht*TEU%zHQ;!OS94z)5AwBVqnaXcZF8;ZeeVq&`}s6FqtXXA>gM*m7l z02dT(Jkafek~K@9n8hdNN{NgBBw=7HmKp6^yY?oYRJz`)a(o)T+AM`_n}|>oJqDq| zF(W!f_3((eIPlYT)e(p#Qq$7h+}s|#;bsZN+)N0GNN2^z@421kAtOayK}*oBQK=8U zGa*m8ivnA8_z`SY4vrWE?*)b478V#_iTsMebxqkhjLC$)3xy;9%^TF!8d_Uh+uJXr z;Dzmi6GJVF@fxKPa`$ckb35b*o4KLurRSwfG+7B-Bq+FJ6t;h#Xs=cWxcjm3-P1wdB^U-jw z#!3$#KTb(b=GEJA-oT(8xGcRKB6?(^c-t(u+Ot|1HlX0QbS@)MKy_8?x&;RWTsDnG z97fwM9zHGWMF)F(nhhH~5$Iy#B`k!(_5+KDkuhawWp#>C5gh8C=g$enA9wfd^ZYT@ z!NI}M6|cjiMzI+}=w^20ef<1KF`N*qJiQVmLbg;w(C-C{Mxl#3`Fm!@)y<9l>oS5A zcwRg*WH#g@d63WmoWMx|;X%-@RV20xxveztb96i8y9}b9n1wBcF{ZN|`hX_DIKmj- zsFM)-nJuv{v=x)&;~T**y%uMkyuAzINX^vjnYRR{-Tx040G-K5C$zP+km%zeadI!l zK!cmL*|?^uNg6eWlC51lWy$Vb2sN^@cn~R%uH92JOsc2g}J4F&2l^}x~!{Q%$?;bL-5-%xv zrF}F!DL|Z<*CENLJuMvZ3B_QQA2|q`K=vxzy^&rH0Syw__5#mWv&iE3`O6fI+dDdL zuDb*`?9h=n(^tu0;JuA&+m0O(ckg!h_Hr*e76dt0{x#HYG?3_K}dLzksj-~dEe>>wGm%XWN)M@VR^ z3>0PpjVr@(FBrBC7=Vj{t+jgfY98KMXtungm0lPTjy*e!%>WxB49KnetujebLBRvq z0NRqC*%b_8O#y*9xx05EVk|M;OIl5;9iM|`+0e*>g%Ua z=h+cmz9P&+ex03-=mrr4!mzi`kn)Slk#tfn%}wqORBFq;_#*C36(S27V%E+s3J7MD*Q9yN%WB3VuwQMz`)+`H}=d{DTc&hhjyP<*}eNY zBGS3}c{n@A-1e(isS-pbvLIW$XpERp?yT?R+#*=;sEGE+ohEVZxu5Ix+uqA-p~t?z ztGsu@#MBg)03U_RLi8fzIcYYu6IcN-OEzX^zl!%n!y=`oWNzOx1uw6B1XgVpYo=#r zG-3il(&Qhb>QjR@3*$>q|g*&$MNv+h=_zrG!|c; zb4JugG-Qx;9hSOKG{z=F5PEIG5u;>aam~zlqUSEy*yMXJxCwGd|NQX-n!o^~5d1#4 ze_t51DKz)KeEG!i<)s zPew}}`5~h0R5~!_gM1QrAix~76Eq=D_^}q|A8NJlroJLv7DYF?hKQyR9kt;5S-=Mq zaM(_TbQE=@TxMo{80q@jtTLp>{zvh#R8~xdl13K;7#rBy^l}&<^rY4@#FcL)6^R`# zqi~eh$j~szmlysRYkkloytT74s7tj9dhYOoyd#6!k=a+}=|Pn+d<- zK3KQgI$Yqmg+)Bh#=}$T2L%Kwv}<3eM#z<2;UPo95AZ1lHfVB>F$Y&b1zYc>@inAb zq{xL82=F-YW^LWMGvLA@e!WaeWM)Ta=M^v?kZGtt%F=*m0gM5hW9H;eKByTxBbEh9 z(fRt`SpX){b3g{1t?%mZV{6Bb1jB5i7ZEKQRDmNEExcY~KuiHYg+Tq%(!4A3a&YVr zv;iLGdq}@AjhlzOwV9KfMC}h+2hh9JV?lVt*Kc1 zeib?!lOcz|gCGS*U-=uJ zH%}0r!3g=!xzEA&zhN+Ua3GG{B4)mMY)=D}ZSBuk-s1>)GbAtjqVHP)lmt79ftB?# z_)nm&mA`+#@4$g?*|}e9hDp2Aq(<@~Fpvs-1o;~xt_?-Puv9>l(8K;_D>pt0iVbCM zvPyb-Ey&-YdZ&T;(-_-z?i?E?R4)#M(Yb(FcZ5A24jHzIS#bogi`W^9cz;;SoYl>4bY!F~D2o&Hc zQOgoaUgN@ih(xl2;6lltUc4{lh87=gG9@9wqm4}b>F7~5E)hs{z4g3LEekod-b7Q> zP2i-XqN3!6No55)4^19BMxr`KibeQ|wPF>(M+RM25l@^W#tzL9AQ_*#b>dP zW@l9QOhHUbH_v6iEZuBYXKgK)h^ArtA?7~oKTPyoCJOW=E26m8qA6!Z-i$8 z+KnMxR(5v%&bbIVuHRo)*mdB*5NHArRCu#Vkq#vfhDIOr4Ba=9onOCN+S<0)*E3*k zSXu9c*K$&blp2ILBE>H+-^0y)qpH8Dxfu}jhM5uNB*0GKDE*qAMo13e#k1zl%dD(z z!Cs&VmDJSGaW%BH4G#<`7y4cwGzP*;Oz6(8F9VWF~|t1cjc?2gr4%k;%a67~DI3>Qs7B(K0bu2-L?!{(eN@PzxZc zTu_(n9rLiUNoGE{{Nv|OB7OzYC_!8AvUG7#5!OoMM>1C^{p+kO)kk81RsDESnMp~< z9eV=a0MDyP&H+?B?jb=Y7*{YPNL3piK8cOx@-6Lg&gCIvVi!T5G4WC$VfarX5Go*p zFbD_3QJcaxohQhvMc$7-zJP@vzZHPnL3N81i&rTIFx{SV0_f5GoE?S$>9y*fR|yF} z(0U7#P2*|+mkO?#v#TqzH;W8iY#g`^6LFK%8X8W(x4=FVdlVQm`17Yd%7RC|UU%Pg zHdeBP$4X7@XlQUjT7uNG^1$600pPKia)~nPmihgG!NCqzRxqykLJv}QV&Wi@al#>Z zc}ZRggf0c5z=ef{{JcDskd*ZF?WGJ3&d!rSY8*d%`lqIj;=tXssGv;|^{2D8x0isEOU9!o6l$$Bi?%Oy`3!tD;RztE*D!@(rNC{JMb7W7u`MWF zh(};BJ*ao+*frMFkTz~aF6Vi6M&@2}O3Hh$Ou+BoteFsb!c!38imM5}XJUG~0d^!R z5NQH(z)~gJ>tHs2G09K~F+jX)=trh|58whRHl#x)-ixyYB71~Mxj_dlab&_Kx-*oj zemEk?sNNaMBVC&MPOuiQU0Ws1ucEGm^54+#aKH&=6F_vg{rqa+SCV!gI>hbb=;*OD zpB)~q1N;?$$(tERX!zl^iFOJI*-$z~E_^32Fj4d^90;;O*d$Ucw&&r)hc&X#e1a7z ztEg~ybIa7_lX_%bGf~ROi%O6O9kmAPJnvl>?{fMf(nl&@4I~snFE%*bFacMv!QtY(b z^8--n1d$3c5uUiC6RkuPF{M{PKmaM#(W6KA?tOuP4k@UZnAjJL+?CopeI9p@3A zp5R1GIk|6tC>J;PeFV*ojb;7LT@UE-E$in%K=6~4bP1zgubFSXDfAB1 z3hYcciDwxZg3{8R<>lY)!Zs*9ymf1xgTojyTf7lb2P(M;xY1K&t-%&kU9q; zA3l6Q{Pj&gpTxz;7zWIDtTh{3#tMNwZVurC&Md$`6YYs2SaHCPZ#JP%E+3V>f~n~w z?c{Y^Tq#2D;2~hRrNX%O?!7Q8sZpwm0-mfa*O4QrL_2_#SMyfg+`J#?;IH4manccB zT^>SczZsaIaLk!>!=vUOw@E}vW1bY&mz9JzqYi%f`o^K(No6)TDo;{kAfeHlxZjMxXB?1 z=LsQN7^KAAgD|JRCMWe#DtPqhtg7l}-_peNbfcHXZ=d`Y-8GGs=w99|_o1O-8aqu7 zLYm-U!gr*lfw`L7j;jD`n-~ZD z0w0I>9Xl`&a4SxH=Z%f&I5@iS$C{e% zA3ww$ea}HmuGSKKo?s$YNBcAltBe$+986|umXdP_@7&vJ2X7}!mAcID%0{i;2TUmssH{^b&p}(weWr2 zZKD9maCE!3k6`>V3V=}wW!Z~L!~2Ohlg9P9_gU&s#?JN6>8gg593c#2W454SG^N(0dN?kFSn z@khv35Jha=K@}Ww^sW*0w%-`kw%J{NEz%T(7;dhvAV%E1dl$n*@7=rB%K%@5`v8!I zNVve+=q{d1P7bEfy;{3oH1g4-2_A7UA7lq+???d}Dl6N~&u`~P+$Z+A}S=r^7P#! zVR6gJa_wM!8e*+=M0-u#-xb*H% zs`~no)dRh}QMlsn@6Sv=l$1Ql%-mWX9FLR$ybe5W<%E+O`uZ$sngb&vd}lb`T|)|o zTy1=klyu>P@gY=oasU)zX4=pjY&GfM&bbD)Zciz|0HKRVXXUET;`+aJThKA-@Tu2{6-SM1s2uFkL0zZ$?67>SJL(FeJ-9nWMD6)ft zKzLOuE;K#8B8CoWEYg4#2<F`;{mrV8j_X9muAyGsY?&Jbd^!y!h+{7<;ghHt2i6<^?O~+cy&{ ztBCM$Um0S!r5n2@f5is{+cF$9FoolSCQc(g;lT16Tz5VDt>E7gPvz$|%>a$s*8}VV2pJY-4V1 zJ%r63#DVAz&K2X}R?+@>@D&xCwy2TJDKH8ia#Z9HTQM&L(ce5*W{lqM6@>rB{^}0g zsuI#SHFte)2HN4Xva<`XEVX|4UE5Ka(agk$) z1Z#gD;d|)N^X=*E972cc%E}Fg+fY2y(aFIgyn1#2t+BX-1iDB|>+3Ie>r;S*^@O}6 zwz%0^uKArixY9&)b#%bOOE{`nV@%Kk;^L^g`c5HNPqClGNCfoG(a9l82m5P{&DncA zdN`pEAEMNf{QSA%^IJkfLeN)4)Zy_^9=C`>IxvRP_A9Ees{+FBkSHM-FtDFM>f>+% zRtbR}LJ<$~vnlHm>E%FSD=AAfICsv;N|q(?`;Q;GnwmGp1nJ~pl&~!zmAFt+{2kn| zi8)VT>n5Vdg`{qMAPu_Yp&t$`EN}0lvUHo*nF48o4U2$atU6VR#Ve#rcPi#_8oeiW zTqpJ_yA@m;h5%K6&;1u_rx9ryt5vT(6tomOxod;aafB*N5a>(f8Id#*KU8LRQ}wfo$V|fsKa`o-{rtM_zsLQ!AJ_l7q;tOC&v?II>#a+5LX;#? zaBJ%V31Y3AUO>;mbc6#AGe%|$KzG=3iY59FkE&DYoqYrClaiAs>`IB>wd>2rkLsUp z*9ZGySE3{cn)WgwL4DRBMM?F&dqW|?tWpN+>kpyMqk~!e-6X{YT#BFRkw5aOP=`D! zY8X=3cf^S9lU6XoZ4@kAfn>s^!KPML7cw%WG-VgkDqpX{2dEO^K4XScLKe*;_;>9% zf4hd3ppa+XK)FE=f35kMsYEtylt)}q`W zzqo7r!sYF@+n?en_7~MY8bVnL#?oxPVA-;#_wS!w`(e(;%pPLJWjgwhKACRWF0%U@ynYjflXEStE298&j zoee^##YPS+Tg!W9tMrInqk820`E~p|)v_Mbrq!V3QPKJIq9tgR)$e8V3VEu+(HY0; znc1@U%qZ(ek00};TYe`qjfmNM`axq@c^CMOg*B@CRP+xt1&a{r#!zs4A0(b@8`1%A(HVmFFi#czziN4BNd=pE&U&@_sAr-3=URQXqA^ z96Su?lwl*03IF(BL=Ay}bj8f3W%I+75&B z%N`H2R`n;x0@&g3=5gW@1kj9p4MvvD0|I2_$P3KAz}>V7Fa@30=v4p3i!Us`H{jBo z)FPie&`541YFFzfA5>k}kh0ayd7H3y0md)gc8+#79yr!Y7q6%{MOX<&sVv{|!y@S@ zjIuvKA*k267*K;-7uz>oefR!-?S<6?4;)Yzi*-Lw3pz}#fe^OysB}z}8?SI_jXpg4 z{E8RL-afCTiJ}N-P^u)&kru}O_cntHRIK-EY6Q7wp@kUg`vuN72vO3{nhsLDU_HSuZ+}woGx~1N~#Bj8~n=<3XQg$Z2Wyq`SA3xUj>C@-ZwN21Zypv+3 zmqw6bAAic(7*9Vb=f8T)jdy)|Q@C+Aa%mKIGBC326%!dbR)h)gEi1jp=cV_bKE3Q+ zEWgnPxFf4>%$-M%E`YE1*#4E09O{LNLg3vh9?sjTy|>Twb_H|aync-W6%oM5^`DL2 zYj?Te7?Y52N1VvyQg;Es>=XRCYJ5Y$>avkv6;3$EQDOrr0y?2-aK5}W()>PWp&$^e zka1g4?p|E}X)d=VT&nm;?MXV~Z7h@p z?XfA00VZ9=?c!0o4tCq-+X32)tTml95_hasoU+H!Z=x*?8%W@h6=9wT8_6Dsm^RLJ z5{8_pid%Eok*#s}fQ6nuMYt&`jG?_-&5@kHxz}GoZsjVFPjIlhG9Nc(ju`>TtcTcJt8%%}^7E5!ze4!lnDg#0dlu4Nf+nBVD+*j#LWi}zA!zvP6UQb zxI@R6x$oe7zAHz8>=m{6H_x6Ss!7 ze>Iz5JQiKQ_z8cpq>^2Y;TTBey%#(|2O{UbZiS*a95tHIR z$fYwC*VGfUzMpvf>l^-*#=?i3f(i#ifl@3i$Q{4vvHBwW7@&)Q@X6C0S*refS@CEUmb#nFw|6nU7pN(>aoE z&#!gzp%Tj~p>WaZaD46Iix(f4l^w=&Kp^Qjk-~h2TOGCNvE85o#!!EJtnxlJNT|kD zVw3mXesu-S%|YqJx*Iua_tpO3Q4rc52TRyp{NXCenS1rXg_i*6s(D7aXemJrf=o3( zjJH(2sWn{Ah66MW2fub?P}>}qh? z$M!EU$;5ni?%eQQYH$m^B?ddy$dX3HxgdYW?_f@~EmXqT@ZQ34^7s&Mrb1ALvsVz_dJuLKZT)&<&kvF&Ja@6ndcJkqWOd&fka;=KV zQ(?|F&HA{_TR}j+efp&F$OlI8*|RndF>!A1sCq8Z!Kf18` zA4}y^yZ7vwdvQ&dXDI`Wi~`Qj3ekln7rN4Nc|0m8I`KC=Sh|Mf752x2nwki)8|8jL zT|v|Sm|`!}{V6HWrY>29)R~$vNG2%3&z==X>E`W>Q&Uw`EOO*He9IMMw?fmO)X%V2 zXF(9_N9A!da)|xF!O>9AQz#NCD4Y`vYMcIHh*)v4t@VaHs$%2_lO|3qJ!6lz0p7`J zyVK0+)8{W-*yy~otP$;%=ZqPOUu@`q#`nF#>ggKM!H^Q1SIH$Kk=WSVv*6~_%Snfb zJ97XOrrQ4MLy$_nMBOm)^@|s!UZ&xhuYEd>_aVG}BFfZ>6AwCVkjUiUk9Uwbnlql7 zPR2fyf@tpf6=4@frXb(X?^=)b#QW64|4C&s1|Q?zrJ4+BFMkkU82^aQiK!joJOPKs zHs6+s7yJ9iL0#3<95yeK)MW|2sMa!&fyj+p&TyUKQ>-3)SofFXKgW-EJ$fQktS2J; zQVJyo-jBMmD4HuclYOnzHg^U{x;@Be_V z600ZYgZR~?!W}31=gGT~pJtAfhx6U%Oy4QXmk*v}?&Iedd03}jaf6`pak|cK%s+p= z>h-EiH&Iv{QG0*xCGrt4{}wk9|ln`YkE9K35 zpcU}x`!CSmb7#&_m=aiHIbuYYrPQS+2Li5nnP%TSEgv>5z5bJcns}Y-;&0Kc5?dF1 z7kK;KyNO~ul(>!wl-EIzbGPPbKH9vxazDO&F44$9JcGT zJG+Rp5WF3jx2|HFeJ1s>$&Ax}G+r#A>aPzNSfUH1m8!>B+C4iWQE|cz>8#9&Jlm#F36DxQ|P#Msn#f=Uf&)L`n z&l@9(GBh!n>*`wh;6Y@>4zA5k{j8k#Swx{y+lzbSXkK1m-BVX2NALT^^cp!eV5<{W zlPh(QRNEfwK5lB?UeYcTVPn_#cUpq1t?vtQ7wOY>0XM3}M@sWuN6l&N*MBO(jN zQ$F9Sfn3vNf5FVpyq9GRW+RS-qKR!^^wXf9p`pqR5xxv6JvM0KjvZ=RT5D)e-n^er zi4ER%MHK711%(hFf$jnQ>MGEi>di8~F&!^A3dAH=3)(^cd7;W?Zuk2ei0@va&(Ay` z|4l4Z7i)PGNC%!pQrAFtq2n36XC@^$Xd#z)m&2jF6bxDZ11Aq00PPiYVJZMRv2RwyV9e@H>}vPiG-gsuBc6g`*~=S>*MA zzR*I^CILBmd~uLf(&5M!pV+ny9}Yq}o-uN#AcR}NO;7zeQ3(1?C2#|Z9yX3zuu(#$ z&_l40KPemM8=%P+n)I&^@a6JMI3PV14D$nOe$J&MBtRo?ghIjQTz7nnSTI&Wn>L*N z^qq~(_YAV`z2`c0z0h`$*@JZ4uJ3OMk)8ln`GJ|?;r2lA(riv}4&dkK9!eb&tT+F% z?3#s3N8l)AbfE~dU?t$ORDb30PGJ98oQTL7vbSjQ-W1Lb@bYmRk|<>%@HnPK5;$ty zz3_q?X{i7Tk){WAOyGS0xl-ycZcWmS6#T}*h_~=7)EFe!1UYw+_%2y8P-RMKz!!r{ zeU(#Okk$R6Yv9buK6Y%@C|l>><5!Sxi!0(SX^5FWxyHtk2EwT z3)A|zdw389(^r&{mxm~MBS;(yBM$t_>pmH-R>86RfY2r03oo8#2wfz_W$A#I2zt8i z!ndRqP4Ldrg$pCD*6>bkeP#SU-ojFUe~2g{I&I}kcgBXKNb^m#PY*CA;=v#ZfZNt%Ibn^y2s61w?J4|!tx+I z=l{ns0rzFyJFcv8(UK*Nx@Xbl>gu*jRTuKm48M#(YY^Le)V2+?c$jE%XnuIM)z;GV zL~B@=Z={9dq`b;7COXHfZGT2yIXqTGSiifo$}&q%F2;?CKRAYq6ZY(>xqDY%)ZuC# z2Q}mZAgeTaEW|nW9b7yIGPg5~aNXs3YEqD!>9mfyv!^$|uVd@+EB&vFsD+8K9xJwk zzfMkmEY@(MqQToxTfED_F@1aQlJ?f^hT;OXa$(%P>S`otER`}}b0o#h&6o!#B7cQTaEL0g&?!%YWk6SeMXkci6n#PXEz!>(KS%@OCUHnux@1Y_f|AA) z4+@-O^m|4W7rq^TR2E1y&bQwkCBI+A3R`yR0Kl|e!$?EP1N7B8QAsDKbXk}-dZ5P zKt>I~u`yP}HP3Dc-}eKb^{2C&66~TYd>aDa@Unpt(LO@JPj+l$(j4BFc?%YVA2tQS z##VuAVd6duM`tHFcg|?ExnbgjUAvxgLD1#F3jAi5GJ3ATZw$s$BmI(*o^+QMg9g20 z-?5#lAN9f)ihfiwK&Bdp%d(-~pAbj-Gq4CdJ~$7Q6+LcTUqt(D2SL8@3T5kg0F+V6 zG1iv6{P3OT< z_Z&FjlHhA=+i|E|jCjWR6$g_vCw4W^*T2hf@HumCHMYzlMho<#yxiev&trJw1y9e>7y7jYU_Vn)H0`t%MB)*A|&6) z;?F(C8UHw|wonZfa7pxr=2yu3+XwG?z3Kk`CvhdX5XqlTI(0<*+|zzL4jh=n z^X4?9t>n7Re$-G8n1cenC{CEY%{}W3*M_2q&r)0ahlag%1t4!zLxc2Qx2|n1wx8Qn zwl!G!%Gm&oH7=Xt1}h|(86G3nBKTc!7@140e|~}?30nI~;F-RMSuVI=RV6r2^Ct%N z8bu6~*Jt&Ah3XAB0m3!Sgp=@oWo33w&SAqz9NWk7hk(QAneYi}Z+V%L7RXaolTwa^ zsHK5!MMqC?aZ$M?LOse;pcw?(M)|Y&Bj?!}tXcUtm)MKocTG@1+4MUVwd7}&wOj~hZs2W=C3BI@#NV*{Ra#n_PKuTq<9{!fS;~B zYY8E8?i>eDw_{`d=nS|e$(C^SyR6@PkBx{Y6=*a1l9j6li=$IU6`+D17R8j(3kOEp2(-Jx3z2gkxP8KR%O7qJ(%DrrH%X^?e`4ad`~x*#E?_u^>kxu3PTo@qp+(};O4cTe!@VgKYH*Weo$K@Re1QypTyX`c=xW$&ruvS;DzvnxuRQ)>9})8PrIJ81EFBa5|Oty zDwGWBuZioQ4vQ;OQdNyO;>{Z1RxFuG^+=uWI$vdwf2ZhEAS~zv3C%!Jt-F}KX?|)4 zLF;GFe&wDQNqTHwgW1ybh&<3o$GPK0bV_}!Ryj1g=W^M_$NMc`t`+l>XkCRhpC!A< z`J?2HI@JloDtl1~%nUhi$K^t4))Q`}d-v&Q7I$?#>1;TS|B_qOE0rqQs9bb@)(Npb zOpBktf5f(R4ddW9h0l?@TwWscPJ#}eJXvi=v~9Yp^{7#yAtCfBGNa9u0~XO}S^9m~ zdlP)3O?byRqitsZ$!W z)^JUf{kLXJ|B!_TS`Ul$Ir2#nxfQgwcLyi7UFDq9$Bx~&ecQib+7i=&q*y4K6`^H& zFfwvRIH5N1EcA&B7B1A6T_B+3=g+dni*km}7;+7ks>?^uNes|Y<)t?l@!3s0XV=`j z*HvA;ga70s9_3k*&1wx1z)A)&eH5Ke2LK?H1211{jIxr3sNp}_-nr_T(`7|{gc6Nr z(s}b5Nf>I{M{^<7em-{P-L;CB6DPWF32<^>f4Hwjua~c0A?WUbDXVvschc6ag**$C z;m#8$a`p?>yL8hRS=o41Lb5C0z;7 z&e6OOu_%-s6I@hORe^mK20!W4y}qd_+ekm_?rRP>L>evhh@U<&?-tVdX3!KT|2*Om zfWTEf(_$(_-lzJNh|H9eL3ijq=)!$D)}BA-KfwA3?f~So3EtyIg1kat0nu{g(K0ve z>i=K^cL+Z04Q_*U^aOdIQZDH zQzuS*C)|&vsw{@nxw$+cwfQp2J$`~ebQR#ZND|D<&Gq^fjBPu6x+w4xm`PBOC*kju zN%C4hI@u#6a2 zI&E}86%|p}Uz~in)GFnDU0pM0GS_|ZotmDabV_&lQ3R7QF{A&q&TCK`(7*qPRZZe8 zwrQd9Z5tE;O6Ebv`<)xfPh$(e{+_kR0I&G#1@oCtJd8Owte1RDGf zdReKeCklgBkGrowx7;{X+X-X2mZm1hs>0x5l~*(Ke6%GuZh=Rk_~TC%W}0u`+8Uxw z%HyA!o0_)!eVex2pl{zOtS%YL2!k z>B}ZgpFKOp>n2%OZ@`$qGKW@8)5`!cXlp6!LoY~U#s$Q0{?^v`}gxJB{6p2F(Vk<;MuKb-DYRrg0yiB?K^??v7W|TC$+2W zl@=;DC+zbkz0H10mW*6--}qw*B8NHnv-x#(+s2t1J*uqK#Y8=)p1c|?-aUFex*9yjqsQbU)xswW4`@DR>@ZozNgq@(}A+sgqTP~cg zrKR?Y-v28?k>vJ!H*2m9>Nw(zwr$_u^;jZR&$E;BqrGlMXp`ZF7PQdb!Pd5s!_)Uc zZS(hEa9sR>&&Mx=+{WKu_2B*zGuAvvqj>VZVasM96>uHaF}4vcBuaV~)UU!>qf>(^ zj#^uTT3>r>3yiujxBpkrk5F)=rCuKuxl-D!;| zo%4xQIgUc06{ssApcn%(BBgGl5=aIFDtL!40YB>g0hk7~RyjT4@fNKE8If|uxu~r>%gZaq4zY}d z_~+Z4tF%ZObM7`h+um<(;PL|%oLASD`1I=D zorIqGtq(aJ=>eaAHb^q=qN-Yaa;m%gC$1;CnXs)Raq>2=wz0Cru&$UZzpesN$&6`2U3V0&Jt0#tsh3!ahwM2@HL(jVRq`7n3aKA{S z?9lY-9veyhb>+vKfo9L#2k*i8_;uUGs+xZ3(-*k~*j*VbnKq0l&;t!xO?dYnx0E5~gQn+J@UL zuwhdRqZqDiJb&txd-H0;L^@@vd)i83) zHqjG=oVgms7ycNqT81D#Yp4(5+1zl0`}bENX%>icyezOK;_39HZAp zen;FWny)7t-C7sgvdy)hC$)6tIie4uufI^~(gpcM+-qmu{{3Iq*E21)L!YS&7Q6(S zxu#Q6o(YqXtHwXpalh+~8CDD4=}Mb;jZ`h1R%XM79Vn5$1H&`y+ZTUOC}bd?(0pdkgNem0YF6lA^(}%hr5%yhmXl7&Dc3!Dh+Wt z9JlTdR2DzetnF^J6BlU9jH*kePFS$JbP3b6kb+}#{f3M)kgk>?T$OE>v4Zdu_eyc; zH^n??`SSMw?A|rHt?NPWpaTh+`Fu=0A?-V6iQ6Fi=||=NihM4Gz6j+;rXsK;5eRK{ z)xCS;w7?a?wm<0xI?N*iXlCS zPeyx8P{!!3QpyY8q=DlW_6_=tVyN4YTNDtZg8%Hl{sL%kgq2lnH^qX$Ux0aHGeG4H z`bF~_b|ohZ;e=3xH*Pq?W!m^}lp+-6d>5s2kQN(1L>`2h&g^xaaG*fG{P{y8Hxq4* z!lj+lrf_VqtHs#!hayX)er&v__VW4jn1v_g=~q{-UcISv#BU5pP~3A7PM|PLmvEy;BakWWUgky#9gudmRP#V>S=Lt$0psSF=0(eiM+hp`Gg_sqef0#eT#SK z6=D#s#+H_z*-t~pqxO0lhjs@)uiR`c!bf=X4T&w_d`G}zXcK4OE!@rZ=_TT~OW5UABXiUhi z@ne(?P-asTRDSPOTldM9h&k;h1Dw`h@uE$A{rU*jT;!fsR@)kb0OzEU54vxKVFUZ1 z@5h>fNMKTmRi{#0QM%wy-?*%B<`K|$qAyOPgg%-kd$ev_Y(0=Y6)au+No#fTBzRpf_< zRg8RVW?^N;tGLRs{_6R2ft2gkO=I?uFm3F-Xa7f8#x~}LF~kPY7ER4Xl*jBTZ&w7Y z2sPB)Zw}jWr?N6>=n~=i{4+#QR(w2`UAD9GnB0nh0Nwns?50&#(WM(bcAytXT<4@lE<9ujsJdu!-%9`cHhlOe?xnxE+|Ek-gXSMl>4%jJPuM?K>~_EP4f)pzY^YboCJu zjVVeE9rv8%rlcY%x=rs#BB3WhF}MHNv0J!1jBm18xT+WeGJgLM(;~<&8|4N5{#Pj% z)&|6FYqa!M4-vb8dx%6^&TfQ?aD8rOsK1KK?Us{^J;p^HkeohwpiaU#Vx}dqEeR6C zVT|BNwHB$*w5d}KXBZcVT&di-ws-FIm^DlJ@d{Zbm_H^|ee&!Z=UG|2`H^iwHdcEI^PTp&uk-O>}m?c4J#gP`%}5Lb~)a zAKtq4p2_4w;_azZpGaTWX>5Vi$HOE4;zgxf69srQsD<R-Y3I<-l_f8HW^8ZNuOI1}7M7L|YHM+b>h$hCx;MZt@J(n$F7>a$ z%eSIoPTk|w+>TUZ!jx5(my7W4+9G=7Ov!Cnv1*mUlXy#J8R8%K}P*SCc)5>Kb_Mx%{_Z6MkUGc&;rBkn2{QLzZRW#1&Der`NUpz)5G zAz*w^v!=Sb1>gz~AFjK!CDzqqchXO!UBaj~^Xi?sp2rA5HB5~9KXn%+F(^AEBL2L1 z=-g+|;>5r%lU-ePFWC!6Clf7cXgN&jgh{Y%9e!-?)y;gI506gKNCbZUZ+h!=bfbKF zk_btIChv6p>eWM!nLabOfAHi9bb7z=f!aNL(qna)jmwxc2>g*L`FH!~HLY4tx)8WB zO*5+p>Y&r>I{JrB7n`~H$9~+-S%POZE9G=#kaaEp1I&WAONr?X3PaGon+u8$xkaiW zenGv@#I52-TW&6>p{s=u|N6D4kB6I^B5DA~GQW5Z9<;}WBD~w2xh1B8xoG$fHER1m znQ@z>(hh%vYs&|9-??|MqxK0+kY&i6xZ+3M0aQd&~xnQAMe zQNrdIJD&GytfjR33P<5{9p&^<2ZHRIC?cm%cQh3Od-~gpE(Evz;jh*$-5mPyFP<>ge*j)3MTv z$B^K}H}9>jjc*@)31H`U8b61N-!>md#lniv`gMtzWEKil4loU(d>Zu0Mw*`c3bwhL zPMXCP5vg_|KR;e_w9~f8$aX7LiE#tF*&6WFP6PyN`@u-aLMWu9i=5aiOzLf_O>q>m zJB!*JNC^?YUh)0qK|!tbe;Y@q+Gi5*77V#TtH|BSwMpV7-!f#=Y@PuBG(ewsf5#Q} zt#l5ww@RB~pjsi+hcA2doH+xHEomD`*&aE3c+Jh56R9A#Gym(;gN+yauIhtwMy8De z-iOZ&m6UoL_r@21vQLMZemvdyK3(c;50BH>ep`+m?l%Xh805u{z5kWzJ)1vP^y6~? z&GUAV;h;kj??Kx|rs#Hq=I*mf`j!j{W= zt_1i4zgMvLn?HZ(frCA}Q)8i|!4CxB7`b>0{BY#HNDx8k*HDhS#KgOH2Pp4~O=I%w zGh!+QCr{qwsZ(7X9I${G=d+7zRYRupVM5-&2LOHE|3-?#Q{bp8=RqTp&-eVKAD(uV zt0XX3w+HaG^3`bJsOX}kq#re*jM^tHj8xWtX$L!9^c^>TysfLY#21y(x=#yyemiGY zA%5c&-!Usp_^ICvCFnM2hF$o$YP-d}ZeCyRBw8loU}SW2AhCRZx;`LfK#{bk#beBi z=pD?4v}R3+RD&gBK`4G%ph5DV+TYy-@bhMn(SRSRqT*aAwF?)lh9&ca(RfYFPtD+w zl)P9GrN~*`&E;12xPySCwtFVZ1iu5S*=`h{xQ)Svp}46Tw(Po~tB z-ECJUs82L2YI6Yy^m2$6(!S9#v1Ry_9`5cmD#@cZtU*PF%KslzDa$b7^tGgfwEuMo z_Xp_fcl>omO$tKTMOk^j*}QN%N#J6%Y-tj@fy0N(?%&_-c_`XkYX0h@hPsac4p+*J zQ`X1X%-yel+j%Z)w|n;}G4+#3K|!LGLWBr&fepLPCD zMa668DE&EnflNAVc~WfN?{M>pFRAiU*}&A6Gy;`fyGsn3Hc84wv>b$cIUINl6+eb> zq`xtmk_U$dBe~%1$KPK}sDPH1yhOSDPo}F8$FmU3fj2D0rRL5ZO2(f|pf0b}@Pmqw zN;3dRoF_2ZL2u{r{(yAPpUaIjlZitM>pp!4(P)P5V~6RzG3+-Dm}ZN$nv}+E^s1(K zwl|bCghyy-G1ZiARQ}bgb5Uxh>E^hN`8L?WTEE298DY$&O+9+{4EuMB9PhgQ-Dh;* zh|GzcH=R7K`St~BhP7*lOZ|Xj84asDhr1kO;QP04$>ei#65e;fDmM?0Re^y#gGGxM zhkUq55}Xh)#M&}@x5aR_cKE|bkJSJ6ELX2SV}D!g)7uXJYO zMDNiXJ$H^GRgTz>UeKMwutm9!k%|9soA8KrWv0p_NwTQ3*h7i6v6m$4$dPrQnL)#~y4~i2!-?vN_$Hl|VZ}aq!1kSqnUF{@NvTy8 z9pMQJ@p!~fack3Q{qZ;Vk6h|@dW<>9 z`Zkld4_>P)YxPzO^G-rWMRm)1MsrE8$$|12)*>-@=4Y#&_}Q{D1;(POco&`Knl-2= z;`a^+S7Q(Zy2|M1niJ_w$T9n&2G!T`kJ`#x>_yIWK3+e`&%Jgeif(D?xb(0BStHHnQK}!ylB?DGjCW!rlr@*%aM@H%I;y~;&SWpW4Pf{7~`z%nfZXz z09=@t_-DD+koZ~u<6M`le(`Tt0%gTQ>PT7_7tELfg7h-{L=hE3$W@~ffvN#T@AvC- z*;GCZG#W=BiIH`6X^7JER&=s|Ks=9y&G+&44o7Q8&?LAnChpQ+FQ{%!TmKh* zZQWE8&)h8@T9L+DZ<#A0l2A@!d2DE*D1Lyvy83qeUb5Y*Eo0fuHmi+S%^~suFZOIO zBg7K?V--64+S@yLXktOKeIKT6&k`M2J#MvIH8hBsWC+B2vYqZ;sXfR|Q*u``Z@KGg)wyJb^;C1RDKfu|e9nP&5 zl6eu2w=lzyM1~$$H;=Ty1+T82fLeC05QI3`s+9j`?8$x6(bu7-Iz96-)rK3ty7~8d za-)r{DJ6iW#6Rgc*8~L6)@!Ykjw#U-LQZJv^j(JP={0bu%2Jt*vl;`N=sjgWmA=qG zu6G1N!nR634LSPYL6UVeunD}hu|KOGt!e-D?V3G?C3l(kCn+WXfsVkYtB%8O$*D^p z3Xli1&~2k1%Uxt&?vqE93~g4asg!y+!TolPK~u^vB6#47`e7R@^r8%uzTxh3W7YBL zbvRdo=s~NhcHMr3soQp2IG|?tliD^Ui+NqyE`CHblwa5Wo~^>D;ICg5&7`t;8hfZQ zDt$f!!D!u&)loo}wtiZ&c(Jrsy0*Q;LAR|W0xGXv`_1gzBMp;b$thHe{0If(lP8RG zAA-|OZ)GV#4%%c!5n>~pe1nz|JU0T1TS48Ff2KxNbl9n=ku6f!nte+T>WSDzH@aR&9vKhWMD0KNUgSG;|O zDeCQ7c}cGi0E zC3#3O=f;xg7w;%!HZ8o}+o$+0Y7!kC<@UO1p_RjQB$Pjyz#G#C!aE zImW9XJ(NX6)YQ}vp7D8m{)MP2jt|(0C^4YzV|+|OYq1Fne9r!oYrZo0k*p3wW)1#E zAt{o8V`OZSGRc^O{#sTjS*s?42C*&s)}A~< z=axXOO^xQvp$8xJ3qsxbzY_|>36TXKNH&63M#_$ zInAt4y|McEQW4N*W!Z(BDV7PX3*;kx-_bkVH0tO;2M_V$;3zqI6*Vq)OrY$^3!f zV7y!^Z88jEJ?E;8qTInGfZT`>yP)VjQ&2FP%3)Yu*Yi@v>fx9I@ zSz$1fu;7sT_LcM@@b6J|_0AiTAwFvJiSaYeK(nAbyW{Vxt8(%F@yK`tG|;|Ztquf% z^%NGM`_VI8$!|07+qctK7|mcxkX0o_S;`Rt!V)-0#=W22?~w}S@{=AmcI$@F!n7DC;?=GT@}>Vw8rF}4vr zS1}A5GogP@;fn`&enpUHgs_cQ#zhP@2PFa)$yI3Dh-?gQo0J0*FN?P7;|#)(W4id} z{t-f)xa3}~cafaROxTKf=29j_F#Djqic$%>f|a5dG$yu@&rK;68dgZp#+lpityBcL zNnvVaWE4{~UQ0N~LgxpsUVX+$XjbIx;k<<;wY8rfwiCL~-xV;n*P_hAhA%m>`V=WK%zr4fA+1YwyYXFG?;DAY6mE}X*7eX@%fBruie2cHeZ*Xntl2N zgQiV}FXSzDSUFCotSztm&$q^^u9E-L0=P3Y0kK7l_}`m1heAD?&697@N^$XDwye{s zZgtOeR2EVP_>49|*bNP$Q`TKMoV*pUi-l1)UeMse(+ZH~spbw64k-@oe;Zqfz#6{v zK|7NUy$litcY5tbp-9OGohSNdu-7~wKZ8jZHa=A^7NPZ_XQzVn?)U7;6CDDBIe+fn zG}F!eZCEe9ql?~u zeI{|A0(%L0wv^5QffzR|(umTbf+PD;X1sc`y`_bP!6C=y=j?UF=W4xz!T*6P(@vbI zy?_7Oixu4PoQSIPVwvz*f2Opt1rC~&6*d!bL&6Xfl&-Bx;Oa1&EhV#zQ*U>6w<8vV zR%GPJivq*(`Knei(Lh!k&+8^VMr_`->lDB`Hv9hlIM%0!sB)mYm`)2-9$;)N(`hDe ztUzZ+O3Ef~__R>E3FJ?G^)v;@4wVa82Hf?Fx;&c`fsl!#gV`s-G=CV&V8gCJT#8~j zG|VEM(}$IgR*B!NZGBifU7ITD9qa$byLH$Cp%woQXekhl&{2tPB>Iu&o~XYqb{P~` zST1ZDhs!E~p(`3^$i%`gDf%ohR(n6fe3RI$s}z4)$UL@S{90Kf!3!0HTP~FGzANpQ zJKKw5k7V3>qhX>`mNV=83PvVn3>hwQcmr?SJX_0G_BTtFK9K5_EH9k+AJFa9^_-Mh z#Z37LN1q>7&@x5tE}bmgb7;%S^sbHveD7yqpxLdP?TiAvZgRb|M3QC8-a(i8pL+}xbzY6KejVd=-g*$j;w)`7#=$4bQ6I!{|(NuG7r z*L;bh*kzHE{{eKcXisi8w!kuw(R5zA1071Jx5_8rG~@ zWr4uW;=^{zy<&y>FZ~b4LieVXm`W)k zVT$0rb2rwv1hIx$az@6+a^DCIK}m_$qGI<;$IL3I4RR*kjtKL)1Udy4y~k)4S8Pp} z`6o-fOhIRHvBJ*G*`N_OkvwY-%6WCZNz(QDkX?`Y_Z zYr8PPW^&trO|S9 zZ$WC#DEn@!)DMga-VD(Z5MUbgIciN;#-vnAXTb;?4gafgB1SLG&6kz|bpU!tL?5S5 zotk+mD35LvOLNtv*AkeW-67He*iHG!ernSjoSv>?L$Lq>g&COgWICcZyP?LK=2>w z&Ym1EgwJzNAp@_et`<*rkg{x;`(O|?SLEd8b}~0Nm-6J)DUAEgFiVY9HZpNsS;v0u zAhF%1ETOPO$Ejg+xs#ICQqVa_Oq9r#7HR@(0JxsssQKetZE~s4j=gG3-#}JoGwjZ^ zxtsdtJ14;SqCp!c)D(CNY=wb_g(m7b!XzPP5F}|^MK-pgB}^YRB;328gMw8MVNqOD z(>{zB&1HVnJ@$)HUxTcSjFYHwDIenBgmxqV^*16cQ91=Q+p=i7$4*WJZiFpc(eo+o z^}4!=|Kn(Ow%EB)Viiz^$D5h1;6Qxpl3z=Fd_I1BF+qEuke$5=85|vj)_-%uydQvd z>l*gPUmAJ4!Vrvr)<%p+O41{eatPzKVyBk#vxFN__{NBU5p3j{)WnVItdO^~k&rq}bB zs*8)~-LNV`;?>{2^;K%HbjMzVyg+7<07%NxNeY3>MdTrT%6OIuRbc&zqJi93^eH9n z30|RKGAY*zhOB4O2q7F?RlMpOu{~!E@7bdVya;iLF#55LK3DWLOtJ;#%KC9ayt*1Q zEtHbYx=XsRB3Z3m>GQ{rRzUwbIXNs1_-VuvFjSRqxg*H%kK7ftd<>BCbL2A6Sd6)O z_xN1mFqP&R#4J@leAs``potsXFS6Jd5mA;D6E982{)IZ4wm`#iudZU4IdM-yf4d~6 z0tI;GJlv!=K~!b&#Pm@7X^o3&d%_t{)u!93^~Q9Qmxv?E>qp9nJwN5`?Iiy$VAV zu4uJjbZI}(0aOv1PO(QOxM=+sVV@_Alv1*~}e4j)_>|7EjpQ+dJcCJ)4VKw|-iI(~^^N-}(&e z$*oHDK(MBr(Nw=g;kMk+uc8lT6~dn3qCSi5mw|3~_7yaL-&lTyaCG?}=7wlI1Xymu zV-(?zFIIgcmoGAR0(1}5HFI?le*uV4>OW~>$MQGZG( zbLKFm7MpcMYuZ?Peq@Y*eJL*IIMf+=oS2x%76(QnJ?0sr4FcvBlm-fcOi7K2@-;f?U~Ltb%Z@x7y9?4HC{?5|g6z!_n>i1+T^?f5&f z(vqWGvu97GGirQLpAI_yTPf@6ms{F3R@%s1eSGx1RN2<1t5elQ z|8?=2;y!yei7S)xGnMFz32+K6^}#&)h*!Zu{9NH8a`^7MH9#a_q1~rU>066T>FcLY z`Z|8FNkj}#Azf06F$GD{_NMATqQ2J96Immksr#ms@gc*sMG`I=Np(b{B~$#qeubSP z^&fiJHNl0!syaFhr`TgWU0S5>t6WUJ^Nn;~XoJ#1>OJ0D+PJ+a79e@b*NkgZ)6c5% z<26u>n&$FP322t%<&+?^y*KC~)g-?2<}segI4M0NEzOkdsUizB^h}EH-Fl+(>nR8; z)U2c@u(>6VUwyd{))>U-L?Q#wn`x=3wZMbpAZ#j$DKQ83g~i_D$$RB=g*qa`fpC|F zpzB$Wg24vf-RqtXVu*~UeH^`7_WwNh8;|q!8DLeWbOL0u_Tt*O+ph@5UjmXXydCdH z?|0e_?Ib#Ead7w)EQ0+to$T%9rRbTfpDW4BWuQDR#pf0b#37IHUEqS9Kv`~(WOpqOUBE~k&?WXm@I~M!iQ( z9soX1pQ7qWDfOYuLFbLwBVVCFwAWb(zL1uel|^`TZRVZBi-{ykN=hwV?$Nr_)21dS z+8Xo*KNITb*|W*Qj679AH5fvj)e?*RZ*c6*Vq~2 zbP#0D_J^h*;QGORDwS6jrPBl>KF>EoD*aLUl7{iHWWE{fAI%=LmctW!4pnLsEU!9c zkb{U5t{u}Pi6o@Nora-LpFSTw9ucOEr_S~wMTTqDy$)g0z*Vb|{%l>n2nD0pvn3og zJ$oht)S^$KmP&Ya!Eg>)*We;T4$hyP;NKH|RWp6_D{zOH^?Aev4ckd=Hy%(R>7OBSi(CXHW zj*f{cI%No|qhb zAc$3-aQ0?*C<(j-*==r)bBYEWyF^QjxY!h|mIuO>g;5`bV}-i#dof6`iAj|=-Rlg? z->=`R7ca)$`tb4XR30#EigGIXCQ;XV^6Nl(nK7yoLFbl0QZ5eGm#+>aBy>1Gm4}IK zb93jbKK|MN)2+x)u-2EIi(H=xhb?V_5fzso7_u-H`@Srt4Fnzx1QLX~0N3Wp7>?TR zXFf9f*jGy=e{oyY|L{}SK7E?W{dAd_Qx*hYGvFCOAK}!20l&6XAFsHuze3&9kGs$Ua}s1QkClgu6160P z1Xv_k@>O@Z(nqI`f$G<@zy0b{4hmu$J(3+Ue7GI)z|f4IQRa$J2i(T6uRI2g-ZZ;C zd(NZ?B0(8UATE_B+6$skTKPS`=psCrPl?Yz;OoHh53c^3Uh+qxE^IKfaoQqr3#&$LF3r8FJ248OP$nJWnn?G-){>2K-T4 zy=v8#d4(uGN~Wniy|St2qn_?ZJa7s|SrrC;0XDGx)(yNEa$mU^br8=M>?W;Geeg|D z#Ub1U1e3g`P`iq6gc`WAG9GAH?c!v2cm0g5P0s?PMoH0)twd?1Z6tu{C$3n1g)bW? z(m@h77v5tNq$NYPdh2VN7$)-iR3>N^xlw#>V zX*wd?veMG6V|8Gumu@`kJWZA+q9MK?d-6)PMo+5&#X_Z@xXy21M0?7q4Q)YJnP2+ zjSL*9^ioG7@IqT>Msm~DJ`mEdfp!2nx4nD`xYFU@Bf#zaDW4Vn^-YW^R1gYZ&#*}OMJWbF}W;cKEY14EB)86wtoNyf8YQf_8!{Wu<*p9J5CXg zFhkulg8N#yz8f0A*s#>Gv2l9Qf|)84HNxPnh<9!rPedWIEnE}E<48m~#pA}p!8ioV zah;#({!e*(bN64;6agnYEA{qx5m6$70@$L7th)Avqh*z7rXqD;zSW9tq@SUippT$o z2MFjnWYjCz&)hi=bG<{8e)ZyB;kd!pDvJkmPD$z2q808PA_kspY=KSuE-YMEZB$tF z#Dle8QaG~>NtGxF#Gu3|*>0Me<)9lC6(Dzvacn{sG1eI{8ECWozH;4a5)KH9j`1t% z{Du*KQBf@DyEQfA+ZHI~?jF}+d4{v$kpE580P$#!M!Ls2EEV-PGP2(Ab*V3bW(y{5 z8^M6rKnDkroW78f1gZckuqCHk$vPC;TTYXJX&Xc^0$?yb?l)G-*v|EL46kSsO@tC< zGGaMxd)%g`$}{$9EF645jU|$C=#c%SNva|11=;fN&l*)Pt#oz0^sZ}S1eosXx0cBX{_;zUeLC7_(JrDF z6+ODfNu79fjo6@nf8iy9VQ$y}YG4rF1t*JiC++1rn!v=~ioj^``nZOqnN_qhu{tBT z2IIuEhU=GTGc@c4UkmYoWc*@|C6+I@3pawC+{wJW_7mF4T)CBS?eWy3Tt`Qj(uS&6 zg)FBb@-6SZ;W#K19H^!*7*2S|{m+~`l|8#g%@?G!%ynh2c?=jjdh{QlFG}#{`}8t~ zm+!pi8Ut~0edwJ1KU*E|z$#r&MnA-tOH-P`X2$^mX|&ybULGp6>1bpnKZ1j4LIg*A z4(=lQRcez5BSI{7T;K`_WTT2FNeFQ5`^M3GuHP&#=k&z%!}56)6wf{dgoa=M0-Ip{jq_j5W+5(e;X+PRc%*$H2yfo}hl(+O zz>Vk6XPOqxbNW9>*~jLVzvk9V@@Cge)3z7&5gh4c;yrh;b|u{uigdObkUD+H*p;99 z+G^+J<{Fso?UyqX2hG&GZ9k=V4A0Se0%Q4NzeTgv4(;FHZV{VTpGeh=KtRzt>8CuN-zEMemEDjkUQht+2!D#yM3sYutsRJkaSsJ$DPE`&vXbg z5WH(smgSo^UV_RKFqqp10EPVJ*Hu+M7~wH7kco5Te|-Cf+Ku-#>)xWI!w2-Wb&G>q zfC*T{(v#yhRnnltw#+HEG7I=WEa-C zwWR0eQ9-BNsHI~Hw&=^czdwhZ2Vdh!uv}k;V+}kM>%USzOpQ1WR1S8?*iIFJ<84k| z(8U=H$s4yYdWqSziL;?^*BiHPEh?Y7o6IB*F=%d52B!idlhBRx5h4dUU+X0*kCwyt zm_NS`9!|NLv{R%+RaL&|(0=%=;LS|UwC{~O=)sN8*9aFhIM=UTdtFyY95I@AjNrl# zo6I=te}7|-fdWse_%}+}qK^J||&B4Bt#!>D7U)R=+Uw(W><&y@)%P`Z|4|K4;Ublez&5 z7Yf$iCumtol!QM91HjtZPA6|%l0?OMygOc7&=msvVmrEr&z}*tt-m*$NH_TO5M^w@ zh&Th=H$9Hh5jUiW%)@jNd=#3d(GK8m>@tMce}uCqH>7>7c7?Oc>j$}0|35sQMU5@= z6}0`7o}fy$*9WTj%2;RUjKmuVkf9hr`O3Y+1r16C<5hDoyg6w^1nc_M=L?h&wvk11+^heq~wg#d#;3Fd^T8E76UF1QhSL4UVNRKCv=rE#k0 z^mNI5fc0MxowAAx9s^cvS75xY zsVmc{+QMmv5>?A_+9%s1=&+avultLa+_12}p*7BrD=GxhN8uSB*dLP3I=`8X5Cxs8 z*;m{46w@}5Ub1`4C143JUHqr@)Jnk-?`3OPf8Xdaem7P8K9A|q-X`#&rTZd{<10q} zI^`;!Sb5;`icpQke{!B}oMto8ucY&&?+JN+<3!r=%frG_Zq!+QG70w|o4TX&zudnY zt0R8AsLZ{wYG0FO8Bd@=_0fO-aT1OgF`{|(Q?B)C(_l?o$1R^dJDsh-64;uHalHmv ztd@7|c*b-;WU8R%A%1aq{m>&^kjp!Oji4Xd(ar?za7BfhszfgA*ZZ=TyDH2sJ#v}$ zadKw8@$VdkYe###cTBE4e(XlrLNCH+$QSv}WD2|zdc^G0xf?bpB$mn#)hQEcFN-iQ zq>*7Fl+b@=HG-P;J_l)LXan$OINTO6EkAU`s8M~~I!$qR--&QZA_w^e!Z=QKwOyxi zK5}QSTAn@@!#X6=&TekwMFnx4mI9J7s~l!zwD9RkdrzTVSN8SuV?dULpa%lwnpLY< zGT6TT_51fn5gH&|QB@uK$STVIz~U#Tqhu4r_+_zb;6Bla#hkNEbkY)A zZzKp2`A^ldy|pjLsdh#`sIcNKi-i@Ky71+MgC5`x?eB6sM?6;giIBW^uUNm?`7Zphac@2Y%p% zLuZazy39_knqr2ljqAy$tTy-}@Bx#10QoIQcILsHKf7y_3ieX6QPX(9p{Oo$()v1( zYt}ZdrkSJYD?OocwdISFS8vy`wcCyf=>ezRd0|5M`^MBhly$dn4>B}V)6gJ?6r7v! zEFYN5gABZYfVjJLklNXO$ETg!=)b(#X#fR20&{IH+i#d%jQiY~BZlMI@lz zyxZ}dm^YCNoM8`2H~sJWDk6DY=hgc5wH5vR{aZm^-lb`|V2T5LfRQn6ki`!?(8;EV zNEk1%cSv)m_5eKDe?}=)kg@N>C;(q#ntL?C87dV7yp#S_hR3-5&b@oP)VG9My(9+d z%Ej!g#1F%q{A5g}E6y&MWNTYY-%9}*$&8D=`VyZu*6QIY?j9a+ z0%w!kPhj53BVyN0rR z+}FnCD}b~F`v+xRRgpZ{nZaro$0^hp@pBI{swek_0vHA&G!p!h8Bu!aayeDU#oHg? zj(Jj1!L+$>ib^uB8J9m_aX$Y~&Cs`V)zV1Q8mh0ds|+UYu$7-?;FS4Dh#9BNqZI7b zz5BR?(+PAfuv&2mWmH~ZrYKO>fcR%jo;=*b!qB_XhdDoGH-7SJZ#j?XBxzk(1<{Z1 z<$72$S<2`|&r7aYT=R1}lfou@ zlIzCa$SH?aQ#wqiswzToM@h2j7z82O9G{D#Y;>$$H|VCl*;n9`Kjz1mFQ?C*O}YMW zi%G|2;^De9Klq2xPu#n74XqZdxHznvwjUI#UcEMK9b1l|_7RwP)h*G$p&(l%eRF?T zBrz6YQa=06T5D?#y$&-b^54+B0V6lY2oBeWuFi>ykx8rR!5P>zTrtv#%O)S3;#<0@ zn479%8QK+?&MtQsBu)l5;>r`HL6NEIQrN#oVks48D?E$Q9c=A$d zg?gM_!?+!UzcyBkGo7g*+}i7?wp7*g0F`;?Qo=wDMrH6G2kg`p>Zq`?+_Hp9(Uo>y8Gy z{C-V;%U7VGYgqliY@%pArnKLM*{t-DC=DVITcqY+y~NU7)a++>-)L3c>ncQNqejYj zanqPi3G70ecJKD>56qZs=N#ozJ1nFc9Z6G80>M`ksvH8~+sI-;)rC>@BR?kJXIU_& zkO+JZxW17kKk>^K3Q)-V&2<7Qj?F>A@z?)!Prk+dMYPA2pxWz{zZB<`>HQOKqq{?8 zD7S8{C=JIqEi9lPBukyLX5hCemgqMLQ}PmCRjF zb6$J=zMU19d)dA*N}BGXG(orKb*jcBH=$QOV`D1v?yLTV=~etLkrQ7qvGxA_^C#44 zrKQtIMWxl`+IH?6f}1L1EMY??Sm}pdLgYA;Dil+Mtlc9*e>`8=X3(ZFc3s~e$;^&H zD0d}wUB|zNbyhqx&)twwtZH??a+M85*lfW&flYw@6R0X!rLN|B---(_*Ey%;AndoT zEC{|>Nl!|s947Cp!;IVl=;`S>dc_B|AyJ0<`mb3AB4XSxaxg}hsM#j%-u;Vb zs4WC&3guOox)vz~l3)TF&_gg!r4E@}Y;c2TN;e{t839gFxW`Nx9SRKSv*IInB_pH- zqyG$`9$L}d;2X1~N(fL2>ENtfB-l_j%jw8?8{wB1N5b2);@I5Y_ z_Qi#zsSZ;;7)>zduvLt$?SxpJPYyc4ex*L45@(PxrArqTDEvJcq~wlwbu~6HurGFO z<`9GW82wo1fw~m;G-xizj=4l<`!KP#)?zRnVfT;~_%fun%eFb_;}EoogN(Lc;sw^b z$?0CTA3q+Ua(}Vtu;)8q%B>GDsbuACNWRAH_yMt6dG>&4U#1j7a@gHyLHPm`PD95> z@a~YE2oV|qUx;l^yD~{Hk(-1ZWQ+C{Po6-bktoP0d?OCDlfEUgM}AX?g!?9s z(kZ?;6pi4Q`PU@%T2ay`mzFQWL$YKDmkeAOI&o?>==I1>{@?c`<$&FrQy`ozUDU?sWE1*_ajPe0w@>YDDR_~qcZrFkloPL9=912p* zwE9Oa8bJsH!Udlm>OM#v zIJIM%i`kz$U=iP`r;FYTn{U=t&D3|uqTZD4U9@CL*9m*<-U&KeaBft^Ff{`hE7sOF zJzaEi@r&Fi)RxL!LTI4UYR4p&4#K`zB+8%PivfqwG-|sbmVxFL@`{{$fdY|@9h7{Q z$`tAopOqASE|qgc@)5|ugs6DHuAGlOq%qfK<3b0CXv)HMf~fA(r=BOLR0Ut8yh4F? zY4`4@*fh_bdk_*`&ZDg{6P%cV147GQKhMu^hyH{pOB706In{SKS={<81B6kVi<`{{2-S zKD6+tFnhy2jJk5Xt!)5qE5N<2o|D#99Kk7&<7ng1(}mk<&MMY0$7yK6M1@hv#s@aC zdk_@@i2%Y8UdQ2(yP;lbx?qhE8;egW7P~pD(6+TNd*?l)!4o6waVeKhoY-<_UajIz z!O?u4kB=$S7L=^hPq_OTp$I2|gAbub*B(C}fz#!snJ`HB4>cEisdl!*3X9EGP~eLhG#N=+jtb7E;{)r&3~&8Eu!;+}ne=95~W=l$2XacUf~3#9uXI z*Z@@{q?t+02c%Z&Cd)n^zB(8@{B{L35G(0({y!njK+j>-*hyH%wy3ad;{h z(Hu5NJ$O1S?yRwD`|0{Q#N@tv=LA+WU;4((qWdFgl-%b4fS(t>#OsZqZ;O8^AnB>N zh5QF~#@250rcJ*uS7^0fB(*rYs}gKA{23Yyw%!;;=2ynDY;e(>9JL?e<65V7!s=(x zgH|1(t28Lj!gf@8Hw|upwI-ABX)Z2tAs-&mjuaGV4;<*6BGHjS*kg#G8I|(ahW2DE zdDNw)j()!MpneM$C|=3Zi4(7$X?+%+IBbr)_&mmVF!Vz}pFh}U>eN-7#u*!PANZf; z+)*y#rn9vrDpf98n7(q#@fX_l5z8>F+C%sYfE$jZ8;aVW(0Xp3I^3qdh(*uD+P3E> z-)%&I>ts#f(!mpV?Yw^0J_#PoO-yX4-7hkvSYn0X=-Zn;OI@F>{=s&nSyPKH zFz0hifPWVZ92R76s8Y6P9TQW*gg`Fbhu3)atm=Fj2eVK~IJ6i2lB^C1ak(j?CrtZL z8x`k{ZTm^%`51tpSEbx8n()qAnr1p?%w7jopRI$x9A@C}c1?6YtYIsAlOBU~P0zXa zT9v60Y_u=)vO&cR_Rghs2JDbX>7OY)trfub?)we1=B}oz>8q)^B_>8IGFf*bU)w;4 zPp^j}Kd9%?yu7`4>j-FmR+F!MSXncf5mhf=nTKk?@oUfGnUhfGiR9(ziw}BmE6}$N zQtix_4nGkaHk8v2$(dA0suq}}?*^DKx*0xvMfsMg%y<#uf%pXWfXoKPm;ZM!=r}kd z1;@z~Cs;xPxe0Pv^#zKs#;!L#l3BRP$@G4Ii0>;bDr#JZ)oT>Thqzu&=gZKI=2A1T zt-%Hc(br}pprIYX_?ueWWnou?LJ_R(*f@6sNqb!=f64in+ZufsR-9>`Ah^OF9*0GM zwH8+u&H2u+<9f}P0kILq?-D(G1v);4>sOX+Kpo&Qbg)$iXK_g;Cxi24o;>Mx(1XnyhE0mUYJ~U~-c_aXkB1Ez zG7%RjA5%zxvx9e~@OD0yy8O3nwB~2ZjhtZ` z+qz13uVE<=!{%L3+L$`H?MMk8Kvhh3x}9Cr&TTfHe1wckxA%;=OAL4U9PUnj?VrBK zLN_TLpUOE%ADRTmsH!C159hN_kAdM5=yLc8VgTj}TjM#&8h=lXIBJI9+aMW8| znGC-!m)H0KD6*E}x6q$DTaC3n| zKY_i0tPDyul#DPx8B@Wby+59uBU6h8&F9%L{A7>`wArGaOZ{$fs*3%Gi)l!okuwt1 ztB-2E9VJ?;E+jGbwJ`0`y(SQQ1{8W6G&}|!jnU{M9A*tH@v#%F3JHlH+>|U$gV%s` z2p=>q`okR|^rsAUw95$Iq!;I|B^L7i*!S?Y0u6j5B&Wb--nK1V%p_?w$`;`$Ce4jzZ)baB60V`$ zAysFD;}d5f7O_6DG>lFDy=L`l!I#bN@VDZFX=xHo%@fd14th{*vBSb8gfiqR>&i~# zm5=;ZnsMX^0XG%*?-$fHRhvgBrF`|F;)NTlqswm5atuwBt=86M>*$Ttr9_&0=1IK2 z++1%!)kzn7VOe|0QC(N{G3qAwzI<^71qJ0I3~^&lhT5js^&1`1PBM<;Q01u|=Fe*z zuk|YKVb#3u>bL)<7yC`+TI&>v=gkmhDnV$7u-O?4&OPM?X^hf{s@_&aDge!AZiH^E zwN}j5tv8w$fLrlW(#C9eHlyIh-LY=x}55ZX9Kt zejG>%zDBt9Jx(JvHH$~RCV@enKFge$`)NY7S9*-#z)lY@6NnO-??Ohb`Tl`n0g!>V z3<|Gs8ds*UTBmc`wpp0RH^^m9U|E`;p<_Wv+I^uykfmYG@RrYEt(OlToI87VAuxx3 zx=X8CX*+tUZ4OjkFina~k7Z;OQ{PY;x2U1;KR1Z2reee=yv_K-cdnaen}Hzhl*K_5 z7bKX_usTc$&rZTtfV;uZ564Kkb6u_ zG!##@3y=%Qwysi|95akAfbv2hiMJg6-a3<+Gg;l)+2DnZ)EG6d&j3zY=;9Lk{kv1i z$^-ZPIhZVT2N@gp^py$Q<&+nHx31|Y*Ac}W*cmnRgO-o$jhE$%s`v7Tp~Gg$1^UgA ziPco*%V7RQM|a=#B<}yX0MX1bP@cmsq3~w((--#d^smWgSUJ?Zc>_N}OU?F})=+cv zfyT!3%yxmduyuVrb>_%wFr{~ea7b~q-Dxk43T2Vj=pm-2fjB{-hOAl3q)|8$U&;H; zo$I(CCOTvNn8)%RJ8rx8M#-Z-Tv9lS24hmey3<4G@tbKuNDjhqY#HLy=4Kun*%*ub z{N&%2>X}MmPlAk9jQR4jg+2mL2>>2Adx!?xPqP+rD6Rc^f4pCF&lx#^X{2r09}vdL z7?0@d^I6z}H63f(td-z9F}z@uwqDVOqDaU!&0S@l^;Sz17QbEsd5wNfH#SZvw&D6t zoG8#PGoIFQ6Cx^P?40dvC&Cre*3NFiBlGFkMXm9F#k%-f!R0R=)jQsXH^_pXFMB(7 zQ&HBH?Ew4CUu^9Sn|fb6c<4|k*Z!;*rqA?Qwe)UP<7?I`bdafYpYv1&dyGC&gj0FI+YrucN3OF7OYXRNGJw|>4OkXyNoO02=)S8L^ z^-h?e6Bqy1JMwQBB8A@l`Yi>1B1-f9EU+F(79;-o^5>{Q=cshlBmd0++uV4 z_x)Q^Tr&X{rSHORgD5?hW=DnAZXThh+WO?#GYX-9E&5ue3KaY!eijKB$V+EtVN;2; z4r(S#qk4Qzo&ygkEnp5^*y)px#=4kU8z#Ew3Hr13T|m4+OI)X|xsTH5ceL9-2wJX#PafK_%&bs`^|*;M9l^rv509)C;hqjAg=1sPFu<(Y^0W! z7N!(hrM7)j?SaV$0`mV~8*oN?^A=^MsH zHc$Rz13UIeyRKg-(jKtp$UDa%dmNny*u6ZC*{S<(1?zsb1 zZLp%QL7*Z)Z@wr>s=eu2!aXB3HD4Awg6UN3@$U*X&5?NNp=4ipG6z8mV1((pl!<+1 zx|G-*I)vz-%?Ug?@vSruMsENPZ2VZiVC*XfYQ-*KdSKBd3I{fcz(j6 zDI*oXF*`%KPh~*6kvlks@fRnutx-_8kaPGG*tEI1AtV=)f8RnpB(F4;jbhxD^vTT2 zP*p(tj%^o9(bx0u=qP3&%`GukPAv1xmAzWs+@P&z4us3=ny_4LyAxMjdn?$7F(30v zkBu!BNu*G)At*3}V$!^tro*9p>MF~QOD=IW^NW&|qqg}kCP-&boaj0u36(|A10RWC zADg~{&4d4OKd#8V6F7QwwY6!nybeK6FylW1;2tgT#jL3JQ<%+8Y-l8C2zkiFGrs3QRH02X_R8PmA1t_MS{FbUO@rQ z1HCyVJbnzY3+(Oe1l^mS;viaaiZB<0NqU|zJ}E=zabdE;Nj;>;eTi+%=sjl4^LBio z+SPet*%}ajp_`N>gMAi3HAsAP{)lKW=w$c5ILo1T^yn?Z7>@joG?#LMVdJ#v<^0~8 zab?h39?+p3ZF#qw5!xmC+D=Mw*@q6z1N!juikGNY!#gMwHFmI7^ChIp&%T+t_$Dt& zO&mjE!GXS}UARxx~qu* z3-FS|C0n*`?QnbFMi;TVAQ2Nt(8tp>HC>g9)=Q8M6!k|EnH~JvDO7$(z6kv!oQwrS zr{TFcWXLSygYE#XQ|rfeDEzAQbnci{UyASE{S06ajKWB&*U3bnGhhmenUFPuxnd~z z;8`HXm~&k@%dKx)S0SW?VYS4WX2PmNg-TNkG=~w^*|Wu&xygPq3crja8hTDL309(# z=_N5rzl6ph&?1H4N5_@-o!7;hOR38x_G;K%J-v5Cq#LkgiDsWZ-Az8-==k{A!-rqV z%4waToEQ!0O5&e2yHC})%K%X>1IZ6efXq5D+6zAEirvI(U)FJ2ZB{hE84{v805 zc{Ih1pN|jnHeh@NwhYlRaI2`;NkbYN>j?cDV6gwk*?;d&hD?MqXxFZ(lr$VJ?Dw7~ z-Y%opJ3Ns{Da3^f>C3loGtUQ1_42B~?~1`mFHorgAv@<`lUl{sX|RL|ZD4Sn=HStz z<>mkW{=Fm*Y|M8QwkdkQFTJm?7eX%DgmHKPZmUa3lM6nQDyNGfD;Gt|MhWaTe5Tbmp}lmMpH4DRA`~f?X#vA2G^20SN*J6C8^#}^Qt)lwF~hdG zxp9s`;!n~Fm|aIjN7KH~c;{^0If@*UwBLfFViB#SPJe@NfJuB(K-&mN=*1#YQ}bWE zc<^(v9;O6B)EC{zUCLWdI#<{K%FAK@=66HGcfQS8g30T?Wm)u-%}?g1+N21Un_&Z# zw2xPH;@+p=kl4Sis#6n`u8`PGGZ&ybVCdmDZyux&Cf1TBOFK2~`2MAF z?(fb;+G0Fp0yr&nnze!&F-1 z)+*Me!exf@qxFgYAgZ2thrq)uN}df<+2%*bG*Etk@3>EZ*Eiu< z3099w{r%U^a}{Z0ZXpbP9~978VJY6(ejkloypdlh14F*M?B4i(Yvm`4bFg`ZH9;`@ zUP=q#@$z!$eD|L`A*NR8ktb&ljV%9~U;KvPpOZe#Iq-Y=59a3R9I~NeXJ*c);I2vR z7^mhwKBibrTN(ql!mp!86aDIMwaJ1MOWm-=uYSVxu?}ma5qNX%|Np2mUANQ z5hE_ZUU2=rJU7Wuwe#pEMmDq{_x<5vyU(VO(W!pccy}_#79$80XXD3jptlY> z#AF4R^>FVqZEOshm+_&2{M73;n^Zf$hES|iq%!f)*opru@9A&6Fkavsq#gxEb?829 zXCa>>X7FWO3KX<_{gsQb4#^3AeG{ND-uDj)zrmM1%vugH2AE|3{7Ck@P~fY4AF;a=6i_bt(_Y82 z)U-F-cd(BcaVH^PR2pyYg_RXKXRw+f17M>ega~4$4*c+Pzg#;Z)?6@1*mkdbgz_xE zbgctT-G)fBjX~4yaSLd7?l$$=q(4l}UYm5To3Q&Bk{|4!8tf-0nn{(5yh}vrlxTBw z2X}k6VA6^e!(XBJSO~nd)BXT$C~#pB-{_ab^_qyHlOrkoaO2Mw44^ti+8nqqlllUp z!kgLut)t*hwHU_%kFYP40yOJaILc|MIWCw5aVLo{*n>}_2H{y0WmDqy68spuXU$TT z9wy+IYrJ^)8c(BtDic$7cF`&nRZqbp$rsmHRIYR)W^s8UZ4eF!=4}gpVuKAphC$f? z+xEJ8Qg;=zLeLxrGu0pWQpUr_q_bu#i^*dlC`weEULA0m*p` zK`dbR{`p=1h#0QCnCWFpQ;hkWE)*)&Ymn1vUaGE7rDn%4baL=16*IfQB6nXDCWOTlqXPp@51ZaDaZ z8d;wjfOLIBy=WCa*=_apBF789yHM1AUL{$g_bs(FfyM;` ztiia75}*5^L%HVqc5zPySBNQM~6W8b>fZPlugKA8BOLM<#rU_!*wQ4wh)PJ|K^ zeEp?Z3WUu;fg{P?IxuZ{n3KyhthLx)oo~x6&28+o;N;tr-J^XWp7XW+Fl(X+a&#4E2KY@2vIf*HbPmn7PFzY@6SDcY|FH zM#@E?A!)I@MY>c3s8l1Z-Pr5ofM+c4fi3v@sK1mwkUAVsZ{p-M95@iBqUrGAT?O(c zS9r6=&Rw|>@!aw#jqlIL5#tu*W>q@FuvHilO}{Iq)qxjSRI1~ zg+e~!RCuvy1&0Keo`*?yB+~qbBErRhfjPH64+bx8F4&rOToT{E#KkKYntlLBQh2$P zpR;}x>ka(4CN93BgMEr5!QJPUa-8@D#ugj`lQc{UMQQ!CSE*?y-`dPG(A2aSVHpnX zjj&lFff;~=0XeBf!5V4NLoNcY1y`>}pB6!#kv# zStm}|iv*+1j-lu+8S^6t*4iB_4h{-x|0AF>aX8s4liRf?hY1n;hHLEtXVaBstcFpqQ45@S(>dezX#2(WxZbo7^yT*v^DNu6c! zc-o>zDitRnlFq^Y4H^;#(cWHOF=80}2eBnEf&X7dlAjMrscAj z(Y~XyK;#4?N4d69j#=T07v7E5!_{eBDSS3Pe!YHgN3RkTZgRTC9ddZjqWU>pBgH*U zY=ax5_6{wJsQb>)7hE~hx|PYITZ+Yfz8cRRdooCw@U$}VP?ndkiitJm{~5erEK>+~ZZ-XoElDaGoTI zc>MNdl4SUZ5zbwEP>Z69j(lvm=MK1${x~IlcXDmH*(6fHTy6k^r*9Eo0-s|Aj)OD0 z)YrkRTR7qk3f1{Db37m_3~wYg&j~Q5$NK8?me%hBGn;PmsYdV zmV55~qQ&p(>$S_=sq+Acs8Ou_sNL0-uY->h$TpK@dvYGXbCtGwV|PaJ)ix*+q$Ysm z{NMIhq$1egi&!jp-r@4Zq=B<*iNC*CB+oP@{Bgq#k_*_sf;(tjkN0+L&JL;dZ4QcfV!qt|?1 zSxH$GKEek7Nh*y~+1cn|3%Vh7hQ!ofK^5&TKflBQ__m|MBAoz**l;D|&A}k1=K69XJ5c#N8oc_WL3qg_^f- zVd4D(IhpkCw~2BQ9<#w5xB%TfH^Cu5NSzGfZ^w>#Y+HYS3#5hg4}qr{&-LgW74PKQ zqD$Q*oI*=~U`nF5bIqSg-=IHJAr>s6W5!R(AW?Bd#;H@=)EsGYSw@&?$UM`iC)jg> z!#k%Fw`{{eN+~X&SGPsm>cf)S_Wi};d1oN~5)q6YvoRp$ty@feU?}Cg2!H_Mr3Vi% zhay&D4I>z;q8{fXn8(m~mFF2sIHrN)Z8ji3e+&#u70!>HFGBD`FW`8(nhOsK;`-8H z7}%^1g%HOy_msVRsja4X6>KsmQSUWo%DK-J=G&$v&S_%45cB38E$olPLeK;Z5ps4)Pzznb*Jrp9F{vN}4Bwb&D0eF2_?Ag(Uilc= zi{=?{;cD9+xDOWDTO!epx@CQs_@~`oxW(e=0$YVtVtl1&F+1pzB=fDYaodo>WQp<; zaZtPYOtjCh{OmpZSqXO=E!VgTsZ4OPB>MxaA^JS3jLZi-aDUS(l z7JKtF2TeU)4CV?`;6`m*h!o!z4B@M;t=>E+0L&Umy~7!Z?J>&Gu%w_MyxC#Iuwj4Z zs4+te3kn)ioWQ9@lde|EOtR_DJ4FwrJY3+5J;MP ziJuv={zlVw0sI|OQqRj=F9>XN+Tkvx?O6%5f_n%K*zOdy+@wv$-M@F^Iv#6l-yQT7liClw^l9uAeZ z*-rv??x!+w6pcElFA@3@cDBlM#67yYgcO$W7DVQ9Y84qJOrII z{4|>PUcT(607)v~+4+xjtJA>VIHWKobT{NXCpmpdm*)=bOXU2?4$%Z~m&*Pb(YzT? zZN3hd2$I#ggI>zF?y+yO~(Nh?fek zf#Y}1TMI&)y;jt+Zo`tD^v}YZUsoo# zodvO+1&Gb|jahTOw$bMe`0xL$OL&7mIZU^i9%8~vqQ=U36dx7m79~I2`$R0I&;iaS zst9k~2lwyeX%hKkR)Rzr{i|V=)=GsClNDJ05A>JjoU+}+27&_aJPAQebbgH%{I$#n z+ys48Rk6RpCjr%@oUoT!K1Ez0%+NSCxf)EWdd&;V-GHr#-0W zjFaCp%V;|}3m>Um5)+GF2P2+k=6T@)A(7a0F{oozXZ7@@i38J_VVrztUTd#7cRc%a zbP8#(uV0@BW6PsBwfV5zvw5R?zTs{MBkHC?Sey5OwgFU`2(rER9shINwjnv}{i=_S zj7}Mn#NZdnK}P>Wd4pH1Kypm?oC4jI`)k$e5{Olu`W{9?^X0$kODQUzJ$f|E!Xk$m zbZ{N+{$MSqny1U0Un|G_9~VHvam1R4-zfa1lG%)4-J8Ew6zjWGk*V&mg~%4a6t3Lq zzJCkX_^UT>AT$6FPgtJ2Z=W|+>XVvC!NkRL(nmZhj~skX3_8~t`bsGvskWY;%))k@ zhPLBEJOru5gg+`+41Gjy2L%rUUZt*G(b2%5L!Rd|Ztxu00|O{xp*A|p|GaGd({Ix? z24169ebF;@#z_jX{bflzU2gCj?=_e$#Kl#`iLVb`;2nK-Iru807TSNo5o^Fe&KcJV zQ+F`Pl3J3HfS`yw2o0S<=~q}dTeu*QkR1P}BQ3!ctke&UeBoGdV$S=&rTtS;FT}d| zk_FPqA0so11*fkK99XfFd34TLF2_MVZxt1dlhaiei4GHhh7_c9$2MgIfeWA&V~Ze? zXOU8Z=FEzM3s0;iFGQ|OTH1=vy?cK78R~KFFMd@VV2iLxCo&$fk+SY9CkP$6l`=si+LuL{P#k$x-x z-7?ER7*V{6)p5sgOO-i?-005q}-q25E(`6B<}&?N5v|7jb3K@}z=5A0ynzi(VxCWAas zkvM+ueN8=$;2dk~cIrF+0;8b#gQ~)I#a0Ey#x?VA);P~lk>7hemqjS23wLN5+Eba~ z_pf~NTX*hsX;d(a-IgAfl;XE{E<;Ky*3j3wZg=27%Vf?Ma3og8<}5bzb} zZsr0?NUK@AyW?-S%3E}7Po9)k2XbLUP|@2XFV{X`v+>MJGVwIX-bem9+K?)()|F|B zrL0aMsUN+WJPL$y#p~-e)A=b`B%(Ay4A&_4^@e&iOBbhi(>uvp#>z#wvP? z(q^SINwgMSD=u!A-f7qF-If*>(2HRuI;y9orp^MRtuRF;2$18nR=TbJt_o3&!52Zs zU|ym>ru}NdN=%dd^J+TI#KaD9rpP0V><_1>e}R+@7T{cKS(45&>DHyoV9Fu<{+h2a zviHQpg2=-9>grRtEr7V-sRW6cjwmtX+BoPN^fTl+?z!_HjQnIV#xu{JjWRO2Nn#o; zJb?hqh$5JkAkezMJ(}Xa5~a~%Kfj^Rds+7e>BDf@IN3S#LE}h9RXGw6^!UYU} zh6@jeMO#GY-YZRiBRRO|nW_~}K136Ql?dj1Cu8~uuIPS0V0CWu^^F84=;a#cn#p;7 zy|7Tusw43bw$T=VQA8s(?E!GV+8zq06@bL2Fcdz~7>7&&MvC};PFvhrw!Z@Zf)>HtNhwO3z zd*(2taaNRM-FHdjf-!A=bWqFX$L0Qmpj&1p1mCE;iZPA4U>U zD3H_Cl&^!#lU3&!9q2*}je?ur=YOnY4m9j241#B?s8usOp`b{z-iqTLL|V+)3eTI&!vffuNBcGzh?3;SA9bFCKfZnNa!nJ|AzX zw#&2;0qJnc2>U(CZUT7a?0_nuN5C_qHVaMY)YPJsi*A=rz@5aCzS9~<@}GpG)7N|7 zJO+^b9VDS|SlA5E&;VghW-VgSQ#hf6y~Xh1o#GgSc$?|!-5rBE9iWkVoy)el!+!FS zR8-QTQgZ;I=e(;IrnWkf`kpsGCGRtcSMXH*5pol?>%S0Kn0QK^&(;- z$D!RXs9Fo>&g~(Q2xIWjiFz}`BT4GUNlELH)fGM6P#6nP-L17kC^aZ5GjEn9B_;x| z>Qo!ox=kU{dC3xEX$gsU(69OLK*i#y?rD%OdFa5Y%0*PVrH()N8}PoxQnrL(z+$D1 z-e;w52gN8U5dDV_&_IRWXZ7m!gLGtXm;Hh}z}IU!@+i-yS2dVU@69{*?j2)gb%n@9 z$tIDc&M4S2xOC{=|37$#n7RM{L~b*zi#0Q&-AjaI0mYaSP!-NO&`YK>OsT;UfT!eN zE?zLRqmW)pGJxrr2mV6?A9}(tlA~dkaD=%`QO(kIf#KlfM6Cpx;IV8fONysIDZG>) zX}=QaMaYHY96)W54{)cTV&kj`949v?9;Pynch{!T3LgxW)&XxPb7oxNh|HY~L~RW6}nH;SgPXfe$A%_0Ju^ z&T^kb@P`i{=(Ac#JfPg+=fcenpmf}m$&>>e_c0m$dPV#4$hg~i92OvE2;;B3h5 zJUT2G8E;ev12%yR3yhAweLqxHnJy?LmGlWL%&Kods8HH1j`z}<2nHK&_omO%TDu-DnKQcME_(g@FT;h_7H$rjs3wSCZZ0k;kjQVi z_WK3&p&XqS6Fe%`Q9OlO&B7s%D?9n=_IO9?#P~7`OUrk}BH^@etW*PYXsD@ayT4r+ z$-nx#i_$#uaMhxdc?M7VvaY{dY(ks#H;f6D4&~2J|L_0P5O7M-=$Z=DXiC}NEr-i{f4`KAEQGL>tnXK~) zXc|H!v0#1eW&(IZgp>&$U-MJfb~5l`;$X3&UJ!X2e@d& zpU3*k1zs>MNdrYht^0K$Y>upklvtCy>g(m_ABN-@*&&;gz9Jz>6 zwDg-9Lst)bSHYdPtFa$kHmDagmuk_t+p$6E$s2kk>OJA61{G?VKCOn}ob`^g7DR^=kf zTif(bIWX}+%NZ-H-eDD7ynH#a`X{dN$RYYAMc(s2OGU-I3_zlN%4n{+ zm!Z^Yy+d2tS1g7g1n8wAPyjGprdA~#F7W-d-kZO}C6l?+%+Yh4?Y_s;)9729nK&lB zS1J=u@U{W*C?@`jDRxL^@hOjleh`!nhsrNRH22pT`AvqR&P^es-}a%YAHH2N1G!YY zjouPqMByZO6=C8OV3 zxIS>7A)v9@0O!}%m+L$HXDeDv?oHHouh!AX!oY@m?N^dIFds&&X{ajAOhCf`ga{yd zxI|t+-@tPvQt&u_xS9d8JbG=tahYnJQkpuwU}R5i3MB+;{j5T%J-(WC^x!%boI=jd z1CJvgy!BlFKMLPSI5SmZ+nU%4QG4ZNTO_<#r_nzyUbwJu+!$EV zY#fS$)2GX6#&W%Qd#pM_F*uSS@}mC({7*}*QL4Xx6ziGS{E+N9egW`-JGAl90Cyv< zo+XrKkq5NrvBROdX$S6>$e1bpCN$sQ<71<7I> zvEXQbM%roa4Shs$renv38QD9igunKooqF*t{MPG@$#yjis~}l%@l6^m7W({|GxgVO zR#8tQ2rw(aB|qvm$l|q)JvcLf_T7IBm?LZ58*^82n}jO=O1y+=U$SQ(&)JI{LllZk zS!-Fluko)FY$+0W#Z{*iw@o%kjQxE08Z@$?Jg#3}&x4@rCgreGK{q5){vfc6pcbKo z*zUws4mDHAmsg4|IZVy?8X>{meyaOYf`T4O9l$YH#*HvDBgPR|RWkDUpcWP(N4mi; zu|!U)oq<$iS$%o^=%!wfJ_e9~L`-n0U*R}vMuTeYhd8nX29}UsVDCjOXLi5jt6PGD zyPEb;+G4totpBt*{j18g=g36bxa||9(vF~SD!8s2BExP$-|&2WUT?mP5utzXVMj=ZCUkkx5P^4WOXCet@bZgl^|7~JW0+}UwHg0W z>^`Y&;iuP7Ki`3IcKUR}t=9}Dxp~7ETM1kuav|XnV4+2IR#R@;y3SQmU*oD3m1^Q zGi{DPUfVm`_vP9jf{}9d^b)rGBJ6@kkA<92L5(*Q2@n*5J5}E1gb*@mU6#{ zVAv!YwoQH;m(^$`QzaDis3~A;#1>(dir}i--4fKwQ-_+tg!q@ zHsrwk-@-k!a-+&Y~A`4{WXUA_7ey!z=@4$#+frR~#Z1WR1OgQ{MWKFXj* zzxUBxLjl9cL?f<^nQ1zmc)bE-aMBq<2iCAi-oM8zm5C3NPUC?hcz2w-ENSggRJ=VF zO8Cya7B+U3DJ(p$S+70RMAp73;ExQNuk|_xVaRRYXv7iE$spfs|L@9`t#xBt7qHGZ z4NN@6V76Q13`!cn1nb$a!+pI_K0pj0TQLN<1@a5Mp{S#iX(1?NgzaHuu5XuFuG97z z8Dn0EfjKju0egl9(HnmuT+<=t5+olQO4!_!?sqi?p5se%kA+)nMM+=@2o5mv$~WR2 z^uSzlENSn9?3>=agK`m{(}{} zq*U$YWrA?*GQIBEW;V6+u}EL|2;diBMIO^~ss|5;;NKid&cSt9(4_g8ZOR;dz*grz z;W}EbXvg|d4pg^NTrY<{Bie>eMPtlr&vcPvie#fL^4Sp)vL5?C`=H)%NeT)I3fZC> zYl{mPT=E;H0X{za|3}t;*F~|Pl5Kb|6K`eLPO=0+y2qIbXVdL{n23pH_xUn_HRhNy zGyNU!!>GO!;f-32vUbn8az!(ukf*oIa^}sC+GWVqnaRGbT-MW0B+T=!cY%D$Z9ubv z2%c$lp}#v3=ZvJNTt9wB3$GHZiW;-8eK5%}`*k_cIPaT_kDzVzGYhI!R}?~1fMBtK z>5$wF;OWJg`Q6%&zc%$Av+@0%Aph&rlc5<>BdtTTcW(;z^`X6a($trs`eV0Cz^fhV z^dk^-c3kyHq>Ubq8(?Wgwl8%j-35hyw&p++lku%4L0yt|?D&4I*Q})EmLw=_3`?o^$Fn*u2PGPLOq zd|>!LplSlcmRvsI?}9(+z6+icvQ|Bqqr=mafY5$vDsJ<~V_!fGic|;_Nzm0PQ+i$P z2iyk1Oxq_)43lg+vGWhF#&a9)*byyUm*^HWgMw{}uT=+PU@4K(&vVFmsHmgAH8!>v zUZ2Z!?Gc$@u_Be~K4M+!j^A<&6Fr>Pux2&&h1?Ftf9h1@!&yl_GOje9VsY+>SHIrt zDae9L_50YgzvO(rt(-sKQ(J!B505P@ zDPoEU$XN`tLQ~@&NuWsVAwLClQA}H_mEW#KcqGRm>g2A`v0CX3M!q7Oe}UV`^{|db z*~ByNF7>kzLab}TtRhh1%v>_0XG}mL$J-kUTk`cF`j{K9?{QWCBY)6s1F(_ zXO$ZEsd8lYPzFo=LL1skdiLQYX2sS#2~dg4PEQw3MW?&{QzWz1&Qp9|+%@Z)hY?0{ z3`eMI1^bCu3A93|lcf{~cx5tl63<`1d%~$F0TlOW%K%&~_pbZ$$}xL{`#=B8JnKJ^ zZ@hBl2q|I;7pn5ky7<)rMG0Zj4u^V6V@mz-yy@yqUF_9T+#LEB0tobsP6o!4)YGuw z#uDYig$o#nGFqUS#3S_NovG_Wkpb=eioMg?M&t7wQbHm9Ya~<@_}Na&tTD*&Gg}4f zFF#-;uhEirJ?Q{W*ekdca+=PgM?^`pGd1O62I9;yr13&}oITNzwt~m8BUFn7y0t(Bq#GA|C*{#!Hz<6lF zKE^ZgVeZ|#b64D6^$iVC%x2;RZ?bhoU?3{^nJ+={y?gVf zU?;z!=+wD$2k-kg9DY+*H*UEP*VxjfCn0WxoF5NAind>?j}HsLjQ9I(PS+wZDfamYzL*nxIH=C{e^ufgSOjVm@48 zItbK}F3?g}eo{}<-`f|tI(8MkPH^r7kCS=C)u7r=yIjtnKD~S4=Hmws3I!;Jl=IGH z4&Jfhpvs37_B(bJF+HP>;exb3oApFH>#~8?g_#QqM8bc7SG$|ma0Rq{t)Enx9PM;a zu%b2pIQVnr#SVT0AQcc%(8S&mf5^EU#h3R5HNzik&cdSNIh;oq>x7h7Yj(ZKF)p06x5UF zYa3;;_Z?iby;zNh(CNREd$c%2VW-?EF3o_R1*nkFRTU~ID|WR50% z8`@@v`Xy$2sHmQS;mfWc_FM>~4N5+bEGT zf0JyLyX6EhMMX}dpQP12Uj*Epzi23Hjz;k21jPPzZ3BJhijW;M!aOq_h0rjl zr*mgr{#r_DZzW_-ni?7t^3y*~y$4}t1@WUeqp+GdICt(;3vwF?v_TMbnTTgG|7jfe z8zFZ)-9L7t;N;ngrQD?|1ut2A)zx0k8Jsb)HC56u2TVYu_Gd`io5DSbu zY#Ocpj^vIOXzeQ{nUR>RyvQ3=u2%JbT!1wp?wa`>HWW?07_4veVY*lq})0qdk z_3pXpTo_if4!YYMxKXC(h79>TJ#a9y47F&mJ7O=9I^{NA?;{+ypT@ZkB}OK`)Gy~5 z69^Pi^N*x(v3Ba#pG$dwhVi^v6kgWGu$lN47R!5$#gCks1rucIJ~(Q?AY??h+UjhB z8!fon@j&b9EJpaF)3tMORhPoUx(sH1vPi@DSIpj^||c{b2p$mMY9-hI=xU$Af5+Qomn zi)X8?Ko5onfG>+x-_BCMB~Q;fj-HCE9Z(?7Bm5}k??o^qUukc-IAEk!lJkitJ?|AD zs$zGc1lh3z^(H=6112iX#s3~;LPn0($&dva#)oyG*!Da*Pj?Ga7~fGh=AAj%;OuXv zkVW)+t-NFP4e(VA*{LrYR?>w~EeF*qDGE5l(a0l5j9zplM)5W4JNw&em^gJ$gI64B z4n+-VCB@H7kVi0`LF@@~8ZdsmLf`$zkLO|O4=mD2UY?ae>qng=)WAXg*mF7$Nfd@~ zAH88i8&&GAU)#^7{{NUd^SGMx_WvIVCxuf&DD4TU#3Y64NJ=5gkhR4&))q;KQYUGV z2uYDFVJtPe)z6#(PW_OXQF2nXa?&@x>r|zb4aCCctGJ=q zz3N;Kk)BEkiHP=xDz(yibP!KSC%8>b#kw+}VuB%TWWO3f(kdnlKfmrz8*p%Oh%jly zbN8C--(ES68H26ER^Hpd-gU+dg2g3Lm#KrolvOHMCT|ezl?-0ETQtkb>BON!)w^cQ z+iut(CyWYt_TWLN1V#TY@=`8bc>Dgn{Gky5uIg%9G6XIdv^GiZ14ZD_;|W2R92jJW zjhABy`2+Sj>`q(!$C@dJ<_D{-WAJLu*nJUQNyV6ipa$x(D$%53sr+#bc@F(<-(Qeg znK#2^%9{!|nbi*#`AeY}q+7855ovaw~&ZN#(KRITh%jegy%5yQf`|wA}nT$nV>`znd1^ zZL6Q?`OiOqcpk-qyJi1=G!HAu%4Z~(x;kc1anH*-+X>kiLYqpO-YivZ8TA2!kK|gN zWxKX+oxtGH@WOXPlNn$lmZ@`~Wc%ygHrA5Dt6b>*)fH%)g2*<~pBZd-~*eMVd zD8!TYARP|jXV2EnvRde~W?0tiy*0ru##xJI(;)m2$7}3ZgNTzU=m?&{%9!$&Q-1H^ zLv(j5CvAzncM|hjr zWa#MV2s^iKM0a68d#w7C`}Y}WqodlHTm;Nn;@bjW$CbY5w~qKITaP6YjEkmQH_{xL z^J9+RwHjpgF~|3^C&nCKybCUj&S7$=R3Ui|?>u;8JWCc&Dw!yYk#9N@S+;PwvP;kw z$ijaB~B((4SBQp~sXO^!fWA}3y89jB~ftFzs3$dE=(7swEPfW^A-M$k#or7&x*Q~W>aTfI4R=Dl`q&>!P5mz2vzML_ zX+<~fha__`?#Yi4dbNnu($^5n9!I4G7Y0>xAT_D94yeUCunFtQEKl>~49NP5>nj#6 z>=2xeartN{WUY`Gw75+O17h~>R*MfEkG`W+L|gRpSpI>0&ohAY6dPzbM>1T;#Zc92 zMvGj8vjJr4Xs(QF)te?&kXlF|)8wrq<5K2O*U}g_X_6NLi=vVssaTlK-SHTF`hY!8 zZ{31{Z+9{g@j>=ID}iAr;nuW@=5MsI@k-!du6(v=7?>M(mW!eMyY?$TP-+4B@lRN; zrp>(=_;8n>Fv9DCGb2+Y6gGQ+vY^occ~nW6-Oni$}Rd#7%8BegS5e z?n;36zNN>s^2Pbfb_NfgQM)7M|27fzIz$uxvIS;Yh&o?qP}RUxw6p}WVCiXGt2x%C z{iTcs{utdTkS4g@Tn~^$I?Tvud8%|ZskTC3rM8|#JP&40dWum`v5_4%1UGDy4ILuI za0bfgJ%;m|#dAC@ExcHrIf@i%KArzvV2J#0=~5_yvP(DG2(2-!ATXbDARX<-WbmCHJ>Xq9>dNFhWzMeQx2R%@ZbLr`LqMzq%xNPCMJ?r{{vnZrM zICidcUTq<7Z`k$U+Gy{O;?2{kH3UsG=W*=N0&Bg0UUk5_l|TZ4qGICX^#)+mEdOFp z8LyGL8~l@gjs@dqx$kDJkdWpwcvMhp3)b6C#{Ms}*Ft_sx}Z9d{r#$;!5g8c$0^-5 zK<~fsPHDc{Mq;mUlwGT)_B-i%=HS`0pW#x9-&Wv>EDoQeeuEexzHa8+f}y+i?R&x0 z!4wyaX=4oHuyRw|cvx+gJj>Z&)z4a4EDwXk&R&Zz)n5!pj7Ye>2o?;!m5m2l zn1AI3B5Gt}kfMpTCOJ)Vawg)Gpw=(RQ#0fj&BVYQbcfc}dma6GRhI~TN)?_T4v=jU z<{si)9{|i0mZ6_Xu%@=}Swhx=^rP+AXKn`2&`YV&HOLNFPGl-5=L#iKbewo?g^XQCCnxl=L(I(XKYR!#%q8{5 z(G`Q%_*Luoi@oaawPwiHyxp8=qp# z0a@|8cY~kv2XmD_^n{-uwX(S7u`~9rN)5VH8Yu-ki64*;5B)d$1 zk&FjN`wlPp-{`cWhyl}P%m|&Ik({h~=PlE#xUm|%PUYn6zrD!G?h$tsv^-^A%Jwm| zp=8pP?`+HMu38ykN2WpP47Uw`KR!D2%Dk@j7G`EDy?XJ(cCe-1mCB|)ILI;0S4VsQTbLrR8+c}=G^=&ob_YGK@e#T$ zX>4EA4bCqIn;7bn21GjYk2#oUV18g`H3S(!U`)*6K)9-q6UwFL|rsC2WE$uA`muz(x47s$6x9m zL5VmKZlKD~n~toi;|p?$Q2V3Qx><{iLnUSC&sFb0|B=CvigFspfc?&E#A^fxkK9nY zrL=Cix%p^D4uV6f9F|D=z`0y3&ZT`||G89Yfs;BQGPj-7dy|c%XHSFLHM}|Yk;l#V zm6h7}25y`faeI+lmT&RXr&+-9hY#D$o^8b?wbaC$;38F3I&;&}qm3nEeonGnwd>|V zEFg5jfWtjThfn%Jk(WOjdLcUm8Hbu0_||f^fxA0HP2@)WK*a)HDR{j|(4+WpuTA^+ z-%GTjRc_DR&`*y7o*=`o$%fkpyXnynTjZl9fmSuqF2)LfsFso`v`>5t055dZsZ(8g z`U5BeS0a2x355nLJo7ty-N9kY$v{FFIoV?jz%e1nPLWlT~A516sqS^9Ph9c_$#C4C-d%vVpn?T?`(cdxWxn&mLj2J##}i zSk*O9)S>thZ;QHZ*J-TIJs5TEb*(oBrAa)3cNn9>eD>_d0y8A(TUI6h?)CGKK#7gJ zBd&`HR`$)BlcYS}`P!e1B0`$xpEsJc*MZjQ3l3*ui6)X> z%!lW@*fj*bxmO1S?7_mU8c zK5^pa$7(Dy**lx&7CB2-&SB3p`vLX|Cd0SFXyp$d=FXlSmH4i`kvt_jL!V|d*6Pj9 z2pzydAA3O`s;)kA#jFf`o-QtuJ3`-(x>_T4S=aFbn7PbcPbGrg#>Y|L02Xj0c;^Pl zB)i6wytZZQRyNj$H*ZAgMee3~e?do6BVgG;)1xxbgc)%^k?~L!(QueLDuxk&bAgV1 zN67;G4k{4m#kHWSiC2vBQ1bA3D+(^$GW-&k7C z1`hxb3MP6l)u=?xv&jqNTctNqKoJooc*9Ue^;A~&m_L6^!`9(LgcX1u(uxtnw3|Uqn>YAk2+Lg_ONWEZA#n*R89%dENHb-# z8`lUcbAbP2m-X8Fl&gx3fe2YwQ+^)5b(ZoBMQUG}B*9sD+tii&mzCeUNA=Q0Z5mU; ztv+Sz_S21Xmr>L+io!s81x~PndvZ1A7HTIRE3gBsFe)|rSBN^^{lbL=M=5lwha2g% zuSl~Qk9EqvQxtE)j28KL_f0k~)20au+N7j8-1e_t5z*aM**n%kcR4nW$!aOG6MHgK z&7=|U^x=lUiUBZXCQhfV$6_c5937OpAdM%8kL$pX`|tOSs?^UJMM@z5pUQyfOmBv1 zcP1!&d{XTM-Zf%M!tmdBK}JwK^ic)F0ww_TOC4y6gzN0kgr!RdPV1RNI0@ojP~f7h zb2A^)?PJa>Ss=vMdgl<;^8#Y?{4O!u=M>7@2}eS~dW3?@CJ;Hoc135tJN{0tLt^E$ zpJRjMS7w^rcy(+N!t-@)JL#6YU%V`Fap$9SpwO+PUX(A7lcu1_W2EdYcpTC_I5&%n z^(`%bGWeIp^N4RX_$fwB@I8C!C$Re*@JTl>0a> z3HLbxgrk#j(-|Haa0mj5)-|8Ja#)((;xz@DDib?ZUi)j=;|j+k5T|I>*zazVaB^G_YwI?F>1{@<^vC<2s| zEHMy6sz@QzktzUjP1O)1`YC*YefuKhDW{g&*Mnl4$5bIQj<2V)O1!z}AQc*#%?wwg zrtLx+p`U5dIPs_rKhME>VDWT<7$9 zA(35N=rHTUI6~1=QLGbZr>k3tnBd^;v?c>&jp)Jo0XR=^;C`@fYu-kg*se>CSjRl^ zHzBcL+mk*dB_$`DNW`pZ2C<;&Y5S7gmZw3%nz8=J%7jm~CUyblG!Zl4tjwFu+bb92s>vbM7-z(1iXhOXgd?X>JoxS15zXs-uea2*i zy0dzsE!NzGnSFNmZm=XWB0{-;6{{Q`D7=bLvh$#Ke(!3dh8A!}3rvno^_b7kC4>3KN}p#8Wf!UvJ@3A*Pe^ z5TgOlx}vV`2i3tA&5A!p%l28<^ZLS&>%X@V9KdttL<>~DFx)SsF9O91^!$`j-@yxM z4v2dw*x;nIoj$2Si4yJq^dH|$^b8CN#uAN(7=P)7i7mPD@*0S$8XlUExQR0s#0ICZ ziEDZuRPXENVX@jpwZ6HnKbHx?8LOC}v_Ez+rzo#MC4!3+Nh0s+=9V}0RK1lda2&4*G0xo$Hq!r8?c>P7aJr*r`O?k*+&fVqpGJEFSxm^|>XB|^2?EW~$OtI=qb8_Hk zg~Z#heX}4ipei_38X6|v6c-j0Yze+=gPIaUrBpoGa3}CC$0}91P2aVd2}eu|sKz)s z=^C=1CorH4I5$F)`_ony>=>q8$!U-_COFIqOo&y$wz*11>(>*t~>vAEA^Y+2!vPCoDMvRSPkOyvK zYf`eBxBPFKqF33pHSF6O2^>XBne|VNwOrMG?*}2C;Pd=KMxSlFgQy0p3;N)${ z&-{AhTP}G{s0H@iUKHB6+0XvX+qc3~t}Z(lZHftDo|Q$MU?RLey_On_QYqk$;b|5g z1V4v)<3U4}2*0-04NXsgTrYZj)n%XyqsoBT0LU<9T=@+H#`NQKKY?=gtBXZ+_jCq0!7@F860(D1z}hcq<8 zB;z?xxH35?sGt&WUtPRbsc__|QTpl;G{r)^(iSNjn^<()jx)^0f^xAoDCp)GKS5F# zI4g*0ZM5~3-<hv+a1VF+*Z1ZnvHu4{vqJp zEGERMs=};B7Pgl}*wLOvP)Oq)G)n+;q!!U^YwBbxl?r(xo&Qr3dUUaw6c7y9qN3K>AOk!_28qHD|l(tB>fcaA3ee+3r8@V)!1# zdVPK99xds0P-v0@%(@x!+EBen9&gf98Nl?*9h~_(7Kd(UK+T7 zfIWMn(=qZ9SEZ*iVV{Mbok1MoqRG_iwkRT9=$2NheKU)YrqHTdo0x37SvxvTxj<(i z;?M7jpLg!F9$2;?3)CoqwgC>~M+jZrC&9TZ+qPRbWQ?Cae%zd|ZqI4GCbJ!Df;vm< zik6ktdT(zs!<*O!4KaK7T5C34`JWb`kFDkOTREIQe;i{F^=QiEHfibUEqK>&Jb_yD z+Ms)}%8c($N=R5sB!i&($XeCulB%wZh^ANT>IXj^?dQHJ5y8PrFC}_Ggm^&P?xDiF z<^mFeeHm1D_y*`Dx7Gnd z4LW&SsEa@5diym1J9GYXETnrrC0nm(Fn#2vi;W-A&C(8^KZl(txs=Ue&8)q$f6(4z zLLK)p&@lf`Oge*yW-t7g!m)CuH~FDEt-T>kpGjoNi1nH61C5pI_3? zUJiOYn8m}`98eTNF=u|2=NtRb-XM<90poekgi%4nUFQh{1`$$4_76kW#1udD{`dc= z`-CnJ@Zfo-Pc=(vQL{=an$H?;vIa{X9Zxhgd@3y<5xqC=+~KaBqLxCC2d;~zCX4Lq z`ufY1%+0>jrGne&>&x|@B7TG)a52PUE?g`dHlz;a4bnN&eJY1L8KJrO1fd663}=@-Fi2o_p9`WSH8ZfXepdcQoI$i)FJ6oSxxacf_29t} zAwCDp+kO80P?OksVtd1W+X4&(=ROAp4|TI9vZ$K+n|E^?(js=YqGq8Sk@q^QW~#j8HNx1zpbS~8UO z%q2GX(xHKwgbmbv0Us(V(jlF>`aq$10tg%FZcY2>$CXeAU@N%$f-?!dZX3C<02e|$ z$4B?WDJecR*Oe149tmXbGDhIWN&RpLoM+ljOZ(wetYqP-siD0KntW+@tOMBb1!SE% zGPp+vJ(bNK1T7@2>t(yiM2R97DP$Edi{i44HVz${d!zlKd(fp35?!4Tg0^h52y|zh zUgbH(Sqxc0_Nms5#vmeg{#MCe3Mu>rrAZ?m;4mB`U_6YKghg%bF~-xAQXr{o>&*LL z$$oxhRO}im&D!twUmYC5GY0!~A9abG3{bWR6G1%(TTdOK^&BgXAY>nZ`W*Mc$rTY< zb3U@k=&Ba|)FjAI`#|?TeFokMQ0d-1e`UYlZdlJOpcsSQG!mRi2EcmHY?KH0@9Pvy zchghB#HExujWORe9@|W^NIxMY`u*_3rUD^PJ_zdrh@lm`y{=Te!$m0dFRt7g8M(s( z?HD8+Mz4s_wB1>|dzlN((p`xGWMC)S|h8m~m;8}L(R`iRoyM%;L`X;%p z<*40~eXx`get+SDX6u6~Um;69!q}i_yM>U(e7{|{JK`n7o>5!Z+U*@IL&)3vW1x!*!#q;JiZ%puCV#t$AiQ1CP~2CyeG%IkU*rlYGx zGgmrnAg{ZX{za_K8Hs;>($5B%E@p56E*&{`>`i55BO8_-!L=saytyD7Fkk;myUw1O z6aj!CdMa-r;j4D-jkP02QyBX&a%8ke8OT0ePebXrBxMM$4)XGn{+l{J(e#w@V8H4# ze@PKgF={z9|993Q0i{z_1^wk*4U=kb_FySjZ3~@K`cN@nOt+w5mrn>fA43b)FnY6x zkef4jaF{0A8uOLqTf92|7wauZM%Hvfh9M|)5l270BKPz}R#r2}4oo+!0hPNT=7$t! zDyXwn)}Vm{8GW6}P(XxJUAxLnK5*|Jq`Uz_P?+)DfnNp_0sJbr?YEc1r`gN|Vcl20 zqdG<8sG_cJ^fG0)GzHHwVP0sr%FUr^s$MuFj_amw;m%Cd`X`M+cu^y|`=~^m!=wX$ ziUwJ5WazBDmzSqyWc0pxyoJAyk9J&1gI!^N=B&i5*RIgn?f~-=V6U28Qr*~KIhh&7 z{5lg7N45E$V0p7H1jh{>#4I;Au%|7h+i2(b3Yy9ab411UpFUZy9?BByye$_b(7Ls; zn@+j4+U9+uH}nZiQI>*RySXJ7RuXPC82N3B1CT$A943>QNw04XaQG|s|KE39qA8)5 zwr;&mMSXPh5!|*E11Oy^C@BlsO{^!gPEK=-e-PlniBnNLe8sR+2M@{_<xxp zCNZ$tEE{#N1@hYW_H;>ns~N7YnaiJ=^}hO=$VA@yV#`hRM@-Z1q76p84(C*3@pK+O zifxwjTR+R>QnV~n3|0AD?9Vij3E^5127g?X9QYO2sP%_iTCPhC&7V4tgCt$E`il^? zhd}-?Rsv28UO%bdlA`mIv?SDpbFQrW3-k)v$e}}Hv?O=|{QsRBK|jRrq{=R2vW)EC zRKR|w8p*xfG87raObphls>2=xC`LozgnjPt)kzqfL77VhL-Z+^0KrkfthCf(D|@yF z!-8~V>@vhdu0}og#&iwAJ&^y{jA_r29`}+7VIizO5CP)=5q-2m`F%P~%6r4wG~4PU zNF?vxb!no4RJasN(d(4(d+}6SHN5N$e_4-`oR7t`5^&Glm_mxwT^Q@0QTlLm_4Od$ z4PQFhqiqTxtOvGx=9S>O$2=01h-p)&a{9ilt$hU4y1{#4zr0=bCXe{P`fZEfxf3fA zmrWrTfRL`KRV*=yedBitFJ<7fk^R!LvStR3)DGdQ7Y&f3K)mYYm{(4jr@WEuBqF_f zovgRhQ4VJI?QhC0Oo{}SGMR7exN)D#&Wstq>~#F!Fu6cdD_5*IMHE=)&LBQj(Jp!_ za1IRRC_Pv8=*bfXbqzJzjax^?lM*ZrjlZT- zuz1#WXdVvlTcKlWW+r!hG776(x0aJ21@IiVwt9+jyigut!V?ZxYogE@SxT!;%W-bR zV%fTB$x{i>(SFFxr{29s3|tRObl(N;%U5@sqzeU z!&~0kP&o!+K&3ZL%f6(fML&k=3siu@F96B^RMu5ho-!A(>60%v(snO-t0jGdDHW?* z2RE&m6Pnp{%wi)y6vi#V|M)(T8HS!oBDZX5`0?Y~D`Z+^N}y1%5%05HP1mra*wfKe zS{8fj@e#>1Ff#V)2UMcX)b->)qx(b&95|G=6eSltz-Ev8Z!4k>)-8~+p}cPaIruHScr6@u5@)RZaq`Nx+S zSe9Yo{P65AnPjW|dM48?{<8lQwR1ot+kHXC)4N{F-wgp4qviTD9?|D=$ni^5<4D9D z^!}Ue8FX(%o5?^>SO=dOyJq{+cd&938c zsl<8RMtkzm@8KWo(}%~kAf7NyTYE3kgAqc@pUe;S$WF-%9Y3m4$`G>-Wh)| zEiH(qc**e1upBV{s7AsZrm$yQK3@Jr59HA~>cJO8yExT0#rUl!cUkk;(z_z>+?P*KCzxEAPghsk4e{FTFGnA|McPF( z$eR5`s@%D!wKNjI4gZ_U=h%BJnKIxl!4f8A&dCWJpp zMV`Z=9z8mIh4lHH?uU<&u%AwYr|riz>l3sHBG28F&CQl_JK4hv45ZBH6e(5fx=bA8 zZB2ORdt|a*x)i@+ghhzy&E-k;U*2N7cQLsPD1H2T24Q9=(sC@}(SCD-VMGR3yHPb0FooSPb7q2A@%_x6n>_@5Yu}HASE^fh zi@gcskA6q%dTt5NEf4Z^SQj}={r_fNajHVEDXj-JO0XxfG!9a ze4=>ekb9#;wCMC{)67^_9OfEi@x|5Wd`tfN`@jAy2E@OnMlVN+O|&`TC6_gT(v*$& zm@GA50Np&9ti&2UedZQCAluKLoS@ZU&7+fFy?AlEx{)Pd6%ZuIqBw>i*mY#^N(c16 z8AaJ2f*|g8FHC3JM9K`HI^ez6zsz6pXS%gVF=Sc3bEMmVE!BBB)Y2`B#gz=~kJbQ`QT5 z+tK3vO&1uuKVz^_4{}D~Oy*c+db%JAWMXnxv3QUF+|j#~1!zKOz&V)rs0%XJ&iD8Kj$%wA<>0u#)p+p)4oWznY|;;b z7a;Zc$>G4M3cGa+ubY9A9D5e(GTW}RJsL(7I~3dmgf^A(Q?A+AH~hW9J~ z)%WlIqmxbgxszcz0KEe)+R?AjoTpkdGZztm*B)^o7k10ehDkU#K7aixYnIC!99|ef z^o~j=ieS*yu7dXU9|%d4sy_1v>V9o(%van)2+il4CMhFG&x=}Y*0if$zx&E;?R;TM zH;`ifu^-WUIiAreAr+992;6tgNRTNGAD3`gg<#_t28gGZN-KJb&_p(BM_8 zx_b>b)ZSzx)RpAobERZ&-Vsb{r;7>KgK&R02Jy`SUF~)sUfQdeS)BoQT+ygeH}7w;CcQVZ(q9xzxXe072|ja z%P5W`vjS`ljmP&o8^FsqI(;S3vB2%=-Yo@m5Ss6P}G5wgZzTp2kcWR^dFPU>UH0$+YX>Kg6A9ktQjon80s-ORGB^VO!i zxD@Y1gt{|n#}2vMk;FM2emF@ofl7&Q+A!G3v;a(smqiF41th@r!o`bq zWf(}{E{!#1bs-RLydMpTMAR+ELYEjd)&+m4qN+B17INtO55N9RDpV(cA59kBT7vW4 z5v*{CQL=aF-1(j=JMQ3vx7KJ|viD!uR9~)q?!||9?@$*%U{V&pUqnR0g$X6o?A26N zzaD(=$^zEDqXqG$<-v!L7$2p>{|-3Pe6~k|b1OTV3u=hcY>YGg~jD`05DBbhMq4i60;T&zW-~gWVQO8s*#(Zl3e=e-{Z-X zXtMCP0nzgJI7$y3n-)TdLCdz$KMjE{QMSu|%7WOQgkg0K`+PC?cbDOc$mALKt|{m_ z35buV#wmzBh``X3$;apTmW0LrA?cD2vMgAm>~=OKy*9rcwzM5Pn9i9s$CwT#s*W?F zU4KDKY^Eje76XKfo}g-;JeF_<3x_CE*DxExCRR~!{IRCAxJ^-)vI)!o@%MU#X)VYy$pSSEIy9F zpJ93pKk=P4qhVn^q-bn`XPtIBdqFgFeSY&fDpu;0v*0&ufqvVkB@ktsAQ|8G>}(A1Us!HR@Rp zoRj_+49_p}w}VIV;;?hD+Z{&Pi)mD{E?uh16=Jx4i?_4w9C?HkD_CPJw6sK)ugMjm z8%K6VNo-@nC|tzyRQ(Hc>R~Q;=&bWFO?Z722|Ymbd-TxvD0R@;?*91dmjDiCPE)GV z#QpnYekn02hJRn*bLGbWz!K(CBWQxgZFP*aJBWvti|n}L8wzTcBUp|&1?e@Z7E7l7 zi`y=@t#!TY^5xIYDSQfX9)_M56bN82LFEc^+tu|w)!C*$4-Rt&2z*BRL{u>$4XxAc z4JBeU(HOAfcI*i0et;9l`MGS$>3EV@9fc4&5Hn(l32DD0rU=7Sz)dfaa)Z-5X6olr z!CgXc+IxS-oS=hPEE(E_v;^6joSdBdb5bx<5)0wIQmQ)w3F!>6MmYKiSo`+Rx7{7) z7?WCs#)1PFU6i{TIganEs?0v*KX|O1BZ+olpb-W^p$uAyCv!kt1qK1Y0l}UV79L*u zazLx{c89~j7c6}0tGjpEvq*|3|vG~ z2Oo+#cE^q?f){=I`v?jnLe1i+0YbhG%m4ci>ZGu+iiI(*PX5z5DFn#zCxS`WuA09;RwdjjEj&l8g#B zzoXe$@RpO`h>#cuIz7!ovjjm8(li036S|rG$;v9%27ZSO%?xeK+3>3&pc8!yp$#9? zgpnXf=A2qiGmQyIp-o!5E~|qd6KZS@-N0{>`3OWE#|ZTSN4Ne)JpK!wsWd-*!Vg-J zN`eA;9}qi;ld(AN?U|TA@?%X6hK^27stSWi34(IY=sp4XE@aj%q>T#p4hC_srdn62 z_OR_nML9ucIOrjnpj?K`ni{wD^t>ybAGPP-DIhbvN3@;)BX4RzN1Dv+S4I|${7^A< z_U!dk{&bS{Y27QouTNz|;z2P`iD2O&Y;26|355pf$T4^Qu<{J7Vb8sC2Y-o}ayoaG zO{dE9v;mnzb73AG930lHxfl?-fcYA1o~Hv=?q25qmA`EJqYG;dt|o8)O?YP>-W=mz zn(Bltp)}@7)gLh6H~_VF&6_t4lFME*BPJgG)znl|S4SLtd4{E#nQvta1pkkbCh4HVw!yM=8#GTtSFcaV@H9(EzfW{^ zPtC`aLWo)u>71RGJO1Yc-#^lTR|potf;!##L<$@N=m9fc3wPtR*2Z` z(?@RhDJD!fFN?xArnKlVZKDt%Z&Y`afD_s@Y2AL{ZD+jY%2IY5 z1whfS!Jo*F-ko1}ZrvgcZ9{8oP4mgR5lb+7BHz?CX~= z&zV@`{JUk*OyCs)hWXjy64B!)T0UE)-9G3UgyqtnF~q(qRvrz9F^>h0~>akYF)1KbQu0+>>+qaN4A&q{l%oy0`z8XE246XxN!1JV+~vz>QtNT^E&kG?U?_xp zvF*=-Oq!}l;^hTVx&oE*^)EssEDaDxAc|jsQ}@=|?f`qo8)IDXa2W^gk{>Ssb>s#a z5GCT}^=am)U)DOV$rY8#vAcFXVr^wEy4@$@6m#(m^6nw!*Rh*yLs6Oce1;GkLD9v; zOiBmHl{r(MF83$@Y3D|nuf3r$smRb4DiO5xg3{{We{a-%p$r)P>;2HO9lA0Ww?O}? zslg!&ej14w5&Xx?Hs1Dh%aF>V6Ehh#dhso1AZ7#V;jpF+_aVDytnl*kTDTM38+p5Xmdnn^EZy+@+2ae32c8az%y_i)#NW?no!Hk^ zwS&>IEt@rLHY+{V)smRoUB5)-QN1dtd2`B&S6;sNezgpGSzd9)d)22Abp5vMH7=mu2HnY=XG7!4xx=J`56&pU%L<` z)N>YU?Q_Uk2XMi9)q7moy?YE_G26PR!+rm|a5^lC?)vrUdNW*4Qduz8!RJuBnvn4D zaZJ73b}i09Fa2m3!tk=OvNJL1EXtTK7x~Qy%qY(ddWB6&XI#LS*Ax0}J9^+i19@@z z`mY_|YlRn%KwCL-!u$U0J3K+oZmTCWT#UUC%3Y0kY`$V&9Wld75ZhLRun-Eq@`a@l zA_F8^`9;5B-Z;r`WY1>w6O=^@STkJ(>3qbErkyzP<@@(k7elr>IT%`6S_TG-5Y15a zLP<|8f2?RL;g~MbNFhKuGv+QaZF~`?rLmJ$#}Xq>pNGM>->7wd-`~$%-i5eIQVACg zob*mp_k);=9gQy^Va0g#rsoXa6DsiV!cA7W5cy5p4(~n_%6^(}VoDzPr%#yXKi1Y# zaGxRYo6>E`Fg2~F#SYK&BW9_HL=t%>iT$95|Mm@jJo(sH5hV z1d*SOP4L&Q+)LbK>6uA3T9gRF)R2BnH1$-uh5E21x5-P^?BA{SI9z-x`urz%7qL`9YUVAHu%K7T^db>d3+OSl9D2PD+J zYa3;bkf|3ig{mjtQMH;hb0MZ;+dv+vvGE;XegM=zH|CSlQWiZi`j~XygvG=XE^;{G zN+4r1>05xgxIIt;0AHgKKq(qpTdORO>~JDiA9O4~Cys8MFOdi57_R{Zb@kpw`2dP> zPPeF!;dLBkobdr8`MB=oo-0;dVJ#&rwT~t4y#1Yf3^rM^Xp#A&YvU1;iKTKOLC#WX zfnbwt8+c5_0cJRKJ~px9p4mX0_-$-$+a);fcF^O;LeyT*d`7tsf-xM2RE>)LsK?5e zE?>TBAw_NaSXoD01?D_L`H=dPm-KvXBXw~Y~gBjhs^+u7W54SG1zFSA-MOe2P z&vEpD;Ze6|%CugK7A|C>#6?^&|JeXTvhIn3@uapK?ncHr2i+Ov9{2rS80~|DdB-g; zXnjQeH1aw=4UcS$dSa(~mFc6)Xi=y*aH>mv=8=v;%t>WE$5sA`8=TyzsD>iejEjRr zy;JK;$7Ai^726GyS}QlC!Pp~jz?Z1A9SI((3qJ&!;T&%pF$q)CoqtDMkEhqB@sv1=`QO+4k*L^{z+BAfi7StqUegIg3KOooHmg zZvOg+>bdhFP!P{Qfw}E4PR^*OVz=7^U}a!_{gItfd61ffS;N;to!^#%%Ho-jy#DQ5 z7ez%VXaT6Rk}@2gcL{(YC!)2lIQ+>jtf2jv2z`hG@d^#dB+Z#!0ACjxi(sR zhAxhA-rlkIIC5|-cTL?x^q6_#K2BWz#K#Y}upp^JzO6{Lb6BzGU<@RsrJp$70md-9 z^j`n8uC|t6t&1pF>-y#{eYd2@wc9jDOJ2KOzI74KqCtZQq{x$N6a|AfocU}+S9|-m zAbB{wl!KD+I?_uhPCQeFQ7ZA9`9|*!iy`dw$0tjvDELplpM01UWr3{OQ6wT^rMC7P zIOFvVOQz^-S4S3!?AcURQN+XwXM?yie;1#L5gL`uOtnRG1m7z?o4$T6>FqM>d^%xiu*sHTH(3859hw$W z9!#bSTG@`Brb%#jfdZlX+t}x+?Ur-8@*>6?vFH$C{_~8IhG978J&QYMZ5uAoLEXEf z`fWu&>~>|{uEI)UXnU)v<*k{lIttTkU}pB+t}5A{%uxRHG^0P?nHq^Sbjp0TPdrbs z=dZLwhlIQ6TTHD6hYs)6~?G@fL8mpR{LR?3||hqbpmkU_^v%kMAZ+}F3V z`UT-Yqv>jP8z$Z)ZbOr0@WD3>kEuwCB44^n>~XS0QA2y8(lV~7>HU)I4-xuAB0&B% zdi1G`YKkgGfF=&%MB}6?oLI;<0*hwJgEykax>;Hpu6d7fi>R{8Kbf+X!Sisa?^@Zy zJeW9O`m!=XGbkOzq-9@Hc;8gI>vl`3v6B<=SUh$ephdx*9P?t6GzI-L2h$8K387{A z8)arOLLeZ62#V~K#;}}FDRbhP_D~Zc+5LiW0Dxgu>W>=SF+iJ@Dvu$d7QWju-l~98 z{&Hrf^XG)(39q#x^lh*k7%w$@9f-_u3Gae-OhtE-$ zrAK#?w?*`T$d4biQnhQ#t97T#>pB7=(Um$ekMHA0iUMg$?9JNLqetm@=Ep4tW}{N2 z=Ml`yQ~&Hv6#v~%DQgL{h(ksc01aoIYp3-`OKjpNqZ<$fQx=&gZvDFkh0@cqzrPWf zw8yOnnCYbrVQyEqR*Ood|E-*oar^MUvF$I?ggfMzb_}Bz^vYS94FAf z*4rJdDm~rQPv-3e%ACYJufd*`jBcZ>a&xn0l=GL(ijzpf ztTM&cu5MSA@#_lQ0n=$R!FJy>WjSUZe$55~mhCVcj~&a-E?gM`2ncktNB1jv9wgNd z8dNxaZd2fB^E86rezO2-=0znIRu)~mHh*dC#G_E5&?rcs&K2s(0_L!ezMKVHhI^M@+x4Hbt1Cz<=hM z^W98SdpNsyqAlm?di?uihrZL6g*Rp>j84v7RX4H8T}}iffq9c6m!{U?Nu|69V|&LA z9ZHrb+1qG-X={&A-z*-ejAZ-$v-5+%A1_ZuwqDLSP!VqnY zj-mMr%qX=?MtL)2o>j-uBGU-m!2AWlWKdZEhFe8V{v^!}=p&cgF~JFwIYhI{pn|l) zW-;h;aPeISm@eN(;E#^3FsPb0OHvkX9`FR<`S2CbhJ6h9_LuF?HBCF#3Im%0{AFA^ zZ{NNZ1#j811tC)2>h`=UO-)VyX>n+$zf2_R-aQUb1H%`_vE}Pdr=%!rX#7U|Q8LOf z>*B>Vym^;6;@k=JX+$#+6B)UeiK|qGU1K}ym`XqtC<`{~oG^UG#np_w)7*?$u! z&d4dCtDBV*m{lGsJM5A1ltm~Kfj*tN{eB4cP;|Z(Rcd}RiszzR%Y1=9n1#m#RfEps z?v$2VulcCHeBD`q`-oXzfj%ZJTy+ihB=6tchFrp9nwd@nQd1j31V4-by!1z&DT}=+2<;OcX!q6Dd zHvv6FW<+{58rg9>o`0Py_J0o3iGnsr zh|CiRWPWzu;o{qS^?$hO#@`f16(kkk`{x{~WQ)0|Xzf|>G}VTYH!KQfbbKSmhD^gc zSrv23{&M9TbB{QG1S()&WbiAW>zV!%k@t^}b6pcr&)a;QJ6WOivihiXs`W;Fd-OO@ z8G_T6pk0=J5BNehH}JY1_dSz1ZKq*XEHLLTq|OL=Xz zQlW9n$<_@lj`bHJ%HcHvTqlqW(9-gy)MLhyrUSw(bh<)L)@%Lx?i#=ASG(@cy;UOg zMvv}i+(wn4iDH_NkbnN!vv=>r%j0w9rv^vsqWz;zyuZ<@kGw7Bb?gItJ%6}lptHBs zzkB2@liO5*tRDiH8j#VTq}&)APc&GU_wwcb@ka_=kKtA(m4RF6pI%fjp`l|IELebM z#KA$BV6of8?c=+5k>Qy&&;*5N687(Z3E@a}QZa;xN6dkFkzGY1=v(gh_d9tQBo|dG z^$E2d)gUqWmU&B{AwZ}jWuqqOa?MtF zIqRtqf>aMos1hMB5?NVPRX*cb`iT;?q8|DK7!RF3?rKHFD2jJpO}^sg) z8t@A_Kg>P__D%GXNQ46o34y~;&rBRP3`rNcgUb0?Zn)8Pz3;^RBNeQIX>V~;mlYO< zl1B5^uV>Wix;q-EjFwszP)Ei4$Sgd3$I(yRr_d}fkN*9D@#zMK2$92mrnLDi70--I zQ;hTdgmm)@qsK~W3cFd)A-rymf5PLLgkjcDsAKgVfvlV;=XukogOqj2IIS%2vt~^< zB_-q^G>Y@Gd~3M^u6BHW&~P1+f3-Iscts_aG3sj_$B`LQ?Af@iR><>#<6Zc#?*uac znio%=tSqu2n@C%`UDvG=dF|=%6v~$S6Tc#|Az+F8ruh@PMINL{V6CwMX+Z6MYUL~b z?wPOHe3|p%ynP3`d^|=p*}NPUEiPifE!Ng~9q!=n0SX-v)lf(G!*iWk_*(IQc!sCxrSmeFB<4+HK4jT|+}bs7vJ8)WFUr zq#wV7%ldP%Ao`8_dJXWjt}%d#2)u{BSlrdr$aH1$b8^&GY8M5C{P#@^ zwFV#_KoKx(7`_VU_7*z4+nVyWqnW$Va;Ean=Cs$e=LIJz&wm~nkJf%1;MJ~=Mt5#*eMt$uPt=n{2IC2}V{z4-| zVd#+Cp;HfJ7mp~$c-AN>`Rg1{{Ra+kZ1`zoQ4rLvMFVoE;1!3!V?CjL*W;`tR6UvEb?soQz0-NzKd00{>B$0Cu&Qn`51!+Z;|G^I)F!`GV28b&q1keOdf5&@ z6v7l#vffy0NPvje0ImVMMMe$D?ApP9$vB+sN?*i2Innw(qv*?#(IO0FwEWkWO0N+@ zmKVr&Rl@>gIPMd=q5P)Ej_@BNLGfsY$gMTbTV@8nSZHL!0YE2r#)?UJoExAR!eJxC z^8WEXKBJRQSuUs}8@elE@0j2!Z$NaKLE2ZEc%B`~3mi1ps;~5kNry_OSECl>zq1`5 z6f1M~VdfF4bUg8-8!}dDo|8z_dz`pF71KqYOA)bsd&*-I6MxaCAQ$Sj5!C*}i_7t@ zk$dM+#_%39$+%r6)hz^R=^)o`t33%H+rH{5jlD-I5>UX)ORG;@xw0W3Ad>(m=EkL^ zm7=P+_~SdJC`ZF^uF)Wv7#cD>A9{Lv&S!PB=0;4@lAum+1{VqSVmKUs%dc}xi{_V$ zpT*6AIe;O9$`#bCV@R?jE4+({0G#l_A8mk+nPJKC5Ag`ni#C(}$^cpBoGbtSy%)Hk zi`vdFuv3n`ew;XUY7}J|JFoJxB*kSC_DVT@`eV&*EuTLB(*o2KEy@_Hqx}9{ zf0YQ}jN$iUNyv(^KjaaUPG<4N7$IoH+-#548snZrx8o^H^b2l#(iL&Uhw?08N-~G~ zgNc!Vc*un&8LEOL?TU|QhWggkGNzD>QcC^7LNcXpZf%s2 z07s_%dkBRn3CI?^+67VmCvW(k;GnnnpLR#d;Ia=#o52F^v{L3TsqdVNJo;9A#?$vr zO-&{;&+3McsRvWw&iW7@u6_XXCY!gX<<5e4vW z#1=OLV4>z>GZ0Y5>nOVHaaaZyvU2PhMX9-rT{z@^yT6rDTW z)vobjzX=K5%yd50enuNX;k;7>S{y}U`34|GK2j_~)Z{RJJRXG3P;weaF}I_!j|^{> z#Nw|-r79v^CE}}e)x?Q+cogkKXxJ$0*{a~Cpl*!ZTDR3RMWg6-<^5N3Nmc~{DS?>6 zd#Oxo%YSSb=Zeoxvlz_xclQ28`b%N&0U~!lzn$ERL9OboQzuMVL9fFm_OYK0540l*yr~c7E${C?-PJZr!w%EP%(N zibGwsgpj!DQ`Z<7Ml$aC9r!W_9s3jv#RQ?b*9Z%XK*EPD95wdHD3QoLfhWUiyx;J7 z#V*^c%F~&9%d2^}%b$x3ZeEl}4Z=oy&QOlCL2a$k(Mn}Sz1o{svXF{bylbET>L3*x zzcqD2^}O|9(G3&P!gf-0f~gF|*M=8XWBM>>-dAa9x9`2>M4ZXsDV63Hq3$=-6301M zNTb;q&>VzFp@``uFYjC>{@v1ou~nE7^6lhZWh|)B$;U0f$4i0aR>?%c$owHleWD)N zq(@cCKxq@i&nR$RVMH`2gNINlnG?(i85`(CT@#d^%&fA^@rb)T%v z#6*FYH8K~Ey@$-U`#N2D+m`@V9YZ}lJ$EE0zk2@sCd-m6CzD@_kc*T(Y$l2^1TY+X zQ0sUq_ncJ`tV7Tj&^tg0*hc!S`6*sVWbywn+e4;(U;-APKysa+?lhXHzUVY_kRBKkb%lyxoCqN;`G!x4`k*>Ew3|gNW(F zFq;miEiE8vsCz^rpy4zAepIP!4QYx*lulhj4+HiFt_4CpW}L&3Dd;m=TfR(E-*~9M z^jTN@*q8vX@APLM(HmIwK946P@2amzgsK2q30ww*1TKK9m$H<1-FqC&CqqRbW>Fba zE1fyuL^+09ngy<&Y^x+6$0SHJ-Y}RQG$&PC7I3KXDP%RwNS9 z)C$ABT+f6|JX*N>9JhF3o#kpFhSLkWT-d}+AVCr+4sq`1zL5)>76nv}^nW69b3chh z=o+YoiPArA@ipkS>o^3djEqRPj^p?SbDY=x+O`o4`4)Wc5uHm~CrzF*1yCTQ$4ZtH zS3C|c*g=7x1pvuJjZn)+(sP32u)dH#MMmDpT-Z^Z+6$DEwS~E#FHBFe_xCqXyzTGj zCv$UTt7Y0GVX`PI1GQR<{J`R@oapY`uT)_{aGhWrFltQ4MqUiN;f;-b_hyB>0HNaB zlxZ#!@lDy=qF`P@iH7!^^SO*~iac1oZaR=)>Dv*0GNEWcA%S@XJBo8YiO?0ru)BU= zcnB=s>Ye!oJD%EOrvs2Hr2W{j;j~mcd$b6Vntm-^?vO-@w2-rJ&yX>Y>AU|yiFNDx zb!vIUOw9Nkx%%BSVvEX!>0`vNiA3_YRBQYZi8xIj+=;lM2xP=LgpiiqKESy0^uPZS z?Z+UqgJcLGKljmnPnSdrL*zljUE_fpHG21cdeO@boG5V3?Y)0_WjA)buqr6XdU>GD zktw7Rq`v-tG@S=HmVe*IFH%VoQXwSn(3Vt^6v-&GRN9DAsiZ{_NyAD;T4a=Hr#)0g zluAf@5}GQbp(5|+@_(P+<9Lqeeg6Nr@9X;gzT-U4@A>U7D=Yg?N(5#0C}ui~RJCev zDE=#e?j|EgqQ<1WqFsY)Vm+L^kX`M=OTl!%{M^g}rChnmjf3Sh6c!P;z-Y>Qu@yY8Rn<)6a4j2ZEYX5I|9Ye7gMZoax*|qX`Pj7eSU{ zZDkcPds@pQYZ?%VqzTz28G+?{i`j0krG)7WVofahI+q-q z`K|m@rV?0KpfYZ<_(f(@ssY@7sj1<Yw2#Vh<;qQ)s(2C1 zG@#)MZqcDPIz49|_jbmN-Ol~Z*VZ`WnGu$1m@bY36)j{;KKK3<>SROD7%Lrdo&10O zbgi|6PBd|hlPx6xfuF{8=Gzgaa+%b}A1=Yb9yxjxB5~7h?-0I}Y%l}lQo`XG0}r3m z)lgHzyN7w&vH)}7s^`w zZF$XqIdozreLY|#`2Bd%^c33y0l_6H5*rXB`4$4p}S>Ujl9$k*OV z4qZX!Lwu%t5d^*1%gbI|5S=6_B1}!6zj}2IQ>uYRxeLol$d&Ju?wD96u{O58rWhkWYdtLTuCa6YUxv=kZadRNJbG1w~VTeqbKSkF!amvVrLk~ zTmQiV-VbdhIQv@WMi|S$ExYP#6h98aM6*km&33Gh5odrn+I+U09VX(?uv8RC6)|Fu z!=XuRZs-H??rf?qXq%d@fUz$Bu%GKn4P+u{iuh1s4!@q8^BUvXSdP3y7Xf8%nF8Zif zSN9GWaw;Pu=u0!qeO>%y9tB<6G$9DH5Z+7Lw@iaDwmoQ$@R6VoN=I%Kpu!c!at*{?7Tg`GKa8 zjGqTfnsH#dmzyj7_bTxoXeC!K`fmJy4WnA-ol*u!)rpo}tA;xYoo;aVF{BfW5O9`> zwnf}SW&??+)omsJ=##R9>bOmhYw&)xL+ZZPFIv0ZYP<4>dJ5#?gt}F#NLa%|&0e+tDT@{sefa z=th2_^2m%$WSY-+-5LZR>((W`tqehSj<4bk_iF*3?J*Cb733e`T#ub41AXE7OBko! zvd>UaW-tDih`LVOe&$sI>*wl>qPIUhCrn4NX)a<+5Q%%5D&hHz#WKBQ^m#AE(gjzf zTB0{}k6X_zgfUB(_PeM35St@JDpCh}@t>Re^B}0}smMZ3Jh7ELpfoRd7n>u!pLcb7 z2~)+P=!k}W$kA|hb{4lJtu(5*j7xpQS68FG{3eD8^Azln^iwtxro7cVSKb_~M0$cV zgU+clv)^4%R5Sn!Ks`zI`}f#7iEz$DOFS9YfB!If(-=YUWps;1>gA4U8?i#9>gPDK z9N~lXV1yK>2T(x}gaEY+6KP8Ou=r)pYlMBII$uvyXSS)J{{Y-&bp2z8P?jiMy*e^0 zi_1=JA-DO>MJS26xnF7d;ftt4#I<}IHn8zk9DG`N=FiOO)7MgciH#Mg7^T@1BJI}- z@NVXIDMK18N6PNK^UVi46a4Ucj#_^cj`9aWo+87~Gg$J8V7$k#i;>#fA(iOjs46e* z*jYZ!P1#2F;2g3%<6-pHR1R`-I6X%Jqr0D^?)^^NGIpssL*8>vByVcD`a%$Z>ksMP z5rq+mx4A`oH-=fJAO{+bL(jNzZ7me_V$rkQcgutUuhxpMTd{McxR68S!zTRlcVLO| zDmGOxX7%MuTd_!^_r#4DTfW)vexo#`&XxN$^PFe2zrybF8|@RyU3MtC!67iTZknjj zVwYa_{@&gNOO}|@usrQ0&SQG@GSZ6rMeegK%EBn@__VH4QagP@ocazmH{Td`_d`Ye z?sXw7@I`ml~*XgBSe%78YD$wjb$*b-3D(f!dBFud;)=t1#tPYF5J^%AAG zuJwrWX{VVWqI8bp31F7RLvI{|oNR02&+-GbR^YikmbRx+i_#pq^_kISr)Px_X}+~C zOn%b(qj(8R1;MnKtYTesC+L|)WAXOwvd706Z_bhJ)QM|0dFs>!hN`3<*kJa$TCi(v z?d4R1KYX#={N5GPCUX+CpW*Irma++UQ-qq{vP%I<1t)!I#zkRxIN6&#~<)bHIJDQ>sNbAHsk}eV6>~ z^uekaw}IFaeko75VTg{<@SQlZ%-x-4x$TSrrOgsSvv5Hyi%&$e*g z2wUs|Mb?O{L=BAj7G*HLIFUokqjjON7?rA~(!`%{i)D&)f5@mi_wI!_$S|ZQ`!ddsH>?67X&8pDNEw;Hl6764I4Y)8Xy_e$UACxEmF3@=(N1L z`h4pK_1lajpgvmhvbIWeQVh!>fs4b+v-g=qT8qPc5$ECdJq%UZgDI*Tm_Di3r6k35 zXV}um{Z*p|v0J-qS8$-J4OJ~`3~j^><&@=^Hs1cbecno93UxZH{%Q_geZ7y|7F_DQ29a&GP)ZHx3Jx3W$UD9OvxT-0-_v}KJjG3sbCmQIo+nbz|e zua9P)=CI2eTl`i4zZWlC)>fba&0;*ftoNCnE_Z@<7{br5m^^Z7=ooDltv$$#;bsAV zR@V7azThh>?lib@qvfIIFBwrQy|J+Zsl~Fzi*v@G-k$#!(u17BLj{7kG#Ln%Tl8GX zao$p=>lhdo(7a$D{sh>oBQn{m@3>O9J;EI%fO(&mSa5S?+dnP06XL zjB_|oo!aK_J(fn$AA&5ffe$Fq#2u|Ql84^8GhF8vqM9X#GakS%$621m#qWX5T(TOA zFtR~v1}4DYw1|)N z_RgG!WIzbg2$G|i9y?a=PvuU5_H!4r14RdtubM|X)ON_qyZzUoD#*D%dkJzxQ&tul z5eX=v$~@2UaM@9{a4@Au;e$<*ujTDuCqA@L!1R?pBaS7ZOP^RPMufauKyKWolF7Z>R`*_r>R#bsZ2wDRbQX`(_d|*$v{wDcrWm8swTt@ zTYE75%y?1bo6$XkTLzz=GC?d5cPN@aCdNt${^l(}>zqWQ$I~@r(pVI=dE=e9!Qz`a zHhfVpZQot3V&j%JHOr^bTq0A|FJP+!b<2baOH-qut;E$10DK}k=)P>(s5RUEuA^U{ zTq}@50DOfeqi$& zkRI0OX5H@vd}$0&U8_a5^`&hWl{f97Tz(vO*k9Y_rgkI}L3#{-GuLT}N;1d;UtC@V z12OV~QO2P|qEN&<2_T7ufzonYbS`fGm$XUywxn`ka9}Y75BTmX)tz z?|g6w4J0o`w}K*2l*i%U+Stu8W`^v)eh6#;66<`;XsW=HbMNR-KE$>jUFu_+MR=a* z=#|)i?>Qtb_6Rpo8sD0Y6TUe?H ziP!iJhvQ6e>UItKA%1YXzcHA{>F|-TJB1i8_{eq)dTpSdOFjN+x)P1h>ANWD!SIot zy7rs?l#SuPeqjJ?Gj1I7z^bpiBGJMFtD&L6Rm+vd+C1D{2?_qJor3QGDQav(qU{EN zaH zT7SzFt`274;_hqFx&)m~RX~srKZF#@=_|DPLRzOUK_^D{1m$*-le22oex!1uf(uJG zis)6V8pvoBcL*~)9??WbKjCSWLpy>76h|vHibN9G3P>AC1SCpIkZ)H>W6<+Ou+u;q z+B46*gdh}jyq9kHk!edmaU zqVaepWyB6SMo}mt>haNbU9*d#8Y;vk+*}58eoKalGdq(QxgabK$?4N$TOP+TdtzA_ zaUgHcWHzxJw2Auj`!}1oFfBx)gyInt*7nUC2w5B;C|g~I>Ip$(w1a%m>FCAII5DgD zuh}}m+B6>zlq_K(jVeyV(vaY3@i2c0D7?vzyrYmBw&@4Rby(S4Z@53w_mX*se}e%$ zokdAnC2!hrNf!GEc!7ZG&fzj#X0~hQ;gjsxzkOM1OCo|@!S93hHuyKyjz*-)8SvggwcUo zCn4}yJTE${IUhd4geWb->5px_qR+Dt*6SlPhM!>Z+X(1Na-7#}Coj

    tES1E;`qHhy`mnhaYf_apeCnH(0`mrW6#Fi-hcAtJ@`vmgXde3&7GZFPeR9$)n zpE`GLGrfi&;LC>}HZ)%U@9$B@o;Fj@s&l(Xvz(gyL#C`dloS0(_7kWhX(8J}*1mI0 z;ZPcnI?URC*6dv!s(>RHO4KfXX)-bosA?GZQ!ZG9;)0u#Hv)ynR~USp^Qf@&nyV4D zY6;~a9YPRedZ34sk2q}_6I3)Eo_||xbT(M6OIhZ{Ip6=M1z@V~J!i{p(O?r3QRi%u zI##i2b7yl_&c^;e&VVY>o7D}3a|YFG%j!25zZaRcC*qZ~KYK5;_vgtfsJB}PN0j;4 zr)(ZKfzA#FzrXWoq4Tv0(E_XGU%s~#FQ z8WGIc`9vi1$)iXAo&ug}Y<$zj@X(+dZcI*7FHQoHJ~PQu@#dpPLz&Wn@i$sggnJ<@ zhRMwD_n0HK1}|}EEd7CvNHgyLdHL$qyji!(bfOdm*hh34i2e9XsIua1wrg)^%$=&R zw{i#h-sCT>rAz3hMD?G_IF=G(H>{NSr!N`ry9jkW;cCu=(->wWR;3LMtGvB$7Zr5{ z9`FkRx1~d6oVWI>ARuC(-1|aJRCQpa$Okdpfw2RT4%c0Ax)@a=A02Sx>5Q%xil&Jt z)6#}c9#})1c4=cNWE1_j_Zgn^h9zE=$Tq~uwYZ%-wKdLm72OyoU})1F?!zSTv0|14R+(P!B!X}j*e%x&)!xpXmH36wRe~t$=!nd! z`D3zn&w%gIt78-iKZ3TdZkr3ElcyY^#6S#%vp;|6O5|AVDlf0G^e-YtWR)b_iXyf9 zue2%fVPCTC>I#@l+~d+}Bgk6QNW{M&nmKQ;FTq2t63SOvZw#>z2n2n$y$fUGjEaz4{zo)XVm-Wde6orI1%d-hkK%b@!j+ zEbyzL#@>847FkBjUh2dGL{c$@I{*Y@WfUZPnN|aYe=pv2m!;Cu!QCyzY2pr)W=v*& zw%L=INFB7Cba=MM{-UjIY*f2!i`_XK@{qAU$QlxemkWcTaTp+_dahV8DA7^;sUIr& z%q8t+&YmsmC`v85G<{=Ihf%>S%DL(1C*n`~^C}LVNOHQ2uv8i6x@bJghFb+4y08Rt z?YGzP)n8Cmk}l{@RmMS8_f{?>4haGp+gGmwUKW$*7-m3r{O;XC)pv{k8~nmjzkE^Y zS){~=4#^0L%F2X`t;^o~Tm~#>T8$$O+s9iw-IKndh5?Ym0K0a0l>OZiu_gp4kG3BS zO)O+Kft9c+&%iYhXflgex>r|x;0KL~Q1 zy;vCbu_U-sc5mz6d!7@{oS@v|5E9q>@}Q(OYuBP0%^f)M!3#$Bd9~;X3Yw=S8?Yj* z!;KqUF;OHS8qv*WOEpIGdxCZK_spY+qvl_HRd+0cnBys$>^Y!8T2{=$HvdM$IqO+b zAP%@J(=ih+{QFiMS{_Jmc8i7dA{av?K_ilo z@RF&#uU~gu4#O4UKWW?ROAw}>#_EyQ>_6ms@aJ4+X3p$qe$vurwIBg`!eROVm##Cl z-Hm2iM*zyJ4sb_aQc*D(lYe%1zri$_!#(H5#!(yRi1Ti~W(R_UbIC3hiLRt^N(Dnx zHK7(2r;Fi&9-e0EG0PR08DifaxI&|k27eSn{B_c8u$1`EGS=9~# zXY0bzYI;ay9yGy)&T;Iw+V-DJ?2smKB|eNAc#vdfZtAiy)C#K62NO`|l_J5+1FSc9 zH-wtS29r}tH%@$NAPcs-IRKMxa!had-NYvpviaP!_v~#f_!mM6M{4$AalLTbyTa0K zPh;OYv6xQXAqcDkN4&-%z?@a3TQ{ui>@<39&bhSqi+bzq2@S<9yP-YUC7y#qu;R?s zG1{PUfVP)m1Oa~jQ)o&F24MhL_Hl6s4;`wis2B`u%vdyafY|E1Tz|;t5H^wVmr|x2 z0r~c>WX^_Ya=Gld~5;2K}y2iy%w0;JOC8=FJe?C9hZ)Mwv?pjZjXf%j3+4uV) zRApL^FFa=;u0U+8s-tW&{8&cjgZs#Pv_v|m<7EgW=6%{qZ(3cSU<`U2)NeeaKm^yy ze~RG6$YX#_g6sY$!NY=~Lr9goe!-b%1;M~M4`wu-@}Jt*BxgCIgeRif^V7p4BD)3E zg?%-7gkxMgfZ~9%wY=h?toIhM%Z*Kq1lfed#FW=Pa24KKnN1RMd$pVP2NFNzSq@ny zdtWikE>Qvy4Qrkp5=tQ)C->uoRK#%Zoe?){X$T;_lmD;Q_ z!FF>0813w3ow`a(H(}t}TlM7y1gwPzfAMwJ)jX~E_N{Zn3J6m|0sSj~Z*y?)uw~Ej z$qYNT-*Xtd{96mD|o)G5cb^PVu#ofDh4fyx_J*l?f zzz5dV;2g#tPr%_qchYm$?%hEE4jSB9c$QNFxR?&O?2E= zKjq41?U&N?`6WzB|Db=vBdEI0*C5PDReo>v8kky`iqJnB9R|ZQJV?1o3LP8z+c46jbHu^m1s^Zn_QLUxY+#_uo!S=IpUQoR~NlQJJ%i zCrJ77<@21JWL9|cm0@qO13RW=mo|le5ZX7qEF9#G!;@^lHWQlaTWdo2Al{QZyzA@m z{UCxgq;2J8e#dohb{03&PWWUf&UF0%oD)$G>7GxN*m*ht_5Qe0SbKWin#`{i6`2&& zeRuWUy{cFC5R5~{J2+5&;Yp<<%I+XU#*mDbd-H$s?Q_7Hw>6nQ8 zR+dlu*)gjk|0Jk|Nr+#ywZMI%-PO}cr!)ORz)biFC6uAeUL9wMy>x z0%~N3dJQ=7r>Lo@BiZdV{cii#c^N7q%z-Z3ERmB5hUH*EwVQ{8a*}Smw{mD=qUPqD zr`I+N?+-KZG|O)lN}-=KneUx41(%Kyhz`1S+xlKEgh!4+2@?ctJ|lX@L>N%&QCSIN z0IgWC^7_CYgD}`Ds(AHCy|WjFJXSK=YoDuheL5i4)9H$$5ab$d3Xr{(l@;ax#S0hE z$xZ!J;iwahL`v3%!JtnTVja`HjV3EkHRtTwTDSbcTxZtD!kbpjn|_`1i*|p1Ro>n< z-+QADb4nldZP?5XCpLx)0&7LuKr2Zc<5k)>I88zG12_}QgpFJuq*p zt+YUp_9t~c=vPXWLOoOa!)nLr>({mJe@D9kjFeuRt>4B%5wwB^fJsD7ejXgz!-sJ> zF59hijw^I7Ae?ZbXyDjE3KY#QOqYJhDd`JpE4#W3wIs=T`R#`1RgHk}C${I>?>K(E zze${pqB}C7r!#itxG=Lhbyl3T!2Q-+F|zM`aqhttqW&@ZsPxP-}rZpkcl-hu(vtC-x}<1Jq7L zQd^17x^?=NgXfK6MY9Nunyhp&+Kcp*(l!MIOR_A$L!;NQkNAnr!!CBZa}6%&-F+8xkUYEERAU|oW9`-6CLo3-;vN|1w5@lmaiXwyZ{JH45`OMW zp7WV+xmTv|u30Epi_{`Y6FiNWuCHg{2^nOl6^&l)sv%aBad8=9ZvGERq*zwdF+(r2 zzU9!0`q+`V)TL$<<^%`gD8$vPy-te007B5Xi_uwkcL$gNB}}&9-O(STX!){D_Tvx$WCEO&*VIIG zogF)fAj4zk4~izz*3-3wh4$^Yaf$^YEER3p=J)4;zdxQjYNdnc;Cx_wykRJFnP(U? zCY$YYL8}f5niZGMTa&L5GI6^&ny40u%f#uVnv? zFoq-u$a1SLhM&Yg#-M!>A$;6NflE7r#(dUqA9RB*H!C#`h?l`Xw^s?Y!^ zeNscjs~77!cnaTo#h!rh z^X{V;avSu>zmQCV0Mg9k95QsM=8eut#ei+&inf(>i&MgG&}m|4fl+_TC{M~&51SpX z#YgA?0g=8tkMA`eh=~91@L|0e2^X|#(L6oesyDFaB1VFaks7|_6@mar4@baG_JXRr z3qi!wh;P|OH1jO5F}@~Rs~Ghh7wt&1_T%h|Wy?sParG9vckT>YUIaTTxo+>VMN&vX zJ}Cf~6p1n&IMQcAVCb87QN^Whn!}?w~a|ksbk_f4acabPo^QmCPqYa8v!*%hjo_RfI4}=F>(fO{ z9^Ucq<06vj-2b7s(w71kL)KO4k6x3>dNTe*AV*@n`q;7X(a8tqR}K-) z+krzrt)S3irjR!*Br^=e*4{fwBL@%8q*f*dbEyVh`yP|^?& zq#7WwqpdCT0%U>@yQ~#MusWl=qmu%QrWri)xr=<>A$UPsN5^(S;JvhWeO7fL`IRnN zL1$g}Lds9X(N-T*W=bD8?_lHy^R{bh#y&nZzh~F3L;u}Eif|w{wv9L-!>vz<8T$_t z)#2D1@n+CqO6(AfPI_~cmkYe6HKuE)W{U&T&c&F*}{1&>kHbDT(FHmJP^`Yz; zJnK(YVHc;B#wI3v9MhqcQjbc5!13bHN&RFB@tD-~nm$;aAgHjvlr_=9qJn4WZyxC7 zb&s{h>ggDYwnSd(j6i`LncCGi!wSv2EzBj(zyW_&Bc{ZT2p_s8D+h7|^+%wIo4K)Z zTy%8ns5R9Hg({dfeP-15?V<<6Wbb@LI%p z$iZx4Afd%|UZTGB%iYg>rw=^<(&P6{=1E*yRIGJw6rkd;VcmohAB{LA=9xZ|py{T3 zd?Gg$>LZ$C-6XbI8(7WhVQs4zLJU-YF;1^_D+^sir1WjJFLWB(|K@@2L<0POsD-&P zeACK7ueni^LKgsU<8!xtG>gv~&@2Ry#2{Wjw9T%pn>2hpBcq>o6;ybm-a!Le*kk)WVff9UbGR}LC$Hf8Y z&1IKMLc*NMlg0KgeweaM#n-P`RNy|%qI&2kXi1o;{8Pg)&v8sJ+2nH`(2>&OZjJj* zj_{;$(tsdXZ&0uJW0?kL9wYHHn+H4nJwkDsmb5@OP5bEACd%--ZXO}Kxm(7?w8K;w7dDq8N0%d%=jcgNO zUq504NvSl=jDd&H(3G!}St>BxsU2&kZjoW-qWC^0U-a9Kx=PQRAcvQqEF$?Xr;-j_z7<=%nwrnmFZh~zLXXgWLvQ&0S32TUd5Tn_B zF=$ZdY1L%U8&sKV*Ir1Nv%sZ3R8oj~)0hFO!K%RTHc#1fF4NeZkWz8CM@ zsaW@T&%;6Doeu~YB`}0db>aKTQGP==rVqE*-@pJK8@IaMcIe@d?h33xJ2K6j_MKZu z^>OgeHYs6RyUUrGrMGX(2I{!(Sxq7NAwYCP$0Ue&hzOqjZy=(|GOo*)AHdBfrV3>t zUlui5lN#|bAO>g61__FeLv+t^n)8sn!!I_P?-RFw{|2UJj9tSYPz1!qRZ*Q@AI?oin@tJ^ zX0ny=LYb$1LrPtlI~nQnnmd}DM> zQez0iybIz3GS;<#i6HD?9>%rkFLv+S$HdJ=Ja1VnV`zAqK52FAFvJ^m@0`I<=;dh! zMs*Z`3OF-HD<-qv@;v$p-Vr(FoCgp-m5oFhr-6xkI#KL1ILP2PagVAWh-%`OUM-7_ z)lV?Le)cTOBsrm>Pb7z^vp`bEN|cdx?35r&97W5{#leZUjbNb&FM>ZxLj8ikVcyn_ zZ+9LLjYM%g#F9dK4B#~dZe(Pqp*5BW>t=6WxdSJ4gPZl!WpS)?|sXq1wNkY{6OW{RY0;D3i{Y4!Y$$Op>bn#SlD9zk0XF-^q$zBUI-!7n%rB^oN5#!PZ{-ac@HXxmQ1X8b}JI&jwm*Y!)1eH1XN zZ)Pbn>`?NY_*<2H!Yh|9C0GC8qd+b4MLgd_)lsX7Ff?}>K4@(3Iw=>1V zb_~y7&-Gce<~&As)W0{!dQt_UOL{mZgrAt6K5>r{x!K1gfmC>W&Sm9Onh6C=N~3SK zi>dkcZQZI>u~U9I^Mi`|iKb@fJe3sM4))rYBVK^6_3=s!CE}iqcuBw(I9gertuoA6 z#Ymn#e8>y#J^6c^4I8n*A+Q^7K$anH_U1+j#7M|9^cVD!VPQ~Jtjo0Ot-btpR_ME~ z)NeHaJy}go=+DrH%wBP=`ojkkBO`H93dVn_^L55j=!t5Q#Zp4D&!P|JXV0CBlN1sY zgSFXGQW}gJ^VWN}xU(ltM5;H8yKCATPz6d;k?F*RhqzpauhdLn;d}GBMF~8lzJi3z_MB2U&Ig z{kBnJ@dOs~aGO1V;I=%a7PDTm(mw{F6A@oOk~ej=_9EASzIWS}Ei^OgkqehDrLTTP zOU+@zD9QhCkZTtl2_d;ge)=ONEU};eaK`_%085rYnmznvtQQMBG$y@_n|b8!0YH6< ze#!%)Drzj^FfN>7vr0tk3plX72_31%gS7!$X<68ChS+k9wji1g^2sA^SXop`$hqh~ zKMd<6;G1#9rHyFT#ic9E(|i{AuOSJkg;0WqfJ{P5sMe#$(#4BsIyv2}tOI4D?g8(g zEbfo%s-Xd=wY?xv2r7O*wL)iK+BL=$p2eThwyTE^LIanGDLCyi33sfe<;_7um3UuB zFZ!tU66o9@SdnoSx~^2HQ9}s};xFpLg7|dQx`i>L^)p(H z?ptVOw^%w2pElkatbfk*>FoqDow9f%uyzXJiSxSBM5FE*pkU8VonVX(?hC0TC(u@o z_N%vYyBZ#T7fsCYPRec3ipMEINiIV|Q9L)XfRrfT4m+LFB zVBi}q|Nhp${FD3q`MysnwvVh3)xRR(HNJlYDaOeqwymvs>gwUqwM&mF#x~!psR?Z#(ia468DqX#}4rCpr8=%yUVRU z;%+qqilGzVr!Kdr;8o~p-oG>oRPqelVgs&BVGdJVkpMZleSV{oOf>K5-4ZKn>pON* zqvT`zN$j*n_{apLqEZu8_Tq34(K0tL4*?yV1;M{$o#~Ytu8Tu^nIKwc<~M#(6Oo&2 zV%4Rz$%A;1o@tipCM$zl7qkFd2W0puMwUW(ov((%t^4a ze=-(~Ne`HH?t^&SV~zB|kulg_km<~C7(Q%RJ0Vz`i9bqgx`N3UH`GNa2CN_u<57*6 zRwa-*awulxKj_PEH+`-ry2BDcr4ykT)IV`z0G}(@I%;C9lG`9zGzRRVYQtb#3yIC$ zVIp0?#^xV0j7PhmIJk7B^Hzr;t>@LzOxK_0j^!sF8w@GCgh?z2fBRM^?F4HEmtx%{ z4kO&2?Ht@31*Tp0;6X9b$Eo4Svdt~IUDIRWN zi6d6EJ70r6E_11SaNe{~1Wn9%-Er1*lbP0p?pDumooLTo%h_kRw`Y*JlPB=G6YYG@ zpP#0!nEbIIV~t{>BW>uO+C>ym+%@br5QTmCU?AF!GeG~A9TaDVb2b*lmH zp8@y5gF*CnZ9D3=C+j!Xc2p`1@>=XtOn;C=971#6A7sXsYih4shw(?C|yTODUe%Exh{c zSKO}*GB+|=`mD4RrQ`UwmEFO_c9^MjZ>BB&Z&}uIEGW!%`}gUS66U?*mb($^1eR7W zXkF1O&g>9Msd5|cgfQkrbn!J zd&BM?J`9=+(hZ!F@@;*mPLY^WIhnFkG|Vv_U!ffnuj1cOvK>jtS6W4Pt!)cbe|oiI zDf$MY<$CPv(sgotP%JtDNQ5!axj5gszuLpGn$#zk2SJiuFg{B8@5s8}O+2{diZzFB z+_=%|R=y}VcRz=DtsoDoby*++4y~rXoVWb>h&g<8&d8o{JpS^G) zKK9gvn#H04SH?1TOvJ3LF=jQ9uSx3d2w{LG}+7vRRzJAt?jR8o! z?hX3rXZ9KmYiomNG<4{=^NKgcP0M=@9B>?ZDN%lNj^Zg--PyPjvn_&Qt)mP%TU!?Z zll425Wn})ENJ|ALB$s-64V%pqJE>>SkWS;@zXu}}mtjzW+xva@H}6RBI2V}cCj4Y; z1t_~OT_^1!nSs+pWo^Fx`|bPp6`C(2t)Z}-yclqpJFNg(TBlY;=1cOg?{L&GbNvrd zZ#Ofs<5t(eE+VSk!fBph|8Q3agltd~4~Jj|T1k$UV{#}kF@$NYfC@ z3gjmQjoHxQrKLIV8;DZ9yUC`W8Aq!bRYd}p6oSoglVBf*K%L^8r>iS217VG9X{l0V zuw9|7RFL&Sex9$duS~mklr8YAA3uCBTQ}%04-#-rP?Xz36N;=-;`iUUg%E`82yAuL zlG7bx`-xKu>zi4JEmFKqKfYm>6eH>DJltH@YV4IR3qzRI0pmLiZ^Tp$d?VVed+iTr zj=r0dBTgC}JlIC>jx3S{*iOBBvT2n*mjN}#p6X|scpavF*#72=i85P9-J;CiBKLFP z>jpgn} z%alJ#;7QG{)&`@+#*N1<3fI4{7Rnobi$bo`id(F#vHj$Z-05(1z~DdWR@N*QVK&{s zW6C8fYxd3$K~s2SgpZdTyfd=wx3Gfgho?@ZuZDZ4>__{=sMPV9XPG0LG@y>lN~z8E zG<>@eeW0>xzB}lKRqt>ZU{P{5qVO-vu(o=S+3s4l4O9q~8zu?@*eqcQ%~$xXUf`hU z;xeYCfQE;4?RVg6$~1eqPaCxnG%;$w+Env|jaaX)Sn&&=^u|>^fNmKM1QO`fi4_7Z zbTc$v24;^RJa`UO(scYK*NC*$BUn1Jda@KI6tu+?!~wEiw7av-B~ z__uSXAjQX?jK2p8j+7TE{2(UhAQ|xc9$NL`+c%q!+8*xihp0P|`9K^|@rd0EX%J4_V56dCtkkpFDU@7#q)MvCb(H%~-&6B*I&r8^j!ujHq=MN7Ids zMcMZcjkZA`y^jhD?GCr!UU;54us8v$R(c{O8L)7I-hl5~D?!9r%q%M9jDvIkty^t6 zN?RL{4`oKlvf#Lg(*Rynh=2`rJXBF4d^Ub+77JHme~c?ItU(}^5uiOQV&t>sumX%k z09A0OL|iQIuCIAg3tn#U@YDhPo81Lbo?ROG?{|^7JrdB7E|2D>#0OJe{kpftZ>vU& z)&og_|NLgoRO>rp$FS7Fn;1xqQzD`A*=IXD+y9)95mCYnCS5}P5b2=iZ`b)SoR{QV zUc*Mx8se=7n89F*mzTYP3Kbn|HwZuNHY+JA)>1O9?%GRHmS&sV0Ge#nq z1R&t!q(F1NhI-^yqg?*iI<*vz0dvuan)dnHqDMBvM^dGD?w4=hRsbq`9b)@e*J+Lm zjP^o`dx{V}lFO14uV~p(Q=x%DJ5qX$UECj;e8FFmO+hxKAo9-?52xx*k=B*oCY*i- z;o-)_b&K$AOnA{F$ybh77NOQjyb>lNDe`HP=<_^QK2-gAj_6DNqIoG9O)HPj3~h>x zwA7~paZd_1_h1#>!rPXO(tTK33UC3T)jkMFo6^pBMt1?Sqz=Vp#je?VROH*W85+c} z2erCE84K5#MIkW6M)Ti> z5}d*N!4et#CTy+8wgLaY_(s#6WNdCU@1WWL(U)6?n7nR*RxJ^=%|M!d>(@j5;xCT2 z1e_%K=^$XSyLTsm{vr-FF=09~4d2_y%|bcdMWqov&AC445Ax-Y~KJ1hXhPvp74m^WwaG1eRC6> zsaj~dYj&vQ9k_!@Si-)2VOpoyWC*Fu%Ye#4n%=)#KNcPeBnv`1rY!kOBbjGRP0K6m zBen;KUA(;&hqgI9jP}3fO-pl&6$4)$Sa*Rd z%Ig)&g0YwE9C%+geQ(QP-FwBw0GGq|4Yw}R;*iWj!B0jzoTG2JwK;%OePuN?KW1g| zXNI^dyp(BPT0;78atw*iusESkTBcxsivm?>!|fhM0X*c z-G}R79MBteU~{w{yp;@n8H{d>_JTi=i!Fu^V`WyqFb()pAO`#MJ6z5_LrTrRAhDaW z#Z+80fv`4Tf2i8P-SjC(k+L<9y6s{}pV>~p%i!4=kN;|C=F~zXBT;`pE-UK@<`xl= zaP(jSU=d{>;v>&wKiGQ;Y$qqGr#h4~>}(K%U_h9bKu&n1r$p8b{r+3E6vQ`;3dMt1 z*M_N@mexsHGCbIEKv-OM7)8s7<3qL<_6XL-MEuD5=N}UDE4*w+|sHG5!TkT~m;WVRejrHiksuCJ2y<=rLr{8Zu zI^yr2HAb5N4=O(8Qym1K(|1VdUTmF{i>ixKlBuYyEQ1^@DGB;|h>ekeNh37whx{G= zL`_uW-I0aFL{I{lp#P$$M+n+QZba;v7b?24xd{C6n;37cN)Gz2)@xWIB1TbyC1fMA z`1a=1@#7H}R$YE6N-w8$5o(_=J;h}dM!jqJ{b85`9Vec%fFP){`F3>q7tWpYpQksQ zZt+Pu%==+|kYgSa*~d5$NRDcZ&xgnV~v#a}$ zU+lCTvuk!qqz9|x#%MD(N`yduWMkvOR5q5a>WZK=;1?Zaq{WMW%~vgnRDkyct3)(S zykUKC<0{@ie4SoYpY@1u1yHJs6o~K3#Ak8yDE+x>~F@DD)i@ZSnfywg? z4k%&pz-r&yO_5FVfV_Nn5*L^s)yK@93O`?J%LW%MoVv7HN}F&?Sa$14CZU2Nrqa>i zC>*w}W4hWoZz~wh!-wN{?`~gdWC`T(?%f6Nid^gtP>sSErMxXx-D-zTHc=CpM@UAI z#8#;@jv-t$&l8-gP>pUsl!{3*AT>cjL4_xCNHYBQhc6xW>e~6Bwj2Bq2k;)#SoGrmZ_)=nEdAOa+T^F{)O9-(8x=qJJvl78ifBdcz;EO z-RgB?Cg_kpMCYQ&{as{mZP}s4POh{=hlC&~Lsoj-?EH_i6LBHs=B!M&abw1e!5)~` zac|Hn1oS(!M*RVtogBWNC&>UZoh5vie!>nLMJp;xoJ0ka5hDthwHa5_R;;lQTufM< zsSMv2rW1yz3LYvcksL`1umgsgO<=rCE|fS4jVr7n=dgvJv-d{yV0$fGJif9m#v$5* zR|vAs2WVVAajz233PYJyeWQBrR}b8y1RCrZucSlI-8i*-UIBXx`WqNve+5yhi6U*r zjP&?-RC6rIVv}rp0TdFELct7k@N#AoKPVfRnavsRako$2`vxj)Y#}l(W|0A2zC50s zY`CBxqHyD8UuzTQ$WWX3d1lx)Q0|VK)IzE?Q{^$IVta-C62MC>e z2kUn8)vLLVv%K6?hvkbUgz8UA#bQ}r!lY5_1-3>ZhTi>qt_BLi>C?qeao&SDI^{ez zgT<*BzlFijo|nA{Ad69Q7V2`rl<)R&&j4kIQlh8-kVJMNzpja7NjF^ekA{Z*hT=9_ zi-y0nTlq8W-1a0~;w6&Wu(`Pv)qg6u^rRc-Xf3){H*?f@iPoD{-a`2|eJ`6x7D4E> zgDnR=(~Q9TiY?US_pe9j3u#0IWLyjA1(sCUnUvJ>#K_X@ue(NJiQs}252VhG%NHhE60+5y1QXf5zpUD}t z1bcsJ5jg3~uKP6{zS%tlfsT{YA-pjm)}DQ{fCUlJ@%l+&Y-{kPY9(nnhC2p}kh*ipzX4w`)-MEPvYko~#7$UJtJ=G>b$#Df*0zB;m7ar`#kA;~0Gw z2rh4NXfK7?t69H(9R+9!o{oo4w(8cxZfHm3^X92=7dfTWeh<4LS4Hq$nCCKyUJ|;X z^tnY9Yl32jW-_49ahDb&9-4!5lMPf3nE5G-dsCIBe052GfKh^o?%FDZv^C&JPq3G)B|a^5@HKBr#fMDzcow3Hot?Dgut zFIhWUK{}fl12RhDK%SVw_3`PP)L#2rmqw?J@U%GTCEsYEkBVozO*Ecutr{@pUleqGW zykX@nk?-fvpQ&%a=)|7s&R8?%O$K_T{BDeZ27iWZ zBi^RThNnXE$-_b_D6b;x$K%&bvBRw*m$?67LckkbaeP&`;N`RSu|DH`Y6Ffpd zh&kRy=3NF$>_iXltKY6SFbPspvd6n;oDv_=X~v8^iCPNAmPmD2Mn*%Oq44YJIV(jg zM3)W@op#^F7h^5E{+^qgqy6NhR8_eP?3=*IV9%!o`G)oiVnJnClb@QNj)hA%P0azX zmsq}||0gt%=O~vonECjllxqD8%yHVZM>J3vP1+SAPFJdUa5??hnn&@S{YIx8@B< zqX}z;7%Ol5)CJMEC_(t$1hl~6JNm7>akZiVC|Hub_2R{gmoNW*mL~YD=z-miSfORx-|_@V-J|LQz;m z2Lg7dr&o}joy>O46W!Uy#I)dGV`GjJQ-zi(wV>7j)lC=KY$S>*W?yvFn*Kazs>;Fm z+p-h}OfH%&3PJtufY46lo&ymJW!!BifKNYs{CG-pe@+h5V@Gz{>*(V3pTfgL?-b!^ z`2d)sqr%x^lI>o2y=bxAO5EWkVu3ewhGa2-)t{UoFO;q3$h9d)^_hh)LijrMBBvBLfw+i_Kr;V zHZ>%*sH{i|eZ3r-iAD=ucZ$^(Q`1Dp7|BsDGgDK!*!0aDits_##|*j73o&x_bCusP zn38*Ud^`Yr1{!5AuZX;g?SWIx0z`bTJNm)u>Y;=%(l!qi*I`W(O=#y%;Ei7&&4Uj&MS8N-2Kp;!b-w`n6T zR_^@jO@8y12=AsPYeqn1QE!J@J|hQ%=1-a^Mk)wJYtcABLBk{{zVj5=3656Vj@_u% z9IA6BU{$&o3aqm3fS$O$&(^^~Q%kEIn75o(lzz0*TUz4|QMWeWd)Zoy+5svt8@BIK z2O&dyz_gj1gtZhcmACB3dvJ-{v~J))gBSGC9h!NDBZAvd9T?iro3!pO#UA}Szzbb1 ztGW+9H%CE5H^M!NkL4op_u)3Avh(uB`u}zTEF%B_0$3<+Q_v*k+$k*F)G}O`xmwFb z?_m+R+-Fy18x97uf;EuZeYdT^_hR4E(Qht1S0s9_sRO4I=_pt|%jJc6e$~6k6?He7 z;HWbOM-lIqp1P(tdWE}uYzjC9dWD}1y8_uzc=LyNJP&ghY{ZeY{}?z{%@yez`wDG~ zWb2;ibnkXY7NUy&x@3xeXv9wSDK`!6Uoyv9`aK+@fr-9So0rd~0LRh3t+Z$l>FsTJ zB?99vx<7{InGfnHFAuG|@a(gx0n!sIiWi1-nSL(5JyR!iH_`&E8Lw#43;ZI+rETVB zF|2O&QM7{b@iCg_pyCk%x`L@zJY8B`&wq!ILP3os8HxNazn0`}g9AJYXHb$W^vcZS z!dB7)QNke2IW^}pGR5W0lRvOPfkZgLL_%QHdfakG*#rSY3?QhKNZJeb z|8!m7|9}Dskx>hA`?{Xq)_uJ;8Cm|GInwqw=smb6Lv{!uN|IRL9-LP}iD>_&*MdOZ z7zrze%BWf(WbM38;Sx*B#f)}nXovML_&Ui0J5!`G?uE$7OzRYo5rW2%RFwPv9Kk%1 z^Bd}Mo5d@LAIsL2#EdJmzoIC&WQNyit1QuR#OQnMd zY4qR@Yb&?8P94Pffy01BnaS6K#O7 zy?1X4`Dh8g4I9vSGDMNQx&##q+^5g9^&RuvQ?XG5V|e&rHg}P`fxZN5H{-Y=hK4&4 zXA`Q@)3^PELO$c%e@Tc+p359-XOcWN1$T>F_oyH*0MBn91W&lcvA~y_kQXTSH`Tq?^*8aN}l=$I@evD8@yUu(}A$yMArXPjGke**5I7=&?_yOV6Hx z_yvAH?rSP5fREG2(%?YAe3@`n-AADgFX3s)+jYu?k9qzc3~W zzy%3BALO^cdEWuy3ulO7#>9j-#P}6Ec2h3s36ti}Pl8eA=%6M0dIKa79BRrpAp{n*9(QoNX zBvkH|b#P};(Lb3BV160#&gah<5g{AUzbgxAi2s8~7wS{IretMhaj@B8#V~sEn-$pm z!lmiO_Q_jy)6fsME&7J-&+{qyscgPLYl@qBe*8#eIm?+dKJ>k|5}KsDw{IhWOOAgs z!ChR?z$9C~S}(mFcf=iY3N#*V@1cc*s7ePS6PS}=2qCRzvAerU&z_ydB?Ropj5$^t zQT)CqzTbEvp>g1zthavJYAw|!c2NlRBL+t?g)$Bu2p<514+nIOEzF|j5nx8CV<`y( z342P&{)nkU9giZ=4OclGo%sGUgHN4I@4|?Ph%@&W z{Tk?Jx!>F~pe`_4V4V-kk0mDyb7jb!P}`_UMr~|TZH_Y`Xj5&DZ}HAE^m_jPHPVxt z;zU*^I{QsHRO>|F=CgzGOmDvsLG0woo5HqWyeHAu$8MH^As#ye3C4C)`BBBi#kX#? zZ@w);;L)kXJ5t!x8)1?{(Jk**WdM6KBz7oh8987ouYB);J#%m<=Fiv(F6bu#UeF~< ziDssSg&n4q8PVt%8tyUFr8rPv5j6~J{t*e922r$h=cT{=hLuP(QV$=X?Szx;GU&>L zgQaX;fi}bI*VpVdj1v$)-sg`L$#jGp`Y^~%Q`1?m$7j15Z8KsI(|l6s%$^;$7G9p(0FR76vj8E(5Ti&XR_?@f2BQ`5#h7-ETL4H5I|NJ7~t z?pCx2L){tMgcvwv;nBrQmVoNzxFBm?L(X5lnzYu8u*9>t4xogoSB&91h#Ou|#U1|F zsJ~gx%M8a8(%6wBc9?7ARZMDWNz&7$>7Z(r0aj$!>I#3G5k-io7SeST6y>Ot#Z$vQO|ou6w6lMWH}W3QaiE7W*OJWbDZegza%uWn}u``WfJ>^ zocqV$j6yfb5RZyLlm(qQ{Jli~7gP)1_aCyl&WJV#4!qA!Ote~e?x3+sUTz4n1pe~y z_%o_WaJ`k6=LBmL98ZAtTs@m}pf?jGuhzXmc*~oU^zKbRg(5F9(3QvyF-WU@aIlT+gbC>c1GZQHe@gT-b4?u)lMFm*M8x5?H(`* z?V*dSt2lDJ&rlLraDI{6RSW1-k&XANj5)CSqQkgQ8WFAsTB}5@pWg{#@ zsOR-A+RyX*@8|d)-(!FF*6LpO{l2f^JkRUAV89QSCeS6Va4QI$TSA(nk$Vk1n74*`N@!g2tAG)tU zKZh)ylX@<;`Zhp*4nW=sJ=K4!tKL_~6(;d~*m1MDn6jq|FUiQW zO9+Q>!Bbq{O#bzwxZ14U0?Fs$=drX0+Hfx(2RJNlV+`#gAq%&w@>WVr3;yv2sc|NNyBTqb*;}5bV@b^$8og zG6smi!I;LI{f+cil}gW@bHRI$F5Ga_0j+=`YgC0&h^C3&4CeRnE%k$z_!>T>w-MS) z91*s*NQ>UmcC!i8@N=CvVcu$8<-Wl7$j0rY2dQqo$2VJmJ?JM;{M$Egk~p26oRFmm z11csUiZ^9DqNG8lwWAwWr$P{AWyJzJMz)$tlo<>GMNGcTnHk%!(=dH9CK{~fpC1$# zS2NR0-wLIN1O-Hg`7j7T=`NH5#sZx?2Y8Nv$l$mjgOOkDcFFMsN6H140Y#60ep%Gb zI(+z`8zL8I6&|=b)2AFjjX}Oyw=Tm{o8o`az=8WOm0Z<*JJh$PlW*~33uFEyY5)q% zYtG|Y7_R}zeu~%9M3lFTl~SKc5%{x`vsiZ9>xod1LCN(ruHWLvN``HKs8M7wCV>In zc)*Q3flOmN9v&L1G(_vl(Q%XwkuOhK&wm0UNsRq$d+y?ErkOp#Z`(K9Kzx9c>Y&k( zqjNc|9ZYVSrz74jxP>#jR3igpX$N-Cs)h|E+6_(&A|Htn?nDpHqz{n!wR#SB$R{CVxGlzL#R^fQUanL=T` z{cW%YNF@vdx%Yvqz(c1~Gbo2&TF*x2&F?h1LY#y97A1tCR0uPiF?~9^vDOkVAu?9S z+Xps>EjaNIf&jfd;!sytYjz-a>*hdZG(;)Hn*pvMS)@+4apMoGjc;wj#xT3TumdOu zMmB8>LbkFAiYFklF9pz(*RNb*_OVt#I^j+e=rF#E)#}#EY4pGzuO4ptWu~RIH8K?j zks3%CPQa+*yGY`qcocKEH+9=?yy**~PHw9ZrTR!Yc1$HBgK`u%4#XnzS;|H)u4}QxHOz`8~;)CcA=2Ta95I?16wAOaG znn_$wlQ&m@Hvre-rpB|uH7BS4ph2r@#hxg8RjWH4EggyHLVP@+;m*l&Q4nLbBvH(X zja%2Fi{XbB8p#{9+`6%~r-gC8i{Q1WlVgGJ8FDop%FRNpRH$oPy&j-RK2* zS?aOp={plSCGPFXOvTr4+|Xw1k`sdh=y^}%qier^99+D1$|<)YSV-*98Uy`y-54LF z_=E&JpC2wB6fk4Vx2G5DV+YBYuW~c&nwCNMg9%HvDe|Drn{Qf&UmJoz4%A`Swr#CK zCw92gq+SHRqKGIiS?DCKO<$LqYrcwrnKW@5esF$0JI;c8dP5hGy+Vf2RH>+Rd&hiXpphA!hK-@oo7 z4peUAy|)Zq-?T1UJhJr_nq_>LbEY_z_4dqq{fXT)VNW+(w4(F>LCKa=!p9?f-Kw#2 z-1zbG#fxXAd!`&{aos+6G37gRSPL8-)eSwBC1%%HB(S*bA>}fqqK1wVsQ`9tFA)5d zr4Y&lRPC@}~O|n!gF(fj2Q+f%L@4qjyCojvKxIX7zOh#^)#Y z@TI#wZC4vn`iwiG1uiqHyrzrpJcP5bNK8T^!7Wg}8lyy?Ow&OlB|)6wdLFh+O^8v? zDP`IRn*&NLW(PvIHo(286P67*;9j(P%-Yr%cr%+F>2{hYkG&ct^>alr+HM+=z&)F> zyug4Qd9V4e&6Hs2K1_2{s3VMEB4pI4Q2+<@EepKpu)3L^{J8Ylr40-YvmFXXlInp; z)&PHh)j9r_2zhC11`l>e!og^m$+NZ+V>fIc55N4(ykdF)?Z<*V_tsL)@Xkh)L@^E3 zw70EaRBkCwqrcwfb^emeq#e(`SZtXmuC=%vK! z1ZEXFb|j+lz3@DxwSnew(m<3_QsR4G#)>G!$+WG-49a>|?B277Gs(u=dOpni&7654 z>vrSIM_<>g1#jM}bPAW@pD8Sf3A-FXw6WTx#?mVcm)~?4e}_V*z7O>ej1tlVs<$0u z^Bz1v6Rpv{efMMgf$m1Ob~5y2m>B32D}M|fped<2Y4OFJ{TIsDv){@_1J1u~xEo+d zY!fFw4_oziyY2TLIk$aoy<}?QpDqs;ud=nZ<+=fC6-Q_lke|}}#=g4I!`DBVp88-^ z)M$1}v6q;l5%446!`bs6;h12}*ptp%Lm^qR_n*z|g>m}0o!f^f*;-9&kSUXPxP9U= zKhkAnzfzt$;!OYdJ!@bq@(~gMc9QT$2``3C8r~yHBhcYvonqB+4dvO*-vKzxaC5*> zT24h%tX$~QxKYQAMzmP-rQ3i@>?Wltf4w}q(d8-|^X~ZWMcj)J_#V61hMi0xS5$5+3OOoW8$o|_8B=;8MBuGJ!|W1*}4?~F6ejLvUHlIS-iAmb>~LMq%}uH9fj_=La`z+&Dff8Z^=_C9$DO@i}*9p??Ze}S@jc%QEV;lJ9l04-7*#!AN(STMIta>B#i}Y>^pw>itM~7~!HiyRxK>iZHi^<$d`*b62;U93;5Dn#-9Q-zk0HzUgb%4j;FZC&9@(7A~05R_9w#^g9nzF$2K10x7+Zq(9+3#l~DQLIhb zvV<@fUE6VQ{56Z3l`ueE3yK9qDaI6r=eFJd1+}M23p20@VTHwiyY;WG(#D(HYve_p z+q4-lH;=Bhor;POS|2$we9)j6oRZi^M!m~%Mu77CYX@?5bwN?+^{LZit2HuOZc=+F z8#}0-a=Vn)U1j@b_Rf}PJ{j*EKknD5;9dJBFKO6!--M(?^Iw_E2NFGQImnxx=@RpN zW8C8F?H0&nEjzy}*7*8$x%cblqh8$@s-66LPQlstTYfBm@+nwl?SaAJcN%N1z${B~ z#E5f`-SfGaL4jssSunQAx*b*Z<#{m{od(|aIE_FD%y$NZ1fbZ!_PzZvF;eT;(Kh{4 zz04+9h@58dc`a$M%<(9TLR0CkEG@U=kOY%`(SFGC=j;7CV|T@_i>*Jh!SedrogAyQ zy$G(jWV5TskUn$H&wNsPnueG$)+LH{Z4uA8JUY3EFbT%(@nXjq(7r=OO`o?OPJ-C=#6?oh*g8vuTwPQLn6NZUDGj zKb{U{xckpRI2H{5cteX9f*lg8-<^HUYDdL``P-N%-tXlgjrf%UhAlhGGG_V%fG)Y5 zFI0G9;Wj_c0BQO9+D}jP37T9EyDTtniCY~U7|8bb&uaS<7Q)SLzzE}radm^K$_pqk zVRzVN7gm8W^8$vt5YnJ!!AF(3lQm^|J>5$7(a4gY)^FUXy0@eMSO%9E^3HD^2R?)= zMuKrazaR5pHayddu3d9P(M|x1IZgjmgycv{WK)hAYJ4qkL5GGuT&!>a2pF8)H zp&@ROz8l~iG0w&Wo7Kijs@BHUDUKmB|FIYh@Rl-p&6jpqPw3 z&Ss$Y#@*U{-5V*4n3ZMR@Ot$dbxbZ-RMLHk76ITyZkrU(OSSCj2xQ^Z-7hDq%!LWQ z(WKtvziPg%6xX(qZ&##g400gWMJsvUqhel^S)h5w@&4Jsq8&19{QhVGRv)WD4Wc*RL5R+t81YnygG%@7$e@&vpzLzhdIa8mp-{;12l2oB)>H zx_R?iJlX&n(TC=xk)=Q}3_|;|DA@!>7OcJSBSsE}qvA9h8-G8f<#+FJ_$&M}&3o{rb*4qf7P!p6*ouU|S&|VD`Uy2=O7W?9_6;8Q9KP zvD(?UvMK~v6xC;Le!hO!t{0&jKHn$z$C&S9L2)llgk4aj02%s*W2|(bdb6gld=5?I9gd-TJGB5_1&P~5}A32hB;#mh7 znF-L8cQ3fgy~}_BADNU@YSN_j4qh&qmf1{hP*hHzS*g~uOyW`r66>3`C2{xCArLSMddgo{zVY%~9_4L`s+)O4DXJ>dn z@u=X{YTU9jDhe-X9T+gEZVY`66_hJAx{S6Ou|Mq7;?&J=D8$<9*hTjpt2%VW3$1O# z=lwnSd&XzabkEe;RLmq%3TTV+Cg>=IY^iPA`JO|evFT2zQf3IgzG>Hy#vYLn(HN$cwpOn_8%R6l=7@r(O0!Kfzk_A`pR| z{*$q|v$1hJXT)$2DuGVbKce#GIKmPbO}h;res0{Z?$tQ!CD0cTF_b{KQIk_f}1tTCIt9SkKx8P*4)G zB(qX}iC6oyg2>H&HVHz|Sgt6Od3mOv=KvxLA+(S$9;Ue`U$l<{)fqQ#sOuX`-fi** zn$q)-1aw@y1zX0V;ygO{a@pm2>+KRxA&NrRLt*8ad*`8FaY3n7vZ z<=X4A-suPGGrkHh)3H;hme8To+k)iG7$J}f(tk`n0WH^lqrndQ$S!o!Oh z*!@VYKW(~Af_5e%lKfMYdaNM}F4E6#Le23Q4x#dc@k&XdG9#eo==`G0A2jz;1KDyn zH%365^W%dm?jsaszrn(p(}&vN?UC;FZmCLiEBp(WP=2<2su2GcfJKShZ zdUM-w7gNJ;w}-uN(717@{X85DOht6c&80VDa%}2f(~ce?+Jmyegvpk>yC-`k=RiE+ z%J8Vei~Pn!|8?JQ%`v&c26bF_Qe2Tty-Y|x-)0B2GkobI^G^q>`eR_D^dOQQoyOwE{-rnQzrF?yE@{ zSdtqPxX71$#V6nbbI5T$)jW!VLEg6(rEdK|0H*BYF*~knDvdCV)XpUJGJs(k&3iAP zrxh#O$P#S|87hqp^Ni12+6*}iQwSWW!~0v?9Zk{Jv;T(sBj*H0|e)Y%+!`t(Gk^!sq?W|dG?H{!l%!kEx+#i zfeAdoE$f1M0vmky;8=-TcnNz+)zxpaMb+Kcr}m5bveV*DaDOf03ABHWGBjt5$eigE z&wZi!naMLYE@pwsp+kn>U+lVOTwyct5|mM(hVgk8l)Un)FO5iNyMf~Uy6PDwG_ywE zU$g}{$W5C%zz4h$`7xCnN07XYhb^=L*o0Z2D$qMIcU@kb73bK|>+iIZvN8lQtM>{^ z*|`uchIfPp1(4d!Ak=U7-n|@`5`8yB-&Cjoj>yGp!@a-`2IN4F+ zV*;eNwCJ%1`c`4HMD3_DX#SU6ZZ{xNzQ?Xj0K{=i4qUn z`wT}=1DyMCsX0k-F%itGXvNX-X2IBL;q@@V%mW>vr}vc*T5)t?CNE&YD0Yz5Bp&|6 zFCn3~-9hNd4ZWGOo%I+(y2(V~?aWL@Ag=QE#3iY@E?f2ul{b+ss0&Xg=lfa5OX_(z z{dqo&HufW#0{5FxWchU-w#r;beh-Ey(WbX1!dn`>SNoVpvS*#+r>I-|TZvk0Kn`6R;DjC;Y3gnmz3O zxu_+weMtXlv;@ZHS%_Z)I|93EQqFJ`^uO+o+lx0YQnkgY|JazIJ7aIvV-&IzEE+z2 zcDa3&xZBG#h*E6t<@7N!*{)0hamhQOd1{4@IqZ%{>F}3?zUw z;il0Xet?K$FV4gbhDW5pNA6KfqNug9L2;YN-=KWoqpoye^oHN@Vp}hjXUFDW7u&mD zCTCcwC8?NO45+-Vr`j(B2-gtX^(yex`nNK!AlF;-qy=oqAC z;HurH4{oX3({41Oxo|aLAHbfx?XvqT35Y_Ui)$OhR#XO^^yRx5=GXL0uc~zt6?Edm zhuF>`h!bnk{b*#X|6RInB}1T}-^PtOY3D-vCrk6vCN7y$Px{)SURUvy9uJODIY_7H z`Ppgl;MMf{dy|_Zf3@MT7mGd85XoVb!?jVx*SOAa%pKKJGs)w_E}0ZFQ&5 z+v1FMB<(DtMJ{@(7Ic5_jkGYG#`{0P&e&FE?&{UwiRH)>K{TnSJJQDitbwJI1PEeV z1Ym{wkK!CcA^(XD(3&w}6<%+`otBc5t!Ne{>8baC>_2rV6>6{5eWIeCk(RRAf77n>c9nE_|t}t1pQe z+2CMpO}H#HwMm;YY^p5H7!OFDdwC<|5Ea~G>M#!(0#hqN1xb4zuIpo0HtQxTCn;%UZb_JfQ{!F*hYgmtwdB zY@AT(wS+rB^F&!6wTg@44Y|Mb#Q&r|)XCHdNz+ffq7aEGC_K676IkJCmqMF=_rYm~ zRBs+@Yx{tW7bxulvneQfkEaek>q5>Kbs+--!lqslm-dpq|XjIGG&uHGG%>#kYP*3_KNx^^5POe7%Ni=en zzaexpS{I!!S9t!yG#%?vD@7{I5@0`_6J-W64qoGhx*bifuvPowKMiC4K46MxFScN! z6GYR8fR$oFflm9u!O@KsU!OdwX<``$lkMm^C5Fq~U)`=ME^S<6poVF+XDA}c`eqXg zsP~L-&$n>I9iB*%p1FS{?!sCOviyR4S65NN{dzYb2o&w~>4#@y6;t^pY8yp1*Gi>9 zK$x9dpIUrjR&Y-AxV9_J=bRw-(^867BvXO?2$PvfR8D`uoI_X%wb7OU@ze(&fEP2 zK%}w)IiTPz@*mZdTq076h8nbB7}i

    Psptu~T#6Mst%jpF^2qb56}oGN8)%$pU05 zN1>uF-9B}|C>a}-2fw?dqX`y?D1FfD-qx|&nY?|VLDnxT_v0q~_Qai#Q(nMh-0^5% zB4EScmp&8^AD_cw<=MAqn>4X){Z7OA@X;fi_q<#nJjT^OPxvv%h?B^T=gk{9pPqNM znOnP1vKOyJ`nhufry+Rg4C8w&jB$P+?@<9s>ZQm}J@7S&E;TQ>C61b(|1F$mLa7qI z`qjQ(p=GC!)Ly~DSt5dr2nDy;wrv}NwGKOoU`^Y0XE5vLz0vigK4!Ci_Ryr_b~kTq z3QQx0c8F)1lUYD^XI)h;nAa{KiukL1!$l!2{rzB}gI*kojy5sjeWOdL#P>KMLvw1N zs=g{o3J{~*`$h2%9AM2GgC%#+vD4$e5>}^8s`&-mTmDZ{@6v!iPm=6g#;804bM0Xs zysYB6>4a7drK164o5LG?eMjrK5O#_6*5xQWuro3Ue>k+Y` zb?IXZJ$OaJtbi)s-m9L5Ids2?_NAU(|( zHKK{Q9>++>_0-UsE5P5-{qoqHf>A%@J*S{dabNZ3Zdb;QDX2#CJVGa}N)C8-h|uwiOgY&`so~gu?0XZ^IQ)fSX>ycbOAsV^jTljkWK^qm;ea zFu0T9h(N2E8z1cQ=*sko9G)h%hbMR&{Pn0&|Mb@i*tQKI`J_1cDWbZr9XRWKh5gqoM&`Y^HIW#3+ZiCm$nWBU2Np!SW61k$1DDhj8?O-SXgRuMOKW2H1 zo$FNp)b{6V{qC6o~iA=YQ{%d6m_eo^a{O8=6Yg;H>DtS}7PYPQcASCJ{ z$$GMJSuylVL+QfqRaCiq!fHxy)%MJFqxeRS1x9M;*`A%4-zqEPM?Rt=rEI=Oc@Scd zfsv!omX9R~V2YVF=$Xb;)|$YA$^hH4+D;n&?8OV~DJYR%@eE8@R2W@U)e9>B5%3w4 zm<&f^i5nUoo&jz^`K}T*k}fNwN|B6Vpf?UhaG+FgI-071F$C=PYMmvYw8`tL-`1QM z^?Be^iZ8vb$Z8aLrsOIa z%{)++8picwcK-)~QD^$`Xbz5D zq7CmWDXDBy{_B;R^(5T|E~Pbh4)A58(-l7%=qwdpO>#ItC&Qc6rT1=ePfcpA{rb*( z>@XOa5?I^^LyvXjM_?doGtGG}E@Ig)+n?Yl=n=)lPT2*(qhJUU+%{wLOFu+jciU`pq*9jFDO|e8}=kXAF)WKmHLk zmg{xqRYGSHGRVaa20|c>U=(yMY&swK#MwHzU>%{7&Q0?kZ6Q$Tj2EWyQa-XvKvQ|2DE#db?-v%Pkw3(fPoA9)o|N$O8<-j?s+T4+Ck&zu_{wV8pUXoY z-5JqHgbdb;x>J~u)dg}Lq6+2${z9vlHFT;QQbjH~vcD>nWu82R@?)y;7y41$C*e7CHz?D#l3PfqSM+D*!nlDcTr%S(@;hX`dadIvgGqn+q0?CV%>qZ`KLKRe z_|!`|&}_Qt#ED`TPjcVGorkV)0H!KmV#nR?P>X_tQ0{Ee=rV08)tWm;t=DO~lVKF@ zv^ZN@I9l7;Rf1Y2EVm~xQsSz}WCwGcfED=btik_f~kO?$PeVJpKk9u zdSeTfd0TUw39LLVz80b~tCxFh5`Nv9Q+1D)t7Jlcw}As!-iscGq(iffva%k%URhfF z|0K&+vYO(L8@I=uQUkJsDq{B86<5ic=+-8!tDJ(x$a_a?$D>3VUdYXUkPVmus19DT z*mIiI?p~vN`5)(j;gt$6W4(H}O@bG8ptC)mHN15m)?mV(J`d?3D*Q=!H4eB*6+iAR z3|oCo@0;~+I=-A-493GbfI*6XwS}X=zk&ZdJGA(JlFu5m+H@dE5rcRf6D=&-?GOQh zYfVi54jM$&)^-Po40S^1AMvCe^AU_nK#S; zfR*(GegxeYsG0R+d_B|A?~R<*yQe0tkNN-yztS1QtABo?@2CPs!_CD@LJk|+0zPbj-iPpG73U~SPDy*E14 zqw@S-TLPL%Bh+e3Yp&mNKkur(&M@LOU6J*Kg|yfC&vCtS?D)2bCF%(qR9B(wp0t0E z&R>6-raMtL-JlrdEm_V*68yKAHQYmxi_hx>Qb1dBSb<1uC3hgiAnQ?`J_J<1&^15OY0)ApZyYes$P!-Z!p1hFUnXlfGB$TAe$J8cpW_ACKSUViKe z^_eHN1u7hvh(-6Jx!Sxlws+}u1xq4-a^7pr%86B*a1f8X%A3qIjgxs}C<@!Z)Rbkg z`zdEIS{S;iffO;Z?zL5VLNlWcft;|BeTDFAK~ zGY6a2ulKtFZ8$W1*%7o}|NF1YqqUIpyhVqXR%Q*nBdYdSsv44y!I(dxOV1Lf zL+FoFKi(-Q_(~_|^zis0&s+AoM<@j#$Vfk$&)J2sq6_vck zi8IemHgreE=EZSF<@0JyV@WFOSX)D6n+^5sp@;8A0M+-3i4mJe_3MQ1EWpYZMR$~)+3!VTQyL{OSK zjv+L7*(_5laa@4}b2YebmXhq5ojlDLA&K{whYJ(!QU<*4%{y4|Cp`HylJgvRo7(K4 zHa|@Wa(_FNE2G-Zzwj2$(u>X_+u*r|$ezg9 z60lNDD2>1l3_{A8_Uy|2`=`bm#3hCLZQeZn)Z&L6|9mCusi>=o!LyIr*DLKJJw519 zR|HD78$%9l_N(co?vP`kb8y;Us@oAzO|VBN%-#E;%8zX?P$!fOI-!kx=MwU1sz_VF zfSrfev~C0<{sW;l=6lybYgCzyGCdrgQbEfX5m{-Prj&!i8fcVfU;>leKL5@X zy#=}*9q)ww5(6f-M{)M^tZO&aWShJplyfM?oIlhOh7^4Nvl`H1&c(@(K2h-j8F zF?L|Oc-DxcWoP;ONK9aj-+f!pKOZ&)-<>syROA&EI(+rZm1s z%bq@Mjedp`;LlRS90DGh<~2IWN_^74Qc{vBx7_Ho^xU)8uNUrcuP0rYte4U`zj^Jo zMEv~#kcI!F>$Xthh=ErJFKE@px(1e{yGZm-;3|04p%BZiQ|Jl=M((m;fr2hmOst?s z#-@Ml`0Wu6;&#zE7TCNg~<$&GD*kSD6kkyUYcWKwOhy#QeDTH;C4PHQ1vB? z^$N-hpghq8E7shGW@Y6ekQPV*okx^RjpizEQP;h#DAxI2dv)lCmOVNG*suXKP2)4*TPJIW(+jPV` zqv7w<)d$T8Go;uIeB3q32sS0gj{y!F;Gu56A;-P#Vp!^OU`UR~po5tx@Uhpub!!Xo zru3(+krdYWpCV*8>87PMA4X8~x#;p&!bgb8HaFk{nb)yXyZ?X`qLpXn<;z+|Mn#xN zm{4kV7X|sfa)rKP+Vn9)7TxOx-Xq25QdU2_H}LnhUI;}UHl~Jsi2ZTUQqsq7&Gn2xLql2V?tMTi~_g_Z>}{~Oqt0zAn}VV`e7 zLFK!lOt8`6;$(ktl!~9#+e7B;MO$m~2!}>~p zG-BfC75_|({lPxI^mWx$oN`%?JcaEl^&7Qtx_@NfqC1$b){{oK)t%)&=2p7_?9s=h z61FmRkk(M?rt=0|OYxaH;L`f_>wSErm6KXX7kmS1nJ>QVUp@VgdsIB9!Ba^F(jgTy zecUl49Z}y`Kbh7Ie#e=9b?46Z9!HZ`CGkFdc5?a`I~p9*7x>TjwmHIkJ$Wf_y$eN^ z1EAv1uSfbA*4zhv&Bh7I1^s56GEXEtRg$<5mqz$u?v3z5br`gt`MSH%q#OuAlS0K} zx#?;UdKU5M(~EU2;Dvdp;gQ>P)8r|Nq05tYiJr5P3z%h(j){rap!*q4d@cDnG6;YE z{#wifvt(WO{ad)>xc|O_Ia})NKEE$6)>At$$R(t|T4z0{?fun+cxBC(BNf;419S}x zJTrIqgeH6ys>ZqfjluCBxs;5fNQbTSEKb=hSrN> zWnpClIth@HU9@(~zW(>?1HdWmwEZ=O?nWrN#*MS;7ZT&Ec^n`fs!C95yLa0<03E2e zY`|@-`IAK;!rC1mpkuNgFt-lEqr4Pf{X@Q*_fp-Q6YnX4qp7A zb%zcMvqYlIp9i+YuN>z3W_zWm?5ExvDMR08wFU5JWgzEbv|ru17)l?8glD!1bvq!( z;o0DH?0#*(gP9`Lf|j_{d^ogscKptkz5G|O!!Sh{J{3Rye=k7jL+8#t=too2qSK%( zk6--6afa2r*8&D5#QJLPL=3Zke{W5QJ)uRob%UQjQ1=D$x+)%DMA*mFfFcuB&%6bS z>WqC1MXUv#4b%|J&fwmAwaO`VCB@p5_XzVCm5_hGB9BkW`tMWPnOfn9nH)AOI6S}igqr78S->O^&hRp(yv=lNy<;M~I$$}x_oROCH1C$qV`w{DrhI#+=U>bJd zc|NLD*T6{a-{>k0)MYa(Wku(Uv`Z*vkS3a?C*{lc>de5Xb@lb|@#f|z_;G7lu9(3T zJrT%-$M__hT~756{y*iy2TOPiig46+gd}K4$nc@0B9Q(38UB&*fzP%YGkW$Q&3!2> zE6TZBrF-RlngyH;*#$GII8<+4Q>O7MsR-7N-=!T=I@9{AK&PJyU8pe_r2 z{X0?{tNaJ_(Wa&wJP#2w0TEBejyD^^TS`@KXK%v~466HqYMq*MQ5|0Wb0K#OMFXdm zJI35l{xI~k++i&(lJ0*e>2YJUkMd*mT;h%$M{rOA0HKPv4|6liZWjjcb6gL##^n;S*_St{gqKFoa&iA=wUOfKY=-of)aWoC zdzgYs)6$YMER7u<9N1+%)@pZ1n0;!26G{&1p>j`0N$PT>3x{0%S=Ne##g3R;gmK2P za7A)Xu~T>kxvj^p7&$23!+C5HTDui>7+^Vl)+!8>iYXB9`qZHk-!6m431##a8iWKx zt>GLKcKXqEIp1tTvG0V4?$on-lGD?=cl766h%SU?+{&G(Ck74}Kn~z<*OPpP)_v}o z4&c5#ff$v!L_4bfyfg>mW8RGa4vke%fRKo#EL=~|Ny91ryKVbFosG|$4sF^zfAxwn zZ%y|dGUV)EXy^Ywf2VppoxKnbpa4}>mEYXVg+8de&^$A%-B2D|{KCWMSQxs8%TCGj z%P8MYGoE+n11P0I-TBg=T*D~xh1na>qGih@LoMtog?J?IfDDNKJPj)u#xX=48AsE) zYgFZRat3+e3;0L=Tw=yxayc;&eU*$j2dGXa=M)LR(0+N{HPrEbm%TQPsPFqu9_2x=b&n&=P+`(K;jO_uu&05)(6Q zOniJK8Dt2^IL{^i@K8NHYZC$lFR^JfBso(Zi>Y~hicS9%?h(CAtzcG^P5aM2`y;?I zA>V&2hJ^GvNCFTM198}g9iyvwayPhYD~^CSY^l=sc$HHd;345hi2A<-3^p2MKM$xy^7{iQ|2frO)Sg6BJN70G{& zpwGr6frwh3IhQX-QN+3p(Le*A{C{0IbWXk|2lLBBnwb<@ z(Fsp(6Sm4){CRcm-sKJ)Hnp=8q@;7{+xv<8?dT(s;24Bck>}O>b3h^vG8HcemIc2R zwXfaI437H3g$rdpb#&6bf2Dpm6p^vj4b(3+8}0C-_~Ib#WTN|8qEqVql=C$Ot-{5p0Mg6ELxNs9ZaB zn(?ctKo`Z+;Qa%y3SQ~Koy%}~pxwFl{(HRX>HiU_#bPvSl{$MP*g zwr$xmrJUJpVRuOmK>`vTEtZ$78o0)Vw2Wu3a7PV|aGXwZb7|RPE%x8XV$vYI6L(VS z&m()<^(!QPXG2eG6MCEO=E3&Nx|}-W~G8LS=x7l`_o{toa~lhdqv<)48SCG}bgSJn8Y{#t~|{1tcXn3f@$a zDA8?d_AqA^X87n4D_0S+-RU`Vy4$^Zu%_g5jjdBL0W-=F^d z{&?7f%BH;)hTN#G_wU_%8eOM>TGRhKQGL)In54?11AR_$#f38NDlK$ExDgeZy6S&V zE{pEjaLUoh$a#RQDXzef_X-PVn1E{C23w5zPQ-XvmpxmZrO%GPiuwWs!g|Vnas)$q zw$m%|KbP_G2&|XmJenH=YXzR%_=2R&B<5NIXzrO-AeN`9qhqq8RMacO8 zv_ojc_%_%+^!AS|I)3^zE?v=fjH5umBr?)e*%ocwo}i$=G)a5{WG#F_?VF`{ZQWmg z?#>7`#@*Qk2w5J}0Tt!OmTIav=6laX9?Vgki?S+2HI?OCL@9c3%D(LVUt#e(E_LIH5i_|OTqO(9W<@SBWc1@4 zCElry4W*}D>w3@O7t9M9^0Jba&!(l#A!PyidZy1#F9Hxlt%esnjWl>Bg!TEDr40#+ zF%v@{k;?~KKpgh@yY37MGQc8OcVr@?2*c%h+SutU=7XS6x6r@Fc{7de#+C#I+WlBP zJrBPF(Qkhgre2IBKtcpNujP5E)IBd>IcX6POYyUuy!K6+6mpyt6&j(m)2cveb&L4 z=)k}LR6FTae=@FSj>WVap|N-GTb(cS06pu#36Cz^>ja?-VQVu=;W(a^9F}Y5Yam&l z%D}pN+6Cb~rj46#&?947E?>$D4C~s#pt=b1H)2IpQ29WQ8O-3M=oLPC6u%*;-JGVZ zguPpl{JZ#hI#Qo|q*!(lc$yO9{=LzT zJN3gWrS1U$8aB**2@V++N{G1H_L#UB!P3xJ{+gm~-80vG#bD)pcl{6D&^ zMZip^jl`E$s{5NCv~_ea&olt{pBT$Q>i3>hG)Ul)6Uzt ClpkLJ literal 314243 zcmeEucR-DO|9|#DR8~tWB@GP;X?IXsnpRUZrM)*g6qQoiDrsw>L7O5fX=tdlC>mO{ zG{3LozVGz?{{8#&=Xsv{;nq3lI@jm@dB0!lbKO=sExTnC!zKy}iY;=dPN-2(Y`jQ8 zvEGKT8vjPr`IHp?Z~Y|&*%K5? z8@cvhA1?d4Xz@8WezunOQF^n^>g1~9YYmLg8-1I8J6-ZUhPnDw-+PK*E{pc=S@|V; z5?veA#rG7=X@@Ds*As9x|Ni>+s;qI{e}AB$5ZJStxcsM=e$>?DZ}iVEBhoeM|9(#) zz_ymO{D*7wtSoDn|MZ@)igo!97i9>9Rm*=mM3nmb(cv`z|D*oBGye~6;Gr`rDi;U0 zU0l6lUA}Sd+h;iPIsfL(n@N0YD`fuO{EH4j!NHd=Up6x{bNum1e^B${)fKNZ+Al7y zlc=_lb?Y9n^JmW*4cc7PSozn+Z8@f)p`k>df`WpMLdU3r-E=$uuKBfAYg^kW0sR|7 zo!Q39_V&4^L)Q<|{4J#L`2DeN4OLb01l2QVCad{N!|{5m6|X-w_&NU=ddAF_RriQsgs&Xb@3a_X^ zk@Vi)-mH?@!I6$aUf!8%y~VwUX?E?{!OqT3_f#m{PfSFlt?}@3#da?$oIZWm*SET= zs;$t`^5n^rS-QnnuU=)yc$%A=k)Hm(s%jhYbAkQPJ=s0?Dh-l2cU2tUuw4F98|;SL za$md|@@%y`t|01uB@jt#C;ZM**I)2B5xBR5{TX(2 zQc_Y1DP;(368XDjMV@_#77f@V?(*r0zM-L^v$J!9!SUneH#gHx4Y#+{)z#rrWMyT| zx8ra7QdHv=H3cbsw=TDkYg>tlYCCCZ&4d`voja$XP@S~d;`58A_Cw7i(%KHvyH~Gz zZtstwJ7o0!!7S1n`T6a6=AsTi!Yxks_V=5ZnQ5Ir&q?y}^K<*%7v_If%+GJdn=z%N zrKxl7Q2Ks`yUBB*#dEl`xUjX5NF;vy5Z#`to}9$o_x-zNdmgWZgl@*!qqC>tRIYw{ z@~XA*olYB^A$_1xmk+qZLb zbA^S4M`zzvR&pIUaP`M06i)83W5)`V3{joU&3T?ni__D*bux7;RzFFn$myVnh-RvW zrj{1}g=YpYudQke2nq_ibop|k^~H1VYU-d~v2*zO`7f7n`FX8a1|?ftTP$BUijmQf zgss)(F_%GI*52+q{^iZCUAqb#Tie?1(j6=-FK0V;>_U7!J}W0D=jr9QZEbCN6J9@l z{J@&wZ>N8>vXN3AKV}ehNWZSPmE)rI=)UFp7f8K!?OJ#^9ano=Muz|G+s7P7KBH+R zDl>b`^n^~W_V@8|`s#l)VI2RmB}>nF4Jc z8k*WP7%l2}rI7ONU8XtX>XYrq|4xRw4?Gx*C8>PtPBddj`Kl7s4Rt-uI5lKz5a*xWmJ?C39jQ})I42CF!u_txFQV>L&+ za-KZt?AHCTKsnw=|Gk#N#H^~iS~*(8eqna<=T93^(ZZK6U)I&B(9+U& z9KLYj!f45yg_4p#by-J;`{Mj>4-XF|X8ZoSxB$t;^90u5$|L4&y+3|jeQ-{S@x+R| z@C~)bBPh9um!w1Xa$>xXBUO9&f=;Que-D@A3 z&d$z|kPt2|uC|(!h4w>vSDLs*MMb@u1CHEXv2V;iW$MGoUUSw({SWow5G4DVj{$_;3)pPHyDfF-~_DVF9EE;Gr$sg@^2kpykwK_hQ>NF zGSU|D^XJcx$;v~ez#><^yuRHq^EuyknsUdgmDpz>zp(IaMa7^IDe8#%6%&&>&vPm& zfkG+i=|%2yd8>wwZ&(4rFEaSMEHW69$ff(jI(~8gr`A@}w`b0rnJ$#FQ(RFc9DxrW z%zqFa)i`^0@br-?g6HDz?=v$_;#8G2HBEOrK7XcE;Sgb20p~9-qbAK~2DcaetZE_; zZ``<%S6p^Z6#(i%bo7>U0wUX1zG7~&MUOn%zf^_r%pM}o^+roGxrRfUmiP{)(>(CMnl=n&G==%f9Ix?Iy%u)4Ie++{{GpgQ($+{t+JnG1?p7% zN@Yyimai>g{pIz*$7EnHx;RZcx|O#${%jhCfcuY62M3-Vrtblk!hYPb>1I||R(yQ? zQy!VQxAcE07YAE#Wm#F72O2ZrB!N|4US3d8a3WUL>DMS`9WVxpio~R42y0fpyU&9M4^mP%L*!9HO_6>ALPA1ZT&YDx+JQ7>fh+C? zWm#)$etPF9wDyY#HcogH@I>y8{2;6n9p_*g@vwHucqUlsH8Ant)%u#g#P^>O@xv%mkIDg zFZ@~0<9A<-xNCxfc~Og9U%u`4UA&s?q?y^FECqS_)vH$<2wqSKzdTvn{(yZ~<;^ZtJ%&^bQQ9>aa}GY+_CDQyFBtdW22Pu0K%d z$lG zc>1&i?iU%5wO3YF3Qv}Qeuqj_(pmH0wMZ2vsHIBG{mAxbJbD%cWI;$$a_QyuHB(bl zi;Ig5N8$t{4hRdsc=YJYpOt?6_;I;6mFTf!_GolTNk(9Au^zLjc4D9X=nuI}4jT5h zT#x9c{7W7riGZOxg;VeDu{9)OC!3h0L`Ag&$^iLb>gV#j#RnUI*qyJPS*$uOR+fZY zaW+fW+_ds^pa7xd{D0R;;rSW^I(MKu{NQC>xABu8ECbInGc7H#BGX33trCK0CT>$RSo(ZzPK29Yrgv;QX z|7ee1jqTD^?0T*MjE0N??P(K|k}~y5OA-=Jh#ms_i8y2|g8l!(E&TA`3cc3Fi?M&? z$XP-?Dxy|$=`*-ptKqtJ>qd)y-MvqvdH#I5c78)wm!6}vj*gC|X4`Q4^NfrPFE6i- zjt+knvG)&drfy#T!X}<5kc3xgBAW6WZc@j@#Q4(khn{BENNdvbT(FPt$7HkQz%+?FL~Uo*_WoTN#uuR&1(kT}oFr?I4;2&^3Nd zO^(5I3aXF|*mL;e1u32td-b!Ie4>c`;QbNB=N26aud+%P+e}*OlG4-DW1PNhG<^^h zWK%nNO?rhu@VR_;a;VF5X`v?SsF^V!euA`xg$3$WJXXM__mruMj!s5L%npH#e{Ua! zJHGE$W{Cr&PesnQE-nQ^m;uU`mX@aTP3QEuLgW{yo<4v6JSXRVguMk5X z)^--VCcYwV+L{CUj^twG|b zW#qUKz+~Ad2 z{%V>z?sHS<8}gTpjlb{YIh%8zMqY@a2DNrbQ1FhgFBB5!4>!EL@Edw16`ys&#>VFA)gQOF?_WToI z!Rv|&7bhpW7rNaZds&u=f|2pZBqhnknRua*maMMJ0pdsO`c*LQtHb!0=6}`hKm8z3 zVlUeX?-i`R-$Kw9hO|014XEU`2zJ22$rDSI6J~OCHR0w0phyX9N ztrg$zj|Ln4-~pwcu$AZknSR&C!3e3a?}$FevF>>g>S{8r?d&KwZan5TV|D!aad-Cx zNE;jez3EAq)@RS2MTz30Ln{p`IGJ8B_y4^g-x9Hl8KA`UU-;J7ci61?fN6Siz*u6K z?p~%7b1P8$dtX49^$lEJE`H3Cx~{Vq73f(9SlXEW?vabSMMXskudul$vcR{f#3ur$ z)T#(s+WD7WexWE|0olIoV`o3Xuv^&bVNQ<5(iez41l9vksjglnQ8zU;k;${JZcLo- zT1xM~1T;MC9-FMZsh*M&P(74A|Cl!o9tl@+T{?@L8+3N=#HLD5$;vugqc16HOIV zOPQOS+pY!uHcO{4FD2y^p=W3ag5Q4D*BJJ^`+T9>bieYF;otp&suwJE1#)}&Y4`5k z*u;T>fh^G+PLqR8UK^-1F^1dQc|xkT??3G}JCICIPv7w6d8bP?86ZMiXuM*7<;r8B zC3L%p#~$5O*+TZqrAQ%jMpt(Ptt7Y-m@kwc4f(fCs?w83k1nAj0GgZRc!2J#*|4=y zw;(@X&I=nhzKQa)?A3$+2`2)M5DTzMNtvrlwZKN0U!(#54nObGeQpsg6Hh}F8+*A1 zq+;u)O;;SADss5u5@SK#j65@Qb0?WODFlw?(f#NBgy#aDK_htm_N}+Kw-)q(g55dI z_3^T_{JK8|1_p4WW((0CX?Zp6&>FyWLqbEL@oi#7u~hicZ>OU>uTB1SV4e4oBS(Oz zJfQ;s4*;R&D60T>0C>sC-Tt$2F_B7Z`Pb9!(enGR;k`d+Z$F8Nm8qS-mvsJ_ft;eE zUv#vNx%u#02-cwHzh`E`3#5cbF~-cA)9X!y@XdA+{g_10lAE&2?8E@Au9Ni9+K*>6 zG;&PpA3;o1BCv*4C)%k%#=g&4(ZD~vypCsceT_Tr zT{8bu!BotBc7ldY=JR*jHGV{n$;$uk%EcHB?k3DttHDOD0kt@l#-=8@uNSh7lp=*H z?GmM9f$C&1_Cw@Z!HBXkLsK<~1ZCyg0EyXm_4TTb)~c%YWRkjR6NwsM^3{!XfcoZr zRF6|rAw%=>@XSG=xx4$=UZvT&xwmiLJl)UR#(?(|PHm{qv|i8p@4S5Zxv9AsI*|N+8u`a*X;>==u}}auZuas> zO-+q;pNi1TX#Vg(+_m}RM>OD=f@-V|e&*1jLwZgexmzupoW9UW!fz8!eNxM8qP%wJ|Z6BE|c zl$8WB=K2_v7gSYy1_oMlj?$PUuc$duM@L7{cS!1( zDO3JOJKt4T2N!Ha!6Zr$MJn1@A{37Rw`1KQK>~I{_z2#wd_+iyzzQ9|z+o7ZzTISj zd5(*Zj}Mcu_jUiNzi%%^VEy^)7vK$vnmc6Bi%LQmz+6a~SzjwjgFEf-4lwQT&dlB{fOg4TlvV~5v5{h6YP5p}#PQI=N;SQype00N{} z>QUfM>L{=`Ym_BO{k1g&j>;$glwA6)47!fn0#l7FabcCoe_W4&cLGR-$mym zpV;<9zZ}{gtTJ`Hg{tbk;9%RaZfP_E3HMnc5$z`mVSIp(N~cZ*&Jwn7-wqGP-OVlG zJG$oF?Cj#xUxe#h|EiA{`OwXtp%;Oi$ov98APbSPv3lT?_TRzq!4=|a2mo^Z`Er*j zDE`R;e2A6o0(k&60B+hN=^-X0q)~W~i}D1x@P+Jepw~*@QQmmxZM$|gr>tu1=qOf| z`2@y5OB)95ePQNn0Q46y4g->a@rr4&d2(_xt9UAM$7H3!v+HX%h=_>j7CAiy>d<5f ztEN40Ucj!uPUh%ijmIH8=Qubxj<`eM5N}DpaJzfK99r03ne~ zVIrgX*g!@{L7}NolPwAoiG-ektvJy6DJ4Lqy0!HfH+L15EZkDxLW4WxNH5IgGWaN> zUHO3rM1BM1DIL#dy*45-&@2x%5^8r%4&=r|vJ9fXkJ~>409DoIA zlZR(>^70&7pBbWyh45*$nQ5eIz>|9dSB7IlYL{BHx>-AI_M%=td_wf>BzD ziHVrpa)xA>6CwZ2Ylv!wzVTSm+^7kJ=E2Z5eXFxO8}mKldbRb=Z-K>Ky2X& z%cRm03Hp~WAA*+A;Wq}A0RGm)nqeu+LVIQV0NT<}EJTlf8jB+NM2mP8-aHtLI#NkoGqIM+@g-cLYS*a_af zOY<5d-@lh=-PqimshL~V-d?C0^BwaGK-7?AZ*PD5_U*X1IB-XoJ^Ktvz9(C zq3C{SZFPn^E{^7%(~U7u8>Z*MaCHCU)k|6|Lq=b~NW`NwrhR1=@=kLNF* zP>a{d%E_^-e{>Q~e?#Bb07(gKu7yfH$nGH_I|o?mw*B?AnNG;dSHY@dC#7U$pj8t# zZnSoAXx8b*_w4QtjEUhRsc?rRpN?h_u}fK$AfA45NGMlxp~ss!^!|MUE1zD8wyy3T z@{JA#-5gfwwY= zG=hc*?TnXObi}O#m`tMY@u6YMF(YpWGqXy-rj=GU4Ij!dE{D2okpkxvWJBK{J-d$jB&&=F7B= zgTo#XI}-zg1c>aZW(Vp2Udo2XD!;l9A4Z_z5Pc#eBY|U1hYMU!sU$J-ISD375>*q7*+;7Kx>2mGnrmLOmmfs4H1_6f-MZFK0OrTDXKFbQXfWg&Iy+&S#qMW%%?u5o#G<1B8Wt<*f+6Ye9-qRD zKcG_}#Kv|!Q7u}``6s*#)CXDe7;ZaUZzJ;wK?;`*TX@;nz7|h+0-XQ;{TrEuQezom zgLi>IWJiUBd_oVEEiTLjZ~amW@qgo%E!Y79&;0`bZvL24*Xtm|a+6F9WrUr^x)T!E zO{=Tm7O5wnzDXj%5z#lqq>{2SKObKx zAHPn)2^qq1f%27CxhPaafI|ZX6)m&_?}@E`pO-773djhW^|r(G>e=)?V?E`4HqO~Y zhJqLV$l{SxEf~l90HL8}&}Er~dneJyfk$rmK-jn(+Zk>cts>wRmo8nhwjPHh0X%MM zY)rdz=bL=5iWVWZ_m!3P!8B*joI!ra)x`z-a02y*s!TlEZ|vG6#70RV zjKTSJ7>A_*A`Q_x#$$HCo7v+N2J(wHUp#YiT1>L(^&K1>@Ts7>kI$rmJH@*FlxvWl zKL}~O4MHeXk&=a}c7!vM5))CUsJoix=Cg$rxX;4EGmWY0OcHGD%bHP+4x&V@t*vPO zfV2GE+$g`s1+x;9AQ}#KcA+R5-4ZuvILkyIaL}=G)4q0)$kW-hrm^(i8{mM z!)}rH96!ssLseB3l1j6O=DBl6Js0OeI|;1N8Oc$q`$q-Tp+WyfheqGC+t0LnH-~bJ zm}vt$(hmH}>>mYNXd_rrWz3aJ8Hev9A zo;3EyBNJ%*{R0y~zJNeoQ`4Eqt8KF3p`qVKN4ua(g~;bZGemz&gFpQmBcp$K*u1wz zdc#AtR0s^2I)xlMTN#A<^S{T;A;%~P)e++g-{_Ry6OciOQlwkAM2;Pk;{kW<_(d2V z9$vG0wc_O7X{Kd#NSRnr;8Gd1?{bA-gpg@as+qJaoYW9lo!lYv0}bF7pe?8}eEo(a z69m@PYu1njq5l4q==x!{9YoF@n`C9*!Y>-W0r3dXovv@+`@1sn7m8i%5gxHKH8pKT zmI^-uWTIW-cA-#_FAP}h=-$0V(T{-lgnGbk;F@9Dm$kK{@c|Nh(UqT|@X6Lc0Z@Cq ziYJRKbWZ!;kBTaEnY=RWSliTO-}Lwlxpw(X8f9n7HuH-u&U}?Q&~t3W*6LA6= zh2gzn!-mxHNAUX)y#Qd%+4*Rne4-#X_hV?Dgr5HXnZdNI4-tp*JC3OmKCb`Y`}7Jd{-`Pah|r2py$f!Wr@x4Dmy6L?dnjYhG>7 zQ(~ZEmJs9MFaqlB>FL?dp-3n0VyCOyN#+SihjDOmRU%1){nclA#!r_64&RBuP<umb@gI3*A!(9_#s4mkZ;s{{9f3Ik&UIix$3a>4cvbMt#5)#0P5KFMH+Kfd+ zMN1cFhwwcGX4}Jk2M6Jim~h>`jrb)*KzlZE;A?Usp}u|`lfOX`_zYkOs!@UhG+Kur z=UGn}86}NWc!RuYzp#q4kfp72!8@ikHVi@whZJEHrSfm|0DTH2PGH4Uylifs;rbdf zR1DO&l9ENpC3KLopH>|`u&mcyy<@R>s?Ch|@O)Ea;|W<=V=&4vB>Tce>>rQ7{ksK4CO!Q?h%so$IW{RNkK*ONSFod94x6RpqyMD=31 z%?p?DCLN6D=$!JXv+ys_d=<}7wFwWWo+sy}v_F@6dJaFmfsKj0&e4m8Cef~oUts1U zJGXbz4w}l>w*icae0H4hf5_MNVMk*yjRf@h1{P%P3tMydV9kTpV}-yor?WcqeE!30 z*H)2ZVfY~iEiEktFK7UPG>=);q%XfK)ntEEJ z@lI<)xKA~2l2=p|x_F`gMMVJf5?bA0Ey0_T{*s~LJ`!A-t{l^k;<1qThq}92{BxC_ zGX0J6uW^A=fZz*>8b$OTV5Yyfm%YaF(~};AACH)tn)qTtzm1IW`6E;7TssMI2vSO5 z1<2bi9?ID*^nxcC*f}_muK#Si0JV{G3vgtPdHl;!LF6D>bX}E{WstNuga%+*2g|HgTP{_!#P)Cj+ML z?XB>&d0twNP@kwkYx;6mJjV_O2JT@7O+CFO0PwCuzYsePUcY6_mi7FZ>(&M{Nr5vh z`i;RG2`z~r`an8AwNw!YWZkejUwdS5@XC|N?Q54g!7W2_oLxl!ODw;f))sJ5r3GSi%Kqa6&3oK2dGSHb-<7_IRsQBJmtnMn0IQSRTdFWFNOR`@V8)9kT1Jxk*%Rswh#{u1<7p)&R z{fQc5GCUXBbL49HR2PvcPf*a{jr=)5 zfN+b%rPl=H%6`OSN1@+x_WAM`jop}<7oaPH)!qz>f)p2;JDkRgP(KlCK@hh=@+B{E<*QAmQzV;7dj{rXm;)57$#m-#9R8@xigEE2d+Uu{umyUf;u`1j6vmb#t-yiukrslAeq35fVt})o{=Qyg4F*o`Kz3 zJlc@xi+)3lBM=C}HoZPF@v3P}aL%n99j7NJ&D&VQA3P|s{wiZS|HYU8igL72Bp`ND zLIQym93{c;$+KrR5N+{B)6>(VZMkXphF&p0)Zj+7FfcN@LU%;SlZ)v-7$MlwsZ;L& z)W(p~ zAq+TFlWV*jXtDmS9tX0>IU3`KOux!=D{OLwBUo<4mFC74Ly;YouIa79-uIMB2yX59H!3l< zAIe@aXdy;)gW>t}pI%sh#qv#!bU-_6M2Y9vT|RyK!=sbFaJLf_&Yf!s3feXh5E>vp z^+|!h*nsp!UkEcetLUe^we?f#GBnmK&xIkxo#5Q^p^u<>;?_*HTWV=)UTFyn4TYs> zw)Bvn)DK*Mc@Ciwf;7Gkyrsn7ATP%R1smRFU6ydbWYRBRZz={6!8pd5gN5ZGQk}5p zFzVq7ja*x`#;#@QXEi_BemmlhCBr5ff=mX<2`RXoCC2MvVJ(;|oaUpZZVlIMaa&uW zHU8s*N95#8_TkD9JT@>kRtQ7D2d!u;B_);)bg&gG14Ab<0Aelt^GsKf0eB8a5!qVE zI>QFCo^4dqu<%@E!!iu+R)%f7G}}^|Fx-Ki`mHt=PK)QTtdb+`)m~f~>aWdDG<2S*i*ic-~<$5bd1X+Jm6 z$+n1ddhSiTr$VoeVr}Ozs}#N>pX?8Zd-@{~D7eEC0w-!=S3de7zB7v%j5zTL3yVx4 zgn{91Cn-hY)f4FQI=`JMw}3B?8n z!>-g(Q`@V90p*N+;1c6Cbt+MHo*y`vIBg4OcshEG9Xf3Rat2&G)guu-M37) zzz!(^equd=E(c_(juBABh(k%wp{F^H>YB$VpWq0Ft@|PTVEjN_kciUwBqSyl13@=PCo3HyR-#~#)dtuf#!vsWj2~al z9L=a>2y-wUn?eq20SC!U3Lch}fSN4g-XKqMi~12L%hJqSNi1%1AqI`#jcy}tK}cXM!4ar5 z5QM>lU&ot$j7&VOA%SFE&^voJ&!930sRB8zEl8##Qju5B5oms0kpotlg@%R(vP}3V zKnu7rC+P(mC7KPi8Li(X0)ic1*Wjp1l5t-k>v8ZUR;Jj9*w|74YC)pTeCf`ALHpv4 z;f)4ZjHc+?4<9^+GM+c5Ux<&|N>1L9&r%?~%@*QwL~2f)i<|pO%?EO!V0YDicyKH@ zGV)4y!^40n+T_BW#tNA6WbH$*v;>xYUa4msGH&pio;!_ydF8c%yd~Qf-(e#B6V{H* z6BANsf51%&bf9j!3^v}@@i@^>r7-O9GOC~FLISCwJ1wgO?QaK&>Oaq&%5E^*sC3rWkPV_JLLc6}t1CSZStLeW%T-P$MPUyU zlldUSzjrR@@}`@(@a;q&d@DfAVe+Yuk&XsyuOOVddmk(5`LnkYrpnm#G&9^-@UB15 z$OEZ$gdmm9iuk>Q^o$_#AAbm4ynZR5ceabbRvPAsXVc>~hmteSo}^y2ZigWoVXv!i)h0IT)g<=m>9@{qBS%XG z+B_b!znlBrQd6@fz?SuBZ%oUv(L?48hYy>pAQVisM@))v_~=obVm#s0MolLtwuhgW zcMB`3!MLLzLb%O;l zEGIEtD2>FF!a~tQhiviff$c6`5u{t|FM}g~Z;si4D4_Lv^Iu4ZXLtYp>mIAIa+tE8 zKYvCf`{mEX%M0^=GAJ+mPX;CQhNb`c1KsA&9Ua)yisE2Rae2U`xXX;&w(TvldjLx% zGn0?#Q~MnPyPtvjbtk2$BU>z?(6i%c$_zOwYJKBWP_G`B#m3HUhzX--6fJSKoP^Jqx z732qp+G%{R!9U^kOS04?AVwS)8#@gZ8scK?FxW288!BVf-B^SQl%vb0s zG(>Db@2yO;01U?O!^iWqgt?_|tNz&r0<(ea&ghPSs<3l##tL~NnzhnFy?|+yTej%4 zvygx6r4}*B0POCM5+Uv{qpqUzFh~;j1QH{HdP9SQKNRG>drm5 z#c*uTouehDJbM=O>LNrnN*8vyAWZwqPjR{s%fn?&a>!die+ z)GR)xjGvWIR$qUwy6?+P0UQ$^MTY=>LW)`6eJLG&DR6OBB&I8jFnAH~v)*{3PItoXgbK&u%5Ka?B+-b(UJ9drlr908UNPwRR4A2m@ z`Ht?}cT!I;cGi>q2fDA*ogQE`9Qt}wcUErQf4jroz`#IEObksY#ZTbqQCccK3scj3 zK|zmp75}+26ieF3xB``hJb2*uok|rJ8P*aVUq@T})*#P*Ikgvsq2)brToFBHBhgV- zfY_wo2)^jYLMW@OO#Q=B9xZ*S`C^lg9o{Hhijy%wj2NaYq~5+hsPWiLZ(2>R1%j{M zyGK(pOeCOT85kMC8@*y`+9S>`2fF6Av@reR+8Br}QrB;qKQD3f^Mg=}5Z`_JBrGPT zEi3EILRjyGv)7|oyoiX~VH$4OEGqPIQY_G~A34Bd?SW*Sw99bqCGsa42&Sc_Asx;Z{EpyC7+usC@?O`} zfQC{T{gb~c;sP}x)6s!}S5!bgIJQ(!SZH0~MNdb!Tg)l2Y9l9d1t4YQ<31S~yhKiM z@dZFM3lo!ihT{>$mynCMUo0Ty7!nO}OC`>eI zbPQ}e9JV{QduwMY&5%AXpscW0yl50kX!% z7W3l-%tBbR{R0E4S?I3l=`TA1&@Le}Kyz)(Mm9?`_e#^dn3^ST_DaOzp%e6IhJ|B4 zAT)++IrikszM1b3Z=KPcpkJdckf;OR&O!BpA9>5U5(pV`kA&KmwO)97!sb7ACNvn{ z4+P+OJOHBY^JlR}1-vsduy0CE=v}_d<}c$q*8Qp}?Hu~?hZu3uetpnPnDj4%w=jsX ztqqDtB1uTd4yYbD6Zu4T1m=KoeD`~8t%9K`AV4j%&G*{2_&0gW;E z`IwkJ5SFi}ZT<7N*Op!(lm;|GO~g(C;dZdKEoVw!D{X>j9^~hDlzK8xS58e#oc|-R zsAQoylS>|BMyaab1)0FPg&f$zG!nE(JLo`$%E#`>O`)s!I*ywNP3e72jkV9Z&ilv@ z=@!`CF7H8SfZfl@$ytZP=(M!(uYldZQfr3ax>W%NJTN@W8``{@9N>U>L`L;|d>nEO zuU@TUO^A!@`T7;1MjhD4U&l}A3Zg9oasyCU=S(jN9XYao?OHHqsgoxYpFP8KP_Cr( zorZt~LaL^&?wxvg6T2)@yHGW2daO|7usvXvZ@hs9g)D$bR83}D+V*2Hr;{Ph&BGMD zW@p6`{_hbEnKT^uK`yySDfwe6Uox~0^G{D!2@=Wo@S0~zY3ULSwzA1qJTc;XU*Ep@ zt9U4fiVC&v{B0H=P_`}aZIHZ!?~Hzj*k!=9Vx*8MIDXl&FYhjAx8h8nhU7Cmf#K%O zbSP;_l{Li`+#Bmo?~3Vyf-v3AWx+Nx*!W$7UeP{CNb+m@-<;kM`$OtO;ppjP&$!c=$x} zEx8TIYL4CUx{vxq93SHg@%~4T1aBA%yg?HKqLF!`V|i>GA2^`6t)X9ai~K)v`D^ueSO}=fEnEBnvAB46T%DZI)_dCXG0L!U_V3^S?w_#3 zqA~JvXj6nZC_Z|?z>SOg4X}kK>?!CIkX;w+KbeHy0{gu+yAj^ zj~!N0!XbwbA2Odk`-z?I6adQ_xZ5pp_a?a@EHdT*KJ$3U=o~Ux9*e(?i^K~Wq>n^( z)YVB*lh}(ayV8N&F`2>3ot=M6cXcXq*ka4XA7wg0fa`$Av|xX3T)UbSD8=&drxQL)mMLTUb3J*!xhBN zws$X1)TXY1)Vp4fV?$GWJ0ms%GjmsSGh~F11AvTLxmQBomHC$qJVXhnoZ284vyo7>ctXVSd(KbeHE;wSz#2f*+7O2|JJ7kvDWPpK%m;$D&i?-GUtmW~7T+P- zZr)t}i-5x@M#xpp=i@03%uwh#ISEu^?(;L_04@V|vxHoyAFOhMc;E^*_nm^z5bY%J zK>%YtK__HY@B<(%*V&)_3IP!)X84Lhf~wjK_p&Ka!C;o{($?QoSZy8 zG0_b$0~92Im;%O7+2#$vzc}f{NwOlV$W$n-+g!;$0EXyn47#lp-3?z(if zU6P%eKu$f=(6AN>GDp<_qGMgkoE7nK3+OWNpshvvVMg7ZS~Q2dgITm!uLUCjC7S9? z!Y06nmwP6n)ZhLhV2I-3R-C^r+v)*@hM=jyW1w@&Z*6bitY?9w1jKA$OPK5M$Jei4 z|HWifl<==FAh2m-Y6@^-3i2pc1>GOU7atE#ApixKD$Yn-G1tBHdyDIO z<)ZnKY{%fVB<|!#Cx|xz5J+c{U<_%(xij1_9qT9V&`^=e} z>lHT85YaIKo_25buKkX~1W@7pt-NsL0#+&68rL{d*=I9OIs%-El2f1aR^EWV8=;uw z?ftf0(o6LrurdqFwZXbDOu4YI?>6~hIw*~#q@=P9IOW5|$vH7S4cH^+5eIkYw5R71 za{TiE{J?Qy2yZ+y1R9B-_GOj}g0bM^>l;7C$;CDP+bxJ49bIy1-nK`J9W58e4O|vp ztvtU+c2?NnWb!QOQG_Nqrd~z{qNfxTvh(tq>+9*iC8wsP6&4imX}_SpwXR4UBN?Lw zcXEbRni}(X#zqUr@W9}2@*f42b9J4A@q)k_L&K}1J}=d8{{uz?&tsKf0l+I+6RY|3 z(i`MaH*}^Picoyq%try&9_0%nzq$_33Y&W+ww2v8fi2wfiV29P!SbgA2KG|iuc zXupI@f)S8(%Hl`RzrzlInpSQ*b}S#k5GV(7!5mv=xg}!xf1hMnw^~|CN(!=vhcKc# z=)@qiaK8p|2>`%MO?tn{*?KbSGl%JcQUK<~o!Pg4e=X2)-46$i`6huq0nZJqcezdP z=jDaqS`c-l`Utg z&36>m$t~mcqMt6JaHz$P=TX!6{`T5Iwb|? z?<-PD$~UmxW+9+r5xEw7Cjx65)-1>kpdX4rY>)D#mh)ucbMY7k92!3h32bOA;i-Ou zv_z~Y(1h4J1H`)7j(G*LS0ZM`)N&e4@c=(Rv)vw6++Ex6Tf?gRtb|dxK#b)HfEnw( z#{28pcKJf@0bB$H@QJ=>AmTc8Am(B+o*RWHR++a6p*h}Qv9q`Lj%a2pDv5p#vWa-s zRw8UQfFLtWbs!1?D>eoMCRO**G3dh>`(Go7I1x1c>sR@&?|_XE-MqQ81i2Tl(D};% z#cYpZ6SmqD2V5Kxf$`3OsUZO{Xpi%&<+W^UEo;%c0Kut=Yo&3B+6*TPohAc&_@Apz z#O&t%543C>hNg^NMqp*|pMo=h((Qm82*a?wT`S>53IVefwTyEvU;UmG7BW)NCf1uk zr)0GoxmJ1-rPt7~%~7P=?@4OvM#(;S+JNt!-&Rqu)5`^cK&W&uoD$yUi<2{-+u9xj zwijL7O*|zln+$6uF7A3;Hi!!%mC~N4&z{|t*^ru$FoD#IpFt9lnv|SuY;S)!ga!|Q zpnOl*c!QH4N0fi=Bc4Ls>7S(U@kFngnb6Q(+G~|jL(nqW9AviPz{NSNojB2lg?t3v z*7lb)6+{#CH_2cSl@u@=-jI@AH7vur>_UJ3Dz^f8wUNMq-ou3C9ZtHEQ$5 zU0#+5O)ag`xsgIlP>f#=$QH7)n3nLZ)%4N`H()#AyceiFe<%19o^>K-<#u9X2P1qZDCaaBy~Z zcB>nsmGA>Nb3QM_bQsBnl>d%omVDR|$lB8okzv*W6MnhoR1gj_<8f!;%H%V# zXt=x-LFf6Ir%=TJff3El`WS(&o6@B3)5 zbR}^yvGw27wk6`>E4cXtR{UG2pqnK3Q4>bGN|_lLBBP^~-t^+QLl_{(6*Mjb1EGeR z1yL+jERvzT$tN^mDB-fN8rESzE6Ge;510O z(ZbeybA2A-UV_cLxQJx7elW^r7I(QH8(VwnDXNiK%*l#;yqI6N+l%}JgbK9>d+GI$ zANM*Xb1J?b4>`hmq=YHL9zuh{;M=|RJ5HySSN3B*yq&pY2sVnIMqow9;Xb*YuogoR z*-0F$N_->@XYr!I*r*iet9(MAgiy^FnBN^iqs$&)I?fMR}YYZdbr#P+fZm zB70G1cM+xS)m39>Q&dohLd}4hy=W6_wsUmsf?3J>+9(^%R5?VSpNnfFQv3k{w0GB^ zX?3t?li1>K=x%9|LCvk1vFswMGvIu-SjJIloFlG=H?K zG@#1{YBAJ`-e14j<;Vn^Zj-;vudy-Yj@%~)cM^Rn4J4e#DEQNX`rBJtz5~OL9Jz6D z7`hx1d<0eiB6Q&_99OEZf0~%Mt~O+JXh=ax$==51(U-L#p-8(RA@bNsG`R8rCnxzq zoBaH5*HO|C@hUvg;Wpg|*>|xEbZ< zKtMWp35ZYuL}8blj;{xO04M&A_NwxmUYZ)W0fxhGOfo=TxP~|~il2sdfk@Ho_CfE|nD?|jcON^u4AaGJ~BXp;D!Bgg8Nk3oy+>HB

    QPE)PX>;XpI?D+-2Bdvo>s_dm#wV6?YK9{I=MAd!Ho=GA_o610KK zOH~#cL3Q;Iz$onKd3?Wd7g8Y2YKmL3@%0F0ruscePTnwz7k3~*^m5;43c7=2^$OAm z4G~5N1i5i&Jq--xLQOGzOtJiqbCN+t#J3Lb^Q;E(NbGkM!4tP~6;+~yHN zZ1uxMA+`s?)|ZgmAgiY-$;sc*C?Nr%+##UOQ1FGhf(M`l&5yx+!ufdWx2dP7fhtih z^9q=Ya=-)C)xaGy7qs2oL0iCq@JhJS@872;m-f?bl!0}_wW0^D7lbaB`GQ`2_Yd-G^Ak`A-g4`D6m*rGCv)bIIre?LEe+z+4o{xq)l`}I11cP z>)t(5vc$({4tfonH`O$;C?BRfI)Y#Hd}~Bs$DFdoZ7S!9NOW=4)EYFXbw^KymM(iI z(by5E?Zkt3-QqBz*KVE}OD`KeXBi)d#3sb~Pk@%^59h7?|HaMtxpOsM?IsN~pvMGr zD;8uzmV3NeB6KiBm6v2}=qN&P-J3UnA$MyUp81?VcC5d1R2yHFu>74PR_f2YHied2 zPj~3x!HV-*m9P-sBV_VwZ9}p4%$a4BZ||dJt1*lC`T9mz={;^w_>4u&h|t$lPMbEV z?o@<)V7%qlUbClgYycH#qi84p8y5tTRTZp%+d8qvq6Tc$tc-b<{JEOV^YFGjR%;IU9H)1~PA zp$(XEV%D2apS&Rvntx7ra*Eo#xw;`h&w(m+#)=j~a)y^&QQN0& z!A~}{G+#V?_$^*|gN?8}J3YnGQMI40jrhj(>&&E1LVk~&0~N|1Mo17|$xhnLoYFR? zzW}TWLAgu04^N*wK?uw>fzru&SX{gSDxffFsJfYQw{8?Gj4JUOHc1ZLUj&wp(r6ag z-2P#+cmqUHiO^;%*WBC|)GpRGdF1MTz~E5*XFdVkBfI#6O&^S;7jCAaH8lcL z4k}}!TNSXP>fQU$=FJ_OJM2$Q&H7K;C2$KfmT~(5n-poij?>_-sA%Rv3Ay&{`}sG- zgaA~K%X>ejz!A3;huWr1WS;!C*VD(3t4jHgKAbZY`--{w=`;eBr4wh&@TVqoU({f> zS<7Kc`O<$Vq{!7iaC9pb|JCTduDr4NC`)j-gbiq~DO6$Gw}(rpl^&8j?M|jvz6A0A z_NOinVOq-WhRWNu^Zus=@bL8&M6}$-X!R!0Oq^(1S~f~0dnX~@Osc=%APQFe$#p;` zR&!?%r*lbz2~YsI$*dKJ7X%LuEq%jOCY1in@#8-sK>!Q}JsG2cA11GNZO(~|3{|j= z!1*~vAM5I<_)3Q8D_Ro|K}-r(Ms)1d3I8DLVyQBhT5u;q9&lisVO%h%8f_@t(VA7O z@L*=%2s_u!%}U^LU_4YuARCdtM^J=4zqGo4+)CnBWo>QqljN3#oMqqw>=(&Ukf)Gn z7>wwz=tx;kAwhJY0~zyo58#mSp+cfa%7T!1w(sv0MX@aBr~f~;;7vmS!?Fx(NG^I* ze{15rt{ORa{CMx3uC*X^fM?=Vo>~xITE1641PSB{GY9qR(?hiYdrboK9TXzZ(3yPf zKPoI=+_57=bm~#(+F)!+6OyG?`hy1LxfgE+GH&_NHf&gS5iHm86~|y2`3mzTo`zPnL8*{G>PdCIUxvw{=S`UKHcj7gd|#C z{KOH+v-nw{igaxJ%Jj@IRWY%#M_k5|V(B%h>?jdD=XQnVKz5oP9J6m<1;|3la~I!w z+igin)U|nOX)+f46>Np(5hxnX8YPBmqZ0jUjwX<&mV@zt0kQe6G@g_xI?PpSNuIrU zF~(MW)y0c`%iH@hekTN73T+;|dB=TJ!a*(SOJYM6$E4lo^z%nz9WZA+xr(bD+0fqo0uHV?fl@wDz0+XtTf6s z=Mzp+iIs$K3h)6iJN57c=HgATf!lL)vu+y7RaB@n95U(KGRta7okVKdMyouJq@*NJ z@~L%)=*NJos#k#z)Aws=?7i@^#ckE9RkHt}yDI+k>{$>V^P>ITb2uk+IaWRPx@>oWT(F$#gXC<_zv|w>ZIO& zZ&LG)UAgk|)vG+PIc>c@eJ-GgL4Pn1z!$uT(I3XEH9m%uwP-_~ZFi=mv{EgVFKpmk zLsG?_YiduDrut|clW3cU?`~1JzyKzpO53BTBQ;fzBjSXF$Ap7Q(;>X#7$`+5fd=#ZxpR>2tvsbiKIbQoZt^6D>8>59Lr8}(;;=M~ zK`2C1IANdp%rRqXxSoxjW>dYRf1|G(>$RF%h31E*Gu}20q0n1&mqch@V&hCkRM6K& z4Y%$(FV2eiNO1wIGt9(f*^(s%{j;%1f@-D8iSj&4rvkD{3GnI57k%*ooLi7qpfoYn zz&W7_07~y?&*S#CnEZq<{Hw6ekBWESz$;M3=9mAykPe1`a{cJhjuz_QR{&H) zEUobO7l`qVOUa|)?$*|Att1>VVmf!Nq;-SDns=u3=grSAA^)deurzlu+>w&fYu*JJ zZ5s*CNiO9sO>=r2dOb70%3%7f>)!Ml`1VuS^dRttYJp;)%e8r#@u#tosnMmLciiPc zUy+~o%9uTx{CAVNVf~O{n_6~k+9WP7JaF{rI>F-HqbBdiSAsRQbyezVVgzskQP?*- zl_f*C3uyN0H6QVT*BRxLp-(S(1(*nh@V)^paeSEbAGYbr2*N-8qFu(TE zV*vFUX*?q(hjD_S)<3U&&wLL0LOB#5r(`7oy_tX7v%`-s3ygR1QPTQZ&=!L{#@x(f zT7mqnuVgWm5t*~TuSPfIuBA3b2hi}7tPN6Q(|aJL1lVQvn$JCQ4InnVsGGX_`o^bp z!Bo`1=D<1?OxPt$7{Td#N>!nUpemFQ$J4-50)j zSAM9(uXVPX5vm2rdI3pE>cHVhs4y3dxKzG#!drf!di&cUm1lkr2>Cy^ZcBOlMDxH# zqsOUdn1F#_!>Mur7U7s4QKe6aD)eaUg zKD6fje`+4{p9bH7JOyRb)pTMEH6sW#HkGy?>gZ12@QW6|fH+Y zb5Zh=@XNfsvc9`7$(hgh5~stR!+rM=U(tzbuDtf{8=Ogay2||ztLOoVRp$M_(82J_ z@LEa^Z`-EaTuKvxlU6sI3-RNw|eX3=jw% zi#6ON_q=N}4IxGzVQksT{9Q4N3dpF%#hBIcE~l;xz3+O?mPXO$ts;y&79_j%9Jt>6 zgqZKUVg^sp{{}N1u%5$62daFroQA4j;Khk4qvtlRm^ytrWuZpE#SQiO?MXvw-osMt zVGuKrpSK4WCBP zh(BGmhc39e?Von84G2$8K!dxDO%dsXnvSog+~UlKe;Ni9O5^2A9D<}n1s&G1L3B+) zCHhTW-Q;csO2`5LnEQPlHbGCAVJ;4~ys3?J4CSd~7#l4$^ESKIf zrsr^lJc)5Z2(}%4%t!kcZ|hA6Rr)*(L2LWuy2249 z<_d*jtIL-$KqnofrPT;?FI3%C79A_jLtL=usvo-6g()lmnGg1fGai>CElqcI$#5h3 zcRKFMHqjvO1dF1r4_LDjmU^uF2radJA65!T!R}qcY?+@gor*Ahx3K%T{}&wN*6VrVZimOl)P&Mz4bi_pddwT_-QLq?m5`7C9wOIGt| zR4lJfFX)te8GUgx&ptd_;R+KV%-7MuH&zk;x%C6~MYPBW=m5-}#2VAknfOfCPxUeR zgSaj>Hny8uk9Y~c8LchK1kH;PZAE%r<}Hsr`q*p1f^+W09TCD&1ye5uvD0PDYM-b_ zJ&K!Tzr3NIH|K^1tY4h}{_)eN{v;+^VAK;hkwCHt_>ZRqnHfQpV7V0=c-Ij z1v?VJK;0MD&+pDmC&Ludd#vEGDcDK$6|P0VY*P)-EEmDQfzvZEYk4W(6GrbyBxZP% z(mQpp|HmA*my_$*<$l*A!;YRhMaF^|q1~v@b0#wo6RbjRUii$@=U$6bDQpGjz2}n? zJ#B1k+}(9H8thTnk(9)74Mb1kzSt-@>#-1}F2~$`H=o*o=$O9lEY&$LLvwT>>X%xy z2^DWc1|I`AP7kA<_uTiy=z}Zsm8hh@j39hg08PSQQbKV@nYt9}?4JXFy=3d#(me`s zJ%@k6Vd7ED_$tc>U?-G0bJh??^Z<8kY2?ryQr7^T&_w_whK7O}>mOLniWfBjPp z?c3+K9)<)ELbkQ4bp}-#EY#q^!kW5Ah9iaE3tY=uTosdvvlcQhyq6I;8QyLYQhP@D z6a2hQAGy(EQPNVMRW<~WumQ*MEcDITwk_WkBW@xu(oKQoHcStjGq8Km-TP>XCbj43 zp-<$!QJ_wqR=3UNk-u}*&8>y$VFXP|a<~VjjRWM@Em`zz6-#q6PoBI(PQ^$?I{VKI zj$!rnuG+}y5XP<>K72SJ4!;JY04Pd0lUh=K!TP*OOUlojIPqCr#?H< ztJKw~D0kLt)xq!AuV`F_i@>X3oPu=6z~U(7yfsP zLhdv@2jpZD-ylk6oe!=EdvH=e`QzxpgUEE;mn_*le^~W^zjhhg^<-t913}?0EC!wr zg0ZjThyB=tGJf_IL-qMATpK!6*?%Yk-RcL^M8R2tQ3qAtzwFC=qT0v2DYQo$HjGLt z?N-sZcW-vY4fnVvUj;x>yNgbCwzqLW5`s6NffSZ5{DLeXk9DXkQmaBqUtb8o!hn0# z;KA;S2#Pl}NElxc1}bhYXyh}tS$!!VA$454cEmJGYj>y8u~Sp7WIw8M+&-4|es&6x4;iWEASU>J zer0{=Wx-rEMyYxYMRdtSs#@J9!#G~)5Is2nS);}x3O7Z?!5eNe?&E1Fk`kajP>F}}pW#{!gRDxw}*9erwRs=;hP7qJ)(JaYnbGF{tvhWO6mjo<`sW9ls0*v|IP}wS$|R_G5=}Y4_Q~ zqAZb6eFyt)V1xl(29?A=f+}%@q^HQJt>&n8(iukZ#nl`d&MfQl6I{Egs1t$EK z@GQW}Aaof?5?TB>Z@OpI2c3liY;F#OI$Hj6Q00-ujHXj8$o%<@*{$Cwzd1dWwTG_$ z=*%pa+s6G&%Rq~0?uWF(Ln|=HRLwU1p<6>cL_?MNGN^K4SDCqEq)2#a?nbIyi-2}^ zlsJ6StYLo#8PLq&UMA+rJO_<8l$!XxM9mx9-DxWVo|$n@5AYtX{&)mP8z8&8^?Sw* zdBKfkA)ge`%vD~_By3P+Iy|2{4L3mgW@~#Vrv_ZwWa=h@k~1KB9~H)oBF{5rWNNzl zLz8UCn$X!+R;lo8^PiLXU!3z!LdSZ2-_2)Z=Pu)o(ySB6#HqM;=L8JdJ1Kpg+*SL~ ze+|~Pfhy6$J~#LQ$799yP0f@&)DP7aRZb^}z9uFrzJecy;_`daXBBK%f|`sOL7I;$ z-@Ly+cOh`j=^Vn?Vu5G%ncH`5`RqlP9)fXs*}q z-gS}<6}A{;tdeN!u~6=1#?+|M0lL|9ma$h6c>s`F#0^2~+7RKVmcse4dT1qeYi2cWkik>-3xd*NBdfrW&kf33S0BnXWI#*7`iz@}$M(H1@Dgy9JX z;z(%dhT$>86h=c*9L&vZQ-7EpE;`ahLuYaO&&krsJ^~1ZmaIPr!&E>!6g^7U*~v)& zt)NVB8)Gn`z5W+&JKw6{M~C`tT_4J)T%vAq!3dXy+McU~xbJe!HZ zDr)bwLiX(4yL{4<^70(Cp8Za$3Nhd5*PX;XS@_AD0Co*OnP}^#s2EcBsr|(EgB?bj zeGGbE|HhKn#%VetD`1WRhJClla;1v;`=391Ry=2a8@3+eMc0gfWU* z9$|gq;)jP%{1b>W6LOpd22};&iPM+V)VXK5{L|W^n-F%9-WxdswUm&ah`1HPDkM_h z_<-fA^>>(;LuRY5FQ>h#s-PVL7POu*ALW$(95Ag@l?xNrIOJ@eKs`*nVNjy|q*UJW zmaSVqefV(X9&d@fP9Y_oJ7(&JIW(08G2yjROaC01^_+k61kZ$Ied&HMxI3 z6=1Wn(un-sPj=e$=?oA2ffK{)ksy_E`AG=L8kto5g(DSeTww7YvBC#ZK{dy{FDSFbG0&G+1W zR}*vZ)vJ`6%+NMjx|Hs6XO$tf}oBfFwpDKLt$85`-M_?x9r3vd}fMHHe3w8 zbyrVpO&`#@;##A5w}DBZsSojf1Pk}{wAt{97%4FMRd$T!0|wls z^d395-K2+nY50uLBnf3{K+WXHK~Q(go}c|dc>gyaz{DsG?S+cbT2j3maCO+y%jcBi z%@NlpbZXKI#}8VG*C!`t$^GNAJfo;Cf-xq@?{vU^fxF&Fr&IpDyT~WApamrtZGVMX0gYE>=shf&UUvo4 zZPpfOS{!NwvDojQ5M7!L`eFW^lS&2qO#0u3IYC&eS#@DJ#;PZ((SwJxQ`@MyAuJ@G#K+$?veetE5F(L3CdFQO z`1mmhl(6};;rMA`NohvLrrjpp*;c>5t;+gHp7gg}E|YE5T8pxd>pQPF^REuG*qri8miR#UZkc!XUZMhzv~rfL%d zEoNqagkPlHq5b=fD4|hSRhKR$bWmFEU(yFWW?4pQY>)U*>9{swinbPD=-DrX&=X@| zzCq&ufrGc0mOf1L36(AlKl8*Y(T7f)808!_=?13w8iJSxkZm@F{o?=DQorD86^XK^Yi5K*LbOITQ8 z{en>234{=Wjr7&(ZBS(-Nr*q7aI2O1d;rjqqkY0}c;Znc8PvyCB85=+lGa8PGjYn4 zq&NF4|EC3*LNTIKs^vz}OoR6F;>z^YRP7%Y_-@j`mY+BRqoKXwp6hq!j-g2zWeJ4g zC%c0_)BA(U$c=hNh`e=+iRf4c*7fjj2Eh+U@4Odc%i5EiVtuFj;B32LXJ|Vh3=2l^OMYZQ&Xz6~F=tRyRNMS%Wzd zL)m>M?uPhE2W32Rgd*1AiWHYn6lmq0R$eTJ$uJ*&{no9RSrt>}(9jD8@7xCUCqI+u@TCff0v4;)R_grmrrHSg_U^{4o8&Ly@PwCv}3^9Ak>^3Lk&>^&9VCP#C z6YJkRVbt}}n>9^e7tE=hHEY&7&`u*|7sG!dEF%HsF9!|R_JoCAv^@+~7A%tZ0oV4Q zyTlt@O}ImRC3@#pYb&cNk~IB19~*!bB#QF|bNz48?#-JSg}g=+GAnup#4Q*DehtV$ zb~p6Q7imX%?)h&N9i-rHwXV_sHqmS5&j9Kr>!~l2;Jp zyzlY1=`PFygI`mcb`df-@ITy!ZES87WCO1{Y<$7!sk+59!6UN)W1^)b#OB#2$UqGA zYzfuqre}I^+S@4oj1OK%x zAt5Ah{TY|zn08{EKye=U1%XDtejf$t(Yr6TwK;sZ4s&LVjO~>gkLkbE)hBf8mdeUHb~m^0vZ{Dq-o`8NeEoFQ z8}BeYhlAE;fx;&?rEaO2J$8e#mXr&@T#WQ`Cn#vv!jD-!;W*&{Fh8ti4_AZ9cR?}L z3bZmdB0A1I#1eXFUimI$@~6qwRnNUS4K(xzo_G!$GQ@0Sv*|Fei_nw?28%LM%x|$t zu6_~%P0sKFOczLta8Ne-Nk|Q-eN2*J*GB;zrjGvm%;zUsCclMOQc_a%3?I=M+KkqV?}NJz$BC^|{qS!NRf zG#a+qg=&FQW+S5mc-G&#mWxgZ;u(>=s*)(5)kg>J>@he(fER!O%r#G4y3`|5O{`7f z0OKAt+#a*MJIv{Bxk`m$_Yiv65aLUMnZ| z-!P&6Rwfbz14az&H`Kc`S&Y2i$0Ygrn7PYtnA0 zFFD&&MquE(80IxF&^))P=>4jZxq}L}kAxNqi!^h{B@Bu0-afz;h2VX}9!oSt0k`(k zf#fcI+YrFX*SLQ^)PFpZD@#k!zWP42&w`R+)~qT1>*z6o0EkPCm*{FxR-$A70N}B7 zDJ+PvoI=3c^P$VA8G9c2rkWO!VwZu?_UO@Zf5&n(@G@ZZZ0u-NCMUQ8^=(bi8Nep@ zDBG%vyYgFlEk$e6ADS#w0!@mRrG=%1+}j(n}m5a~uM1Y#FCD>UYIgKtfNxTk_nv^xOyg&IC7z+#smOVSuAef16K>kr>&`FEWG z15h4~#tScGn3U9oT8MZ!Z|>ZQpMB7j=&R^T$@z#Gv=Xah80#cUbXQYb%V&kMRqoy$ zok>mKqq0^4sX2K!*_pl@n6;-@n{Al8%t?GiVdYsa3aCMRpP_UPl{xa;5vwm!5_Mj8 zk#~dQ?i_lsou#+I+MzGf4yAnFD=YZs1P?|)Ie6qqh-wA~r%o+&HNp@_SP0MYW?ZAn zg94J)F$tHkZ@7$o?A3bF;o15K_EiiK8{7C*F8OqH*3!rGXL~c`xTNJNx65NMI# zT7Z6If0&&_Z{)~zR21ARLzPZXJa|?@NnuAfiCR)sUM|L4PDIGzp)OpofVA!QLPBlJ z;pmhD`4vkOXJ2PW8K3PH_i8R5*VRd7nn&;J(pfQ6pRf z$~BRQ=0cuoAqa~L>=I2f&ML~zyu4;9!?-OXSu0ezaP-Kv21vx$Jx{ZvjIj$YYQyEN zva+&ugZYGTwmGXm?XXYiFk0}XAq0TXn@!0Z&dga*Kz^K=i}6F$b^TsG_+p(}&)*nk zjSJY&7yN@NMrr+;8nL1s`(rB^3#f>c?^2=2)yJuE%-4oG#-|RVwv`c4n;I< zC@rp<_o39Gj3<{~SS2;sOp}d}i|)K|Q*T)-QVD*@$D)4r8KB$||7^L|+3e#UFz;S~ z-dt>uNvu#aj{uhA_hXvF#0b^i*6>GkyUk}(Q`?9nDk>&GJqeTTE)6AYFb&YPs{lMF zT)q?@UQD+rthxA+x0S-_+1$HVue#zbqtkbv?%W>X)(tS}U%!4NV*2`)ym|)u_ZHJl z5`FY4wO$?s!ZmdjdznIbd%I_dB`a(mm6tc8G9n<+81Z7M*Oj7V4{d}gcXLaH8p*s^ zOjyL$vg<8tVIW=B$8hAeIk|z%uM^`2x@20up4y>s0Q3Bh9wF_W=9!J^o}y=ku$ijY zvaad}iVVPT+T`2327PASOXI^-Iz6G!?Y@WF< z@*0f`D)x}NG=tw1`hE|B9!8W`S@$Xsx$mEA? zt4^YKEQIWMVf=K`8EEg0n;p7JB-a%TRlEsqeqDMr4__ZPK_6#Cs|K3c1bbNds7;$B zO%&)-QanBMoh{4~7AUgJNUv#3uBY-@9 zLQ1Q#%s1Fl%XH;i`P%ETAzIgIlPL@v{nJ~3($GzWpQ5kBWZ?U$y$1QRVUyB>aP1cb zQ$`79q^}0a_X&HM+(mmrbKMf&$s zKf3ri(meY$^}tx@o6MN}&w6QTX_U`4J9}n6MIx&$n6QF27COtBBvYq;Mi9}-`@k7s zyJyc_YI`0#di1>SD+RN;+G1;+giPHwB0<@5-X|d`sn^5kraY!yI0b6C`7f|kW%9`io!5RVfa(%KdED6J^qgK7&5>xjw%>eTPpX}2t5v#RdTpqym1i~O;k?Wu0 ztz7JRbLPm4guNyehxd=#dZU~V&u!xSSNtve+m) zrBrq~Rszzba@xUBS#V8C8MR)&#R*sqpuyEu>&cZdQU+eIPJLB}(e@~Mvlum|g{IW$ zSs6{Ta@uzH-P1>pXlX>E^UGfL%e@U~@3mwJW6rS$G8_$*G1d~n7@PtBlb7FPykHzR zB-WPQvs;$^ZoByaO)j+)Hil?aPk~$-8U}uUpMaE|%M(+VoYR@3$?Ik|<=juT#3_17;@g-e5 zh7$?HhFf-6v!$d4R}uU+v&wvkKo`nn&pDR(#6Ys^yu|CZUB5}EIx1I#Xe2hb=#oLf_^-np*wbGmW$y|%Mm^a z!$Y$O;9c~eF)z+zKCaLwf{!L$QdMZ|waipoJC*IhdeTicI=p6r9geLYzJk_^Rkbz; zkat0ELJeHq@X^UTHaDQb8j$bcuX^bLTx}Q?7LifE=eO?_)8nUXav?<)W&@=gS291k z?CNZ9xVDV0CURi}Hw9dXz0Uk?Zoc+fwsi#KhQ8k3G9tv>vAa*S`|L_x!#Bp^PG0T; z{Uth==-Xa0B9sUOX8t+x&=i_%v-fz9)bqS9jo#EB!(n#I+f#P%>dM z5BX0?iMfj3GhMsYQsZ!9E*Jwmb=K)!63zIMci*_13;de?`i}^x=K+rT=MTEAR!m|W ztFFCk!ud02qNAdAvB@ScF9^Z{TPda=&{6b#xkq(5l@WQ$QTg0I@9OQY?c0bvjfGi# z#$QGI@kYX&?c6uK-M5%fw1h4*1XB`Rqe*ygHBevza<|C9AP{ zA6yrg@pGX9=mjPun@s#T89#@UG+;6!=V{YmCPw)DgdHrV6lH#}3G|3A1d$j^C~_x{ z_+Fz-(@#ULnrE7*yRF>GWfmIhdUSi?FNf#IZvlu#`p>gQZ$7G_;Qfc zZY$z-2p-nnTc;_i$|L3NEebBIVja-<(b-gl6rg0MVt16xXjfuyjA-f1bggUP85A=K zx^^~S#uiSG)=sd8_9O3ej;kjch(u_)QMPPJSR>ap1RPO+YX=b>t)$MYpEd00xN#jt zn3iwEV8D2ExxT4XOHYeG0bK|tINYztylYSHRZXFJ;d7$P4Cs`>bo8_`dy9@^??W&Z zRBIB?L3XyT8>BKiB@YBjDTH&cgc~U#SM$T@z4c-DLoU9(SeIhVzh+($7K=7~i2~K*y9*w5^~0JNp2lgBWJtMJ z^3}ORQ5L@Gm*f1N^=Wi5CX9igM!X#@|zgCKXv@oend*Spjd$`*+t@9^w>oUz$ zflOZ|Tg zqh@l;V6&jHdHQzyuzeOm3L;)mER#F0H^&j_0T76DA`y*=+HvD`-`-ACT{?thpSOA) zKY=|satk2E&I>V?0a``?jinuy*wmNp~aYQ<4<;pWe1eJTY-+c?hxgL0~ za1gvP>wa36-TCe7h*M#G)fkZ_YKC0ZZ9t)1VU*1KzjgJO0#(r_BHPT7$%40}*;tVN z!aP4ajo+OgAi7%RClVok0+ZO9>808|t{xQF~?B@G7w-6T*(#iK{Hno;iy zDl6t#Sgg2Wg>ku=j40=ju5_k&O1WEedwe{un(=uTGIh0ny_ss6LR%4o)~Q-(`It2K z2s}>}VqCzjqr_|@B2oyg($8E+12~q~_G|n&8~nbFp|ixHtRDaL{{8hwXVMtedb@Iq z{Xy&lZmBU42bd(B`uVkrVWn^I-de(A4Z2V>EI&^nH!efg&cJN4U&nAIiG(UcMx%;u zt|1O*jDlUMxM)Cb41LHQ(Vb>{pQFBN71gga&ZtkH5~|D7x`Pq`yp-ilx?7L;%1;4x58s zB8oI-tWh=E_1Z9${)|$BQ5&WB)WLEV<%r@I4}G3d*m;~cGi5UkipCl#{V!y_GDQU# zbEusN$83OAIzp7Bg@-)g%%A`=qi=CpnCl|7P;3FHHa9oZ09^S|A-d{OpLdj{=T~=hNlW^_$_z%O=?)>MpMGc1I!XxdU&LP$AjfbYluf~+jjD^V-{{pyx~b{n0;pO zzg?BxBoZw*GhyJ4N(3}($Qd*YS!VYy)X?c<=4?fF&5M2=7H(Zd)wd6KDN|G2BF*&s z*c*+cC8pRh>UtKG4LE_HTF}6mxK~oz;+I@03EdLRpUNu-n8So#l@)1;zhdD;+eo&j zq|#y#G2iv-&T}MuVpPIdFJ@+7E+}|Vk`R29x_0f-i;KInGv&i8UDOT? zkkp^U)Cv@%^;PSiAXvWC$jJ7Qg&}6kmDJgNU>sxF>&TM~Ww0Ec26|cMN$Gwr@?x%^ zD!g-N;ILuco-C9T-3wbToAEy_z@oF1yj}jJUAla^^;=5o)!PmH@91U=KKTT7N+IS- zO5c4$zJDMs4R3KrWkQ1|RfW0b!MgpSq1FI3_@p};usmeQuwmOPE_Ny8mf%SWZVj*< zskMp!6C<<7FIlBOM3?JN@`BMg{`us8I$>b}8lixC52FQCNz*4b5`leiM5 zi!Aa>@#;)VJ2b2{sa8yQXKQGbO-Ffo6ul*81#aH4+*4BO8;@3ZUC znEQd#`1rLv}@z_*HI=}j$D@;GrB7o0`g6TSuJaKy5f+nF7BMk2UgS0rr&^evb zUf4-Tr(X3oEGnwQ#=H5?pO3V#$YUfy1Vd0#(?FJr)VOG{8S|sV7cO)cNi;PFhLz2t zE4$YXo+$QXGe7nRowSUI;s@n}-YThf_ zk#!Tz%UyROSNXQUEn6{0R-4tu{Hf>rlaK8tMa z?tXyZp>|HFDWOdm9gDcd;t{4W07|H$}>JF940~Vo+3&Qp|uJET?*TzyDc++ z=ne`Imi-JFJeV3Mix_nD=tqttZ6Q-%&qhZh|A>w4{KWY*iR|*_7dLJ==oKS8$7n?u z#}~OSS#k+tRYw{c>{>vPPl3I1<&ez>c>K7|Db;G3=aM4v@f5k@7&*16gcbwvc}QT$ zYVVb7RIr=mswF0RoIyJYr;?-T=;#=H`-VgoA^LOGeJw@drEkEV#tr8_&s4Mq3K ze$>Sj)8j6j36{0n)Hx%lQcTFBQ5kU3wm!~?$yVY@UD?(vps19RgnazFy?uNRKK#Jn z-+pUqZ1CoZyJykBid-qCbaeq_W}2T~N-;$6lkS=aV+Shd5ja2xo|z3;NNBTYXh7M} z4)NnJU3y+vC==b6z6vQU+hjzd{RX>t?GmJcv@l<1+d6$G=FpFzrtt7MGP$0N4-74M zE$}?)PiO!Em_Y@&!ag}$?e*YlNf+-uZkCqfCCk`SK;;TLmE(U&$ULu=Gk5NZ#u&A8 z^bl|YXj}U`ndwuGaDH(w8|&0>dq0eNesyT5`pF>%OkrZ%Cz~Dw>Xki%VXX=b%?>zj z+(vHm0aVnpg+A-*y?cIuPjzK;e~k*0NCn(Twyqn56q-ZQ1s;rh?a%5y^3olFTMG={ ze_TS5r!hgFV?vc+;`2PoEcHVsGR?Y~=% z^7GnNC@OUBY-MM#Hg*XI%g0BZubf)!2a5@t)kZ{$q?>v62c#T03%;G%AjPOYqO|Y< zY5!Do!JFB_#`E?oMyZ5eqY`p(P`|wJ~4G`Ny{t zN8k4{V8k7Ja~#p9qt&VY@s6P**LA|NgjsqvFW*L5`UXYeFD8qSqb8=<0rNhSoe-)^ z6-rXfHQW}ty;3}QzwYs4$HG?MhqR=+>}_hzQOV5AbZZ^)Uffq%fIb&!_x>`U{OnV9 zy501f`Czb2z$(MiclP8Q$c42zlT9Gtl98*Sxdu-I4@*WMQZRE1MG}9{QrqTCsqg}o z2)j0~|KY5V4nYrg+LNKiN?udLh(tT>XU&@@_$?zV>H?Pxq!EB8UGoauKOXEonPwnJ zv{^BGeCH5pu{-54HL&}!F5ChLFa!ZR8`<27#)pp|^J9Uf6V2ccc$)fP{1$FJO6*90 zk~uH)?pqVFr_tm}wU+q7y?dhBGTG@5N!UaX(eu{I@E<--%7TM^ z|NdRomLU?GU~eBhf7}Klc*p-m@b-?%SNy81u|cIKMkvNiWM=f38*vg5P8F5a)oeuj z1Ji%4xS09K=;Ox@wF}i^7L2!;c~vY5zU_qucKjxNAT<8%=~{MHda*=Ma2+LEu&Z(8 z-en-Af4cSPV`?<19@ZU#P_UIG!BdRc_v=@oc)HBz*+(yDyG_2Ty4{d?Mi&EDSg$gQGG_2hjAma^>Y~+KEs^g3*+L!(X|gVPZ(=qEOm0@!f{HicyP35%?Dk zeV$o@`47|oqk>xY6js@<5o&Hm`x4jPP9anSoznK_52EUWn9Cf*$0yvjZQB-Xq0&r{ zA{{9s)yT@R^ouwrKzTj$d8H`^yV_8_lOZ}hJ~Fo<_bLPV00@lrKzCH_VZu?%k_V zzWwl&BZXWJ$r;L2JWMd<~4sEev#Xu5(aRI+#HmI6=X-rX4$&0_BspySh5_S@l>Xa1 z0GI(qHkGo4_)MV01Pv|arn2z->qg<#12j@sSvh@2l!*Hs=vipi1KzLE&2#KI0wc$P zmk7sWZ&Z&44TJF+p{G~$;zhD;8cX&HMo;5ypDsH$P+PlAL@a#`nJaG9PLg#kvN+)z$9p+Jz%)pn;;3*%Unr>U;Qpew^N_wiLp78~&ICQo1fVkaF*`oRuWi zQQXz#)dNMaQo@7fTsEqaDOri*saLVwrAPyinR5MFFWqV}MildN1A+MATG@cY(7n(^ zEI2t~D4q#&f4Go~1gjH_t%xC1wM323?cES;qDzdm4I7heoqDyh&x9NdP012pzpe<7 zk@^mteSX5nz6F$x#8p`lpo}n20&h<9n!?PLukRrFg5Z_Tw-gIPErDL)DX;1@!A=AJ zQy@R{F$pZWd)F6b?}OVq3$k3G_rh9VKy2|B#tZnvl7xPJ`-Y4=j7Mah7!f?r9l2p_ zdJEdfH3NMhD= zjp$UXZmE9x$!ML;*s-yzn%CEeDaNo#rRC9+^~{ehR(ErB+ycP#NG2OXHwcyJz@+|P zb7?!7QPe5zu~?_jJ&WR1Hd>(yacB1ZV%G%yoOzn@Pb(@EE}Yat9m6|I9=ce`>pHQOY91&_BqCT)DonX3xq)27(&{}- z!NpyXn1^8qULMu+hT={8@RJBfXuFUA3s;*#&-#g!tYAv`?&rVSpRFrZ8^Vhw;6A(7 zvC(%L2}&ucWV68<$pLqFJ2PAcjL&MjR`uOp*obaBT z4MVk^<3ZrdDh+Mr#Y9;sH zkaxmzaw3NNv;_35qzM3w;GMd%B7_09Ha0{viv57Rf-Ol&^YNe)ggvY0CV1M-Eh4j3&z*m*xE8U;jrdwSR_iNxp=Ph zTdEn)PQ5qm-talj6hj%ELFK2rqyJ2}cIwlpO2VCA;x~S`$!t9Axr|1H57t-}aaS<> zjEaeQz2ah_!Ug1g`KB~w z^gW_|!8lW1kIwqIn(K-TmMBMbM>Rm{ROnXy_H9JB55<|MPYXVh$SFu~HXDV7hUMMS zEcvfo+4I=S)}DBIJPRK8pEu3deBi^(`^u`zTF2T_Q0BF^n2B5~a@G=-8{;LiIMaG% zxlj`*MR|g_<733{LqwF7le@Ef*r%W&<;zCh50Focfl@)r#bwpu!n&!&3g(IvxhU<)omxO9vzqI<3zR#$7Doi@PuA+Jh z(#>c0>L%E8d|f>0(`7|a+yK0krF1@A2d>fDb?Y`Vn#!-iu~7Ttt@w_&MqZ_85tct+ zpMQ$J8~DLcOMA54v?)`*HZ<%4HK8iy2y;)^Rm&_Yube*A(@l#>kr~d;VDSJ>-Pdoj zL@9$uC}dm2YAI|FYm^2KR2bLe*}5Tmdaqf6CGOItdy~`YC>*>oPCqWa^=1WVP`kDv zVPP^A&p{lh@45^eLWDlJ@772~{GeE85f8k0blNeSYnv!bk&|r9=9boan%DZVGb$YcXz%X|3sXUQSR9r44bP_)BnF0TN zbm}C8D;=4UKZ~D)K?kY>&KLzb*QokhxTP(<9MgC0gp4*Qx6vI(ehwRn5#bUd*^s|R zlpwaNr65WHV{~;PA&^eD5wQa`_s;Tw(W<( zKSoPj(tt$V5uyUxKXlU2#;by53`lXEDV$W!=O7x#jz}=np^bU?(8J41|A(W0+t6Q1 zsgA;qV6udpMQC3II7`1TvW54F!4wAqEo-bALoBD^0z9Ce6|}Q1VaLFaSy<+<;Xyl* zqq8##-lC^=pFe)wP4<+k8KdBV4dn$O9AT!CxwjVV^>_B_BD(kVDK2uSV$8+*3PGic zI%1L=EEwA`ZviYrF$-6s;FSFHd?=ZB@7@9UwGOevWCZAq8dkP#2(%}MUf>(Q8+*#Q zSWKTd@iV}mD3~h4xp_v6)+U7c_G_gQifu}E0#Y9~pmMxSlvx4FI^h;`~tGy;ZzNma<6oy`Y$*onzIJBC|7^Ik%zgl49=vK2x8KSj21hg39u)h25HFA&nDhtq4Kd9@>T3#kvM=o-Lc(!}c-`av)Qa`; z->iTb19#`yvs-K)@WXwMW*f@BqT*sEqSwI?!sW`#$>=K=%yT3b*54U~21-Tcn%5b( z)25=}e`q2|Bf-v9!=HjW7dWh}X$Z^y5H04bSFu!sFWZ!yA9lb1cJ|b%?yc8RA`(N43Fo_XDQ+=r$I|?H-ASV=dyJL@<5TPZvOUToJcPmv z;5ix>0~ynMk&19EMWtPPhqW=x%_?-5_^?BZ9WV(aHlXoUMdld!lVF(p1XVKizNe8|WFjqPw!PF!RZu&YejP+(CFj^STFKJG8N%HCcwLyYpEo40Sj#L@vywS2^1 zRv5PIdRBVlM$_G(7MbDO2>qWwefl#f<$a!PxHZp@?T500Diq^YVP;)nAdUqN4sArY zSc7hxmLFkDX=tcYN-J}G(%Jwvr$Xoea!v`-(b2Iz+G*rGbvM2au^+4K8V)J65rG_V zIMQ~=>TOY>#HKzr0~UkRz&aq1|HXkJ9G1l>X`?|XJqT>`OH0RXH)Y#-_ z0DRsu-%At>l$ay%j!0Yp)BzIUv~?_|IyniU!T8y7yCOx7^W}>dW6#D;qWlXD4cxGy z%x4XSW{xhJu)>PD+z1c<3fb1~igpa*j5z+Ui*FdkcO2Sd;+oA~BtEJMXo%b^x`qP> zs`%q*hXfG4l-m3sIcCh)w_Ui%C9_~#->J6!ii~sB-IgzJ0PB($Qd@*mcap49+aAaCejaP)K?tZev#euVy_QD6{tz#)_Oj%1D^>~nmV z&YcNSK%Jdu&yJ{^8+Kq31FJ}bsM&DX4+zM{`>D~5SCTeTfPsv6&6zUgv|FLvkL52geL>m_RU+98CS`eoq~NJ@t^GCR}y>MOK{p^ASH9i*4WtFKJI{)Win zWqCQsUP=9K8?qG}J&OIcL+85Kj?^4kz}zX^yN`)rUzq765e1Js1hNTK1%iVUBIdU? zUcR+m_Yg6-r$bcbYs;8+c)w~Lnom6S8)k1^K=r+nSv!$%|2dtTT3Jd(ClAKz?E=w{ zk(IDW>DxE%AS+and;I+Rbu-hjC|@%Z51x)7_2OQ+N_uYqe~X&obq!|_itT-f&Y)KW zfde^HxB)q5JA`NJ!hsf=StBCHWp&Z1cej)Kq)tn9zK5>(f_jzBwfy{$N$F@eTXYGvgM$ceD|)&zZ7M>Z>*=&XKoKP@pqpNY~l#2+w?I2ZOf_8%?bjwXb@} z9zQrQJkkr|!T0DP5cwM@->j_EN-E_X;6%r7cL$yFh$#_6VJfzd>(()xI{BHIa>ua; zlaGjzxXu5cQZXPj-<6SsCM10jDfs3*rT78ZM?OUtoaM3K1osh7<6WX)X3$lldzUHR zEZA6raS3Ic{Cwt62F^OW2<#ZP_QB)FP)@WUjDc8F(Fju;I z63I5@F2y3s{%dXLVp)Wmo*8Iyw996OtX?xn;?^^|a&GW}_Xup3LvtctOm##U!IQyF zE3mnB|!Ex7Tli5X)D`^ zocpbYcz&?-(5}6Eub_41-!nxpST%;J8<%kTo})RcrKR}lZ!fG8iGXfiK73e%U-Oh< z-wAq|U-!Y*&@{uZpdtkpN;Rf)eQ+*EPl*+XAiI2CYEpBb?M|Cw%nDM1TGW2ekhL1! z^m;371MzHaKmbIA<{X_1SEV4+c6qOA7;BL;U-Y*WY6E=6T^4SdItX&hHFhJzz z-CK3Njk?>cSwbuLndfmfyUH5gLi)gbc;#MwsBjO!HfPvLN-%3f} zZEnSSy}0CLF9f~pQ)drpF#(L#8xt@^4OSMlJ_u4>+~#Us^j+qEa8_5$ zo{4nJn3V)UR$t$tMoUA3DJuk6t_v3)-L|dg*`mTvqMB!7H}arw(LEa(;YU!@Dh~dF zj)De}K8Na?Od?}%s)hr@g-e&#H;8ArZoZRO#cROk2RL(4)ewV7JzCSC-tPPF0*$wm z;S@1uDtq=_I{|JLbaKpKeQ$2gy8Alk_d_bIhDNW*@J!Gw2Jf>>vE6#^$)JVSpeW@} zgA$c%+{V2Dr7Tijc4WzTkYmKja>bdcsjLPV*K!ek2rG#eEoO*e!2&_F^z^2%$4$p} zx#FTZGa+mRca;^YOj9VF`;x8~)dYBW+=K&a?;DO`9!6_by82WT zk4zrQy#hcKzIrGlDVOKnKZNr5X8HhB_6rECs;BxrI4?8kyv>@Qrx~3DGLF=vSAUdq z?hknT5vp6TEjq-{)KPH#%p$|k^hsSeycUjIJ|F_RnEyBIc~heD$Vx~9^t*Opzgt-4 zMPJgCMn5ZYx^`jx=WmJ1+xChRJ>?L`0pJgj4BAF1rLCf3kQEmlXNXz0)1LAoGZ(o= z(aNDdf@V=>WTk-fvXnHjvB*{uVgJ0gR)1T+KFmB~i)7*4LXE{qF#EQ`+fhqD)h+eK z!Y)I}>a0O%8r?yL*^grjocfIlC`k?V_pe2)CdeBi08SSu4URk)``@0vel4X9$Y*iK z*~NG6-Al>Hc!TxZ%HEtEv2#^T4J&VYZ5wxoGf}uMd|;1-H0K8Hw*1>38}=PJqWIZe zD#4hk7RdBUwCPI`x6^svtJV*kX+gI;W=vPRMD~OKV`9?j`ZcQDYXUEiWZZbjkRNm$ z6jYw=+RFge!FZm7<)z|>o21Bs8*7{icFks(UQe%WP~{AF0VyWV9Z|VODa`qkI(!+F z=($gMiPTJeeQCYxMqzsyRoWC^0PUGzaObM)|~jmqD$>0cP~zig{E zQebZaC=dty9?xG}T55vRtB-YTF>x?1DG80MFp3QMIrIjFEit_6!3DOry12*)C}(wBE3!M&nx= z)g&NcJx#J;J+W-r88Y(Hwr9rH?IZlKqsYlVhC>Q940;u1IS1tu%s|StQ)UTWC%8SZ z2vE7V#y6zrJ$jo5c^)~0W=Z&u;`ZhD0WOTG9s!$sbR`2 zMq?4C_qNGoD_2uuMOBqGzPNUc9on~3e=jE^7^CeUm2QxEca1Gl8bEEAZk>`{AJd@% zLC*3FbnVQM;3uHlMkt_P(dAz$(evbh&MKYg!B(ueA*QN806^pHerxvG6DOEOT16{@ znh}6z;6#&O=L&84#54tz;k_owlI}47y(0AIN@bw`^gSbNV)(H`+EX zS=!c)C0hDXg&G8Y=!K4&o}g^@DQY!9w0S6ChBRz{ z{xq39CaA|LQ{ir#TyF0pQ}7wAJJMGNEc|I{$#?;vYtG{#p{2M*OXiwAS|LTHg0EY6 z>yP~|Kdlki2x@*lyP#m%1_w$5kn^%q%Os>(Dp58AVKxBdP&(9ntmo@!28g%=+r{d=(skR)oE&SKvb`s72Ua|B5A6iR~^%yqRm_TMr&;>f?u(j3Sv3-|=(BcbWNtpXD2e4j@eDVFDtQBeW?aYSu633%Qz z8Vm-(?}oIQ)QOm+;NuAK##WMkF&$(?#%?b{&t`{fX6kRzehIYm));4G6?5hwK~=e+ zHntXJ1}{)%Eu@d)lVJz;?roq+&2gSf7f(oOTzV0R#;n@h*qZms4?^L*g8e29`wm%s z5TV;#e?K2(>IOL54I997=A3$z^LVePDT}}+czPDJ9i8Is9Ve)gx`w(hZa-P(6dDnR z=L83ey$N9C%h}yZK}f#buz@A;H{iFjbyA~uH$r<62YM@u00fK@)Tp;uLqZI+)&a-m z5l+4(zj}p*Mgnu8f=dcd*~QImUSJ^bbLsG(S%&PNF4gkq-~#jpIGc9vlEx^L4y!}S zUJMrx12tklEBqRgj+!^!KC&79!IK8@HHmfaZ8yW9aOgAe`1~UXHl)kYt^dBqq`b!Z zA}k5G@jMAIIiB{towo2fRCQ4yL)S5OL1Js}rUq!_UwnmopVZlw334VnfR>Fv*`<%# zK@C~eea_DnI2THb-}Cet%Y|8i@mA4X6yhw3eR9CfM?I!z;pO)K)hPw^U{?tK;~L&S zS@Vp5X1$io(KOneQAb%{iZO?Z54#gAY{LbU1?Uwdvf*Y)%}^tkM(=@GPG;Bf zE;~{5#>5Lb2Fd7MJ&zo12;pi~C#95?DLKeNiUdCy; z)M~2Y=ckp_i-=j!{tVDa;JgAOQ!UWYu<+GQVbzV=CYezu`e$Z);?Ovy)#;-ShF?%%}J9N%Mi)g4Hf5 zaV$zWb@1S8+`fZm&Zn_E>1#fFTGW3q80G?*Icy%SL3f*GDDCUb50n4rk&IyM!A;q8 zZdz$ao6Im?0mcNwn0=ySIeCOKL?h9(3|ZRtdme6cvS! z5|#k5Eaxm@=PcuMd&eFu{xBR5#P^tX#TX;yOK`{t2DamiG-;<=AoW3V#nf<^;p%C& zTrR5t1KP=BkwiCiK(L!1BZ+DWL)j(MM$L0O`sqk{y6`hoZ_D+96xe0c5gI6s4p zJ9l=?G#}+0wfN+tcaYCo!&aR>aYAf67NXxl+54stKmr5Hpf&rv8nu=*sQXcbu!3aK z>s6-%hYuq`1xR2bnwRF@IMH?sh(=c+ZJD*Ok^El$*8!1wC}NtPwU-gJ1Q(YcEPGGX zYk&BUjJCgvOPraJ+%D58+#YTeVd~BuvY+9&xixKuBoGR{+cb^otw9ScnPI?I zBlbAbIPC8&6F(&Z2s95`Gv1l6`uO3)POF>S%OD`}`%tNod2Ez~IKHSFk)Z9n{bYE8 z5znFOmfcb*FtJ6*$SvBeh5v{}eeKa{IUmGj?OpFD_xyk;n>xpyJV8!YxWJh5W@AvO)+o3pMak|FAUnhCD z@18iQjl^w8rsoaxTTo_$&CL_tvU_N1M#sb?zO9CaW~h0h_l=3a$5~e+kIoS#EZN^6 z-`elLB#oZOf3}+#`acjn^KkN#vA4Hh+fdOD%_$Bn-fFw)9Q5>LEB)X@$w;+&_Fm|P0U|18nYD$54uIn2{jRkW&_m)VhHwo04jHn+LFabO zZk*(RP$MoxZRB}+6$6OWg0P0oJPbud{KM-<5Dw*_14usPCf8<8-6j~29jZpT>b=di zys!5`lWQWmNGcMFpNovmHteC5^cF9(?-Js=y zLa<|J;fN7?5awUKI-5N}#jRTW05+?Y1A}CF#_7d*nhL`u$Yw;H&MW0Z{F2tLl|_bvgn95Mgb2W?%X#F zbv~GzmRgw_5rs>IXeg|>?+T&nM!Vr1lJ3S5a`sL%D zNf@BAhK)J}(iKwfo_B&;q#kt#Yt+-2e5XbRI2;wsYSZrWTXuVzvJdw!2#Sy{VBS4^ z;}XDysYN2S!a zWkFydqmRGgD~q;l^d6RgHj?QiQfIDV$;BbLFBIJS{6;|qpe-Xqe*bv6EzHOeo`@nt zu6?2PST`z*H|B{?m~cpg{n)hmNhm|D7Rjj|s+DN6wh95|9+56Aop zuLTw)D*ff=&X2K~XH60Q8(o^YPcCRF?(LXUul$chv$#VJo`YQ5SwZPvc4Qqq7=K$F z>h6r*fG^bd=)YT-*r0%#Nk#g5(x-yWR-rxC_e# z8oGQ5kEYD>zBL}H2e2Wf3cMPD1Wjmwm8S9f&mp%DPF=NyA?L#SK`^w=3o4R%DOifv8AO-|~ zA<^T$x*Dk{RpU! zTK#pqdE0dLo9d5|noEz|qo_RGkT^~lZg~A#W-Tonc6z_DJ#&Srk38PI9M$kg-V@XJ zEteFQN_nh+Bb$Eidxb;-!=SDo7i+YSpqSbK1S_dO9j?e$zuc7rwwS~+wfd&6F4A`| zz)!*G;QC4>N$W8~))bKeNSwt=oK+TCZH^9oVA*kGCG#6I%16BJ^U;RqE;@%@bR3rN;ce#MF#I+~Ct505ce(KaqN6t<; zyv$Gc$stdZ14qw3@Om$da~NxUN$=qpZce{!(C{*aHl4}Wd{{(F=1`;SLm zkNjxuwpn(^h+Atu{K~g`_%%=D)IumZI75m=3Ju(VTqSw=_{2nZUf#cZcK{9n2NFce^A`0mXcl{xRv z2i98K4INtj@Zo;uLUv>UaT4+Ya~)ZtjNJD3Z)}z|)YXj#I{acVRhYhbD44?Z)EJ(6 z9xj-!mcllEm4kAOe^yo&Jr#;Tlkm%a-$W1g@ZcRMNJkxaVL}8}9%H`I82iT-Yieu5 z!D90?!OJURpt#NjWRvMf)dVW=U(pGkLz%(^emKsThe9!#(T75?=>YtDuhI;dDaJGv z!e)dnWe3)KxCEkb=;jQ@1fk6r(3;x@H;8JbFR7(Ie6X{zfeOX1&v*XGXH`|_c~%xz z?M2j>WIp5^SYrH3$ZEJH66q^M*Fl9~JI@hx?b_CI>F1>cCPz*jIYJ%nF@1U-#XQOV z^zFjS69~3H7-mt%r6HI<5_+kK9LzWgh>yP1LUAhcFgTGPG7e5)i6nqNSNo>1Zeu2Y zcH{L+K-y@#KHXef-@=uVpLCxpoCuNJRXIRPxd57YC#S|)qz$?DX$9lh28M<|PX2`d z&&n1_%8H8WAogBOJ+|34zgX$|!-w5H`mll;aYMxt;X_bPoI`+ z50cTZXr1zQ8!lRIULK&^$hIlWAv5#z-1=5+5R2Q4gTJPvFx`9^sV6Hi03WOj2M!oO zX3%%+LR#b^3fw|ve^wL-;l8Dnq2IlIE-CBp#XRrDiw-)|0%{J$Yv{-LLsF8tq}e+9 zNL)Z0o8PaVwJQ7PLyIEdaz>_#_65=|Bg1Xj?##HG`PRnBVd@vL}|`~#Af-C?l+2stI(al=(pM>}>BOXy#5{&f(62N1{B*SobQg66Z- zw!<9YupZjlY$%+8b*4a#Wc=g)c*~`5cm5_ibWg)$_)p{H%#;CF8=w?B#wRE?nw2IWr8X`sX$%FXR{_N9Iv9h-s|5@s!U50waOTt0`}$8 z!h-r{v`m{|quUha<$s`GdoJ@SJ6V$>g#p~blz?mBzJC|wyF!wpQfw*k89Vk4^?L0Y z{5rAWS}RHJ-{sHk>E5i0p|I(-YQYUfDBikltu*S|;k?zbQR^GV(2ihPcu;X%N(L`dCnvQ+p<{Q{KMz ztFlP>u{iIY7qGhLq?Y@JTbHtgHCvp$=dFn6>I?)G&4nz@)C0vCdWFyb*D6*Xcf@adLd ztsfe-{Ik@mh@*(9Drd(~ zk~AbC>x#ZpNSo5w7)ZbhF7II>_(&2k-0$o7`uNxl8@47eP^7pM(u(Q>0*-K$mXhMw zGyr2qw#|mJ!W7cOq6%ens#~V%&5>>l&u48pmYC$PEaBkn$U@J*7`*EN1qK^Nk1`cLJT}B?Hq`>#Te)x4zVO(9wVr=d zow4gYkPpX2W+lTIYbs0ssu9Buo2@A+dQw^~o~Tb?&@deyR923X544~{X-K}9vv8WaQr9V z{QrPW$anOUlmgv1qrghA=->Y~TG@ev85$2-vV_us<`Vi7ey%WzdMeG6l8$zgu?vM&#SiY?+ith!@tr#= ziz>mK@KI7;8bl5U?g&{nr!}T8A=TJe*jH*%%L)+CUo?HB_I$s-8=UCx)tf^DnXScT zKzbWbEe7R$zLsAuXOglF>rJslBW7V$jz29Kyf}#i=#@{%vlV6S+h?4Wfa`TfoxK%F zo{?7r*>qT1iX~Mo?&oUBOrT#M@j}SOlA>;< z%`mwtQ|0Y!M8!AJJLOhH*BbB8QMGdQw6Zt-n-({Z7nL>(4~^mPf()%aP%L9SJfi!} za&?Ud?-$iqFv8VTy5G~3Rwub-^B0;9;*6k<2i!D>?0WFu#_$I}MOG47+ishf#i3Wo91KDUZgJZy!ROKv1;fxNp zvZ_10TluEI8^yaQZ*W?Dbn`*q&b8L9KvbNjP3IjSJqPZCBIoPP%I=>(SBW%NEMJaBu8O4vq3*cVLankEl;(FoL^GiVF4yri zW6r`()Ui5tVQW%}cZ;M>amBR|=oHTa>G#J$Cy6B&u3_U@^ZBvrgsedzK%#H>pEC;* zS7*C`KggYM!eENNFD-0>}cC?({k|%u13tw``nKw~jq& zmYj0u?p@@4V%8%$to`1xQ>Q(~7 z#3^%cZG~)BbTObQk-yi{az5qN11?^~YTE2$?9j?(9l1dYTQI4LBP;;4&}MmG)19Gb zllgb}Y{ZX&Gp{C4qp;#I%lNt3&G^e?vmT&dR_T$tpmWR~=YM~;y5Tie zw-;zPuO6i2T$-^G=RbZKNO2x(H{^vuqn@OZ2!KdQq^}ffIPA;?t9Xz9cRde~YT)@C z9}G$tUn?&M5Gu~tR$L2~f>&I8eG4w6dST#pDm{A8S=)=UAT&ik_T1>Lx z+bJK^fB3V>Qqr^%B|l#0(aK{WD^c%J@DL7>eU6(twR2l?L_{G~XxZf5b(5T&I;Q;E z=>3R^M@mxNapcQ47)*;yF-1bw)B@1EM@$dUWPr+FTtVXf`{pdRv- z%_Nc!`vaXCffLTI!2sn2WIH%Fp_|bsV}&yeujjFp|1loLvG*R!^uS3yAl=yokUshyFU;EBdy?rSa|At0K!bVA}aJ)h^Jc!60i2 zy0Nz+T9-gxaGjE;_Td%k9p@{9d+labBdnXo!h%5)>oPH}!HaS^aZ&$%|kVG9Jr+B5X%p>MVkeV8c8>EEy5%SIzFl zo+yz%Ee}96MmK}Dy|1qykeqaSe7qzpWYsFG^cyTbsq+AGV7~obao6E$xl&LM#t91F z;yW-kqn-ZWrD4onR_A$-8S{~?C0HxgGo{gCXEC{28vL8l&$%*EI<*Ri^pH7zerpT; z^rYB&2<{Je9UOZsqRK+doqX${^S((Y|A+j9)-{K~=2|cmGEI;3I(717`hZBmCrb5? z2@oFSzTS8nO57bg7@WfHV3%1^%L_z(CT6QF6otLOEd%!Og@Nywz7tCdMDWt2IqQJ~ zLFMaTzn1SQn?FUD-VivA%?QZWoV+7}Z=al)85=NIg(A`hciwF`W5Tfx^Vl&o{pRcu zq3cTPiSo#JQC1{(-%61h;DqBq26JWd_27wC3{;F9ePtJnlfBKHMT$dqhjNOe zUpU4|&QNWC`52Z?%XO!38EH|3V~1!T<1r&1>=pX3Rj5UUC7;585=)*HyMMZjJt~Vw zWj)tz^_MDkEWUQ_&6yxSS$V4>9CsP9EL2JkXtw0<0b&oDTlIhn5H3WdYlize;)aGX zD~CPgxmh*}n~*bwRP@L+B`tp#RAPP3MpguAp}nPT<&ty{A#G^os)l9?DGTtrXleCru z`)Z0Hz|_ql^YFnev5EMeP~hK`Pt2=8)KD=z=uOvlr5+B7XmAZNC5;Sl6K$c`zal0)3| zq==RvJd^x2nu@5ogh8`aJ<7yvg(O5r#rgvxFjR&1;0QoQ_j~#1pq!q9-W#h)R%5UDi+ip4YDtbFdp)p_^8vhuig^-UHZIe#XQZ{(7 zi-C|09DjZ$%>-wGuyD^+b)*&~Tc))qFtiT<7VF*>%HMZ#SRgQ_i^6JsJdj$CHF^s z-_|4R1-WNB)47rZ_#tZb5KE{H8(N}8PGBPU?|13iwbQB2!J|(A&++P8z{K6oE(bSy z!`rJ^gn(F<^!4h&E(MDeH|RKi)|#NeE=eiyMC8zSV* zQsu;YVZ5j`_4k@W)t68n8>5>xEtB54A*dt~Zv2Som=F{aq7fQK3x(51!`rugAEzdd z-p}x*dbe(KAx(ycM|xuZ?CnE`7zXXz@TzB0=`X&ZUXMg&T+pYVTco|<9xo>@y$INb z<8k*cbXPSfGIQTZ#pHdX507d)ABIg0W3hnWvYnJR*3Tf@HGJys;KXn#2j<`4yFE6> z$Cra`?ydgu{)~EJJvZwzEgao#Vm)a=sG+83irN{5(rdQlk1i1v7ZsKKNF9crn^@4a z3Jp_eF+$J_%*pTCr)PLNCnu1O6;*M#m4+hY0cbz-t9%KVgtFSN0=ltq%q9fxI80$F zjz*1gk1HW8!5p^w?1c;I)>j`r`oYp4Fr^ciOETm{phjkzSWgXFH-C`XgxzBg!qsoA z{*0cDk`R5K&@bfXZZEAbv;@%uEcHDzFLnKTcLqIJSYRO__9wjFY^A5Y%Mu4=3D6U| z1-s^a>>QhhixWH%j6+M(5>X43Y#ZO_D-rk4@6WfhV`bu<0cJvQw1?oMOAiUL{+&f3 zK(i?5Xw4=v_Kl(gSt%d9rKlh~-K;3+A;N8H}F)Cc4y`EZBYrMJ2NLC6WP5&M5iU=g;Vm1d#){r;`UhxBl z0yMzy=}@S1n@sZ*v1B!>f)}Jp?;xb_c1&}Vqo_=7T)SpqkALn%YPDXe;cVODEOM;c z9asz2#uH~rsH5H3friL5=#%kbrAp&k!>I|hB}!U~;9gTTVRI?U{XG^eT$mT$7G@wC z$;J)FogidCcGHKNe?>9+>R;R%;FfVmwU>_EWjyxez0WtrKdz4GUnqi69kj5=`I-8! z{iHdD-ENAA`%B+G=!coq!-v2CUDnuD$O-S$#zrA~IOmwsLhbsp-;3qOq|qZ+CMfId zl{3U+aBP`y`MGF|YVxV$ic3Xke&n4*Nk6+=6+xEFoj?Eb=UcXS#x3v~H%`{l7L5~` zQsrZmINQ_4d;0W0=zSkOe!S`FRlpKK&VTeMZDh@XI||L6OP44XV{CAH_NjJ~CT)7& z+Ba`(!~)06BjKoT45xmdGk^Xi7%249F7l^a&!P2h1Pi38$9u>$gT_a6q3CCel6Zy&Z0ofED|(#D@TV<6D0~9=tptDU7yM|on8a0h zdyIG&j!)Pf&)FX8scsX-jbp;!Sp-@N)i$D0OYU_bg{V%Y-Q>}u7lnpy51N<~?CZwG zByzU=!q*D^LcAr1sV!v9HK*sQ*3O9aPeXy^od*Etl6uO3INqg?*Mnoz-FF;Sme59{ zR-`QRG&D^S<^kxm9mdgX|I3&FsQsrd1%n-cFMz-uM7{rd@N7*p#!?)Awh89%Rtk8Y z0C$I+MwJ0bANTs5?_Oa{PIfuCMZ=qw1TdEvy*|z+yNU3Ar@*TP_Ox4$6+047gXWkM6{nSPsfMUjC{P1DS z6I=s_XyfA19MaIt6vk=t266QhxI$?>Bl@dYp|!u^1v$TT6><_-K9{A^^%q*ny(~ zc@GbaKeTt{>#7%Sw65(}4nR`4VkomRyL=XdL`k3XLKE?f2$ zxl7iv%}^dBk?4yt)aV`M@pQ#5Sf zzMZwV^3OH{PcgED0s5O152#n=KXT6Q04rVMCe7#nYW_{8o?vbcA_8{^s+}0em z{lV3zOnD3tN=?6-Y8bqH}- zc15t<_MSyMF9;?w#0FA=E^DG_J7%yLugg-$-vkkt(W9M2c|3+F)?RI|_(%hKDd!(LQFM>Hp(CMyWzG(YP>*=cthSNz18Wz#}^mX+u;9P#+ zYd?Ox{V-#EB;F1aDP%FzC3u#7AD-$4jS+Ffq5_gl(2KTi5*l9J!m_eJy7}WRDZWQw z^dNNQ&o+tlXw^BCDui^8g5W(48ia_y1)Lx#$QH*< z=3P_oN4ZUtqT$bA5{dh9qg9ke#Ro~mdIlAFQz^ZGl9u%@OacP}Na=WgHB&g;XXtQ0 zyC%}h4%^>|tep)mOy#zz=3zxe!4j>EKs_lIv$zrYSXbjGLeX(0=&_Y+>i=;~mhzF^ z9V0&@CDUJ?q)e;C3R3kGi`Cx$!_trj>dl^-ny9>a&3amdKk_jZk4c-3Yw!O3KVr~s z5-SMe)qqpbc{&DdN_Gp|6aYSZV~-Jd!*#H-;d;{OX zl`u)lX}?mFzRbYOG=;>u+o=hK(9-L!ug8O=NIlI&k4vhxCi2-lC79ecK@P z<(|ZUycn2;{tE`(WB7*5>A05b`B5snKLO%|Dk&rYz@2rDK7**q za}yOncnrHinL(|Y%^zJre92Lhs$Ws@9rE0I2UgM`k9o2}ky&W>f=?L& z0m_m5+Z6L5pjVCi1jg40qp=LnOGyf>@7x$;gCV_vVlgr%>ICK{TXM`LqOr{)9od7i zOKmbgu4Xd3<;7Ot=fWz1{>}Y_A@Nb86mO`}n$)0hg#%YnQxlv_0Ji>C&0yhVJ#d{KA@hG?e2PNUpUi(*SRQWw_6_APXudBPi1HK>*HL74tWHLt0nVg-l63J!F3 z7P;CiC~s*(n=ou;*eF-mA(Wg=O%_pw!mv1f&!R=e(A`D2pE&^(F~LXs?6qN=@w+vO(eKr*_Hp zEd?T4PE1^lg5LDcqFVw>kXA4uhBPPcJF{2!^7Y!;gkUVnNo;Cr8hrFrT*}+UmPHhb zFJI0k&T)2dX3R|L;_gPVY@ngP!3o{GUXeGCo(Dy*A6Zy~P6MA2?f-xQV>S`ZI_rWci|@gyNpq|e=g(fiU9CL#=g8C>j40Yo4fc>_Jk5)We zIS1{kI)u_vY}zJbQta!&wW=nz7N?w}pD>`!NV{?a8RzybkJd#QSJ7WWwhM%D@_ zuut>eY&cwW{20EfoIM~dcs8+dOu0s_WBc&0z#75U+mU?JJh8dkr3o?sNu$Y(Ew5QB z2rUUHHFPD`$;L?n#r*hk`0%v#22uf_k6*0QZUAIE2?Dpz5bix$GjhUYjTmgII31Gk zjeNR}QC<@#?n588XHS&BHv%f$uy;j6Ls0SDsfY(n8*XeIQ|QOEJt=}lMdAJ7!LUa_ z4J|%FS~wc5Zh9WUK?PU`SobpR zCFkxht$?~ZcW!WVCQLu=xxuS#zvI$iykKsO^34H2v4qf(ungk`OdSMti6Iixo0XN# zpm?m`($!^z_#cF9VJMu>fJ*q84Ea2j5jrY2^aRRpQ$@g^v}x>%(8XdR<_U77e&=Jm z*;I2EpfL)hziDBBDhHgla1;@x)2h{LJ(Ne)pc1x4352wnkqAIRf?!FfeQCY^uj8HY zmq?B9b~cc5_vqC-R3<~}6j=~x;+^)m-v7OjNzz%@QaCLPLIQclEpIQ^Jl!m+aN`sw z{485p+9UM@VMq=bER^h-)=Q8)WnP(~*`vp%ht*cDQ9hf5RSyhruDdyVs<$`YyvvB_ z`E0}Gjxz&$@zNz=DUIi%_C~Pg)Y))GEpxbY47~GNy)>ltAv0#w@izVHyZ;4W&Eq@& z45vPLMn$gQ;?AQXOwx3Smr0Qj+hjhwdsme?Rj(CYTwN=izxZAifO^i9-rVkYjtRXJ zaUZcuP%aicDWlq1C)S`68Z_u^Zmw4Q2P9U0>OcQ1<9Kg6v*Bl_Yyh2^Ge-NiR8Wu<;3Ev@7aSkeT}{ z14A_-;Ck@>5kP-1O-lu|pbaXQ_1Gx5SEvB$;iJeL%|MuV?mNRr{2*cRBu7}-)MmDg z)uhj85aD;}{0hqwufO2L00ppv*I`fd*54B-0-#=EX6=Wk1Yb0Lw85U7ga{tD>xXyl zwD;AO95{FGI}<-V7E#B|# z?L(rJ0pI-gySAQY_?!!Y@w-1h-2LT_&V>n}Ly z`KAf%UE|~`&pkiH*0%H0y~RQE7cDZ5nM&WqJB+J!%KiA}%}<24E}`e+$02<{iFT2N zVRPn~9JxfWCia4R13*1HFg*JIq1Lt5iT+h2@m*1Wr+^7L;wrPgecj~@Q%0v}WW+>A zgM{QKsA31z65u2(8jb#@#3k6MhUJG+qN%`gA2@qfJz*lL22aa?(Np%t_bL;Vvt_2; zOPeW_B9FgAIU${Wecd?pc%V~!GH7bl@!*QRD|KY$WnI`Wi1?g%)%pknbyhWDL;xzo z{~}@8pPgM8Mvx3dU%P2xEFWtXat8yC`&6yhuXjEx`0Yn}$Q6zw8Y}flHJKq56f=)r zetx0Jq@bUNN0*-;C%vO$?X__7ND%BUiYKA!*wdo07|p;gUUg$mPrP87BQ*pl?+@(? z6VmZ#2THBAMo*mh;Bveea`jPF6$nCMan7DSq8(L64lp8JB0!MPoW;_Iqg6J3t!16m6A{sa&w1T4ALqke*+tKdMH?I zFno}uom#?H5n(ee^-tE$#DoNFM6e8(Uz3J560sS662;vS3{o?9?Q1TrpiJz&W3<$o&dO3{| zB3illRefYnv+9d)U^MpMAT5)v^B6`Iyv-w(29{(yj1<4STf2veY|g1dza>{Kpnh*% zy^3>F!nAS^^BjEko{arhzTp`h^!J1?zZ(pSHvzL`Ey zzEzO8)z+TC>I08e{#Is_i!eu2Jp84*&lH9W2mT8{QCsh0-^t&*oi;He2L5!_K!Dqp zoA5*M0u^Id0hvQJxH9}2@GDbw(#=Xv03%4k6=JrrRFuOnQVq_pRoBwWMKJo> za+^4cvH*SR=z)uk4|8mpX4!BpF!_JeJ|$TI34~CF>St(lwChR%!D(>xoGNEXPBnTc zW}M!ory<}pq1gwa1z_;VksYEX?7~%eBu^iJViEAG>HUQW#*c-ji?f%<|h>f3fCx zM)>IccR?M%(qs68apgrsDFZ_L&b_!c7sF3Khpeh#Te{)~?-PO4YYabVXg*^(Jha zM@$G3g=w2_<8Klk^9;S5+wfgofVX8;$y)@TUobzB2HrY)?3ger2F}cWHO9$~!QAcG zNZZV*r2pHlAlu~_BUP)3aLnLSNzqf97#p|WM4t=bvMoS71>hD%WS8fg-Li$12^4PN zM)_n~@J_Y%b=E!{f$XtA7BRnk4;3IzRm^j7t??qLr%acI7o41R-DuI=waOQn1Oj}N z1X9mG_uao|k19&9fNvPtfms4~K-z1>@8mQqCwN6zxv{=cS{_gJUNw|*x z?9+%r`yCq{?XN-8V6Wze#U?5;3|vB?|5e@{5^cldS*PF}`H58@a)tpXyty9+v(+iO z;m-m3X)yQ`b!(7mF=dAl^Att9_XxoeZNEp#G4lKa()|dpdY0Ig&pgGcpq}mK)`=nV zP?9gT`6lN)P*ds?(n-k6aR-#}jO>y{ugqg=pOP z?dIMwieKR#Q7b?Jngh7=aV4Ui!&bjUcpzRDQ>)&i2V++)F_Z>R=wZ~&y!D!m_tlN( zWwRrzx>ikL7Jwy^6p|4rm+~3%b=V$_tpDZSFV~{$TL|v>FA&0C-rl*G+Aw?Uxdo5v z-oAysc9%QDzlxqQ0Br8yEa~$x=RtIUzJYOQyFVXt={=K%2PAf7ZfuN^at$4bDjkoAs>xTQMOA$wob#REV52PK0I7Y38 zHV9A!!FtS0!9&_>zvhY8{5FX1+J{uE>k<=vpuX|KX9xnJ6Ia4~RntgBiBOcpqAgD^hwnU4@Gor1sZ*+8=ofZ6Qp$NcrIbXz*jc-36pCd(X-hW!Kj4&Q37=4!H z6+M%y3>+A|!4J+79TdR*4-E$=29wR26+)yNks~i$_?|8uc%3=5^3O_%^_SaPSM%*9 zE(8V|a2I=4F1lM4F`~wSoI7MyE4NeB;)!E zfQ8LdCnA!}v|H1o<4pc4)HKNt*sTl`er1lPNtpJKV7b}QpI_6`(tszaED?BeGJ&sv z+(csxrF^%tz0xZ)v~8A(B&P{xpt^!Dm_P1)f_+D8 ztKaw-V1aUpngz{~N!>Bh*?Ghp*>vzxut64_k{EET-D|VAy*2_8Gn`P^ zfoJN0mIFGhv;IIdL?AIC`tFJ53A=$dO%%9NGR zzpGl98;Q!EudWHjeqTDZb`du|7VG?5H+&_Q$GVRpt4FTOt zsd#yR5qDW;fXkuL$8c@}LrEwnDTf%f5a_6z<>kE@pB%kk7%^fdiT}%dHRDH3pFVA} zc1P~h#x`AL3Fn822FZNQAzGs$cLaN3L_JtiF&4V}tWb+QT2OABs>-S>!HP+U@_OEO zttOEI-V8*6cdtus_t_|lm;&VL=>|KA_#kW>6%cBTeTNPKSluJ+6gfkz@QdKza6GNF z9*M;uX#Ca-7;VQdzK4}L(CdOEuFS)uQ=OHjF|z{=8}cW^3g4BxH8n}~=lv=>ri&x% z&+!r(C)#j}sP0Zab~qv5qFX3J4&@Ugrd=D=P;W8_ZY1`c3)3 z({qb=B7~^*`L!v5CE{-ShQh*dNdVamSxS?K95+T7`n*Cb`G#XuHbzAxsC!@xk~1r4 znT5TY@!fY5_3b;GN-OvyEut@Y{CAvG@eyp{F2ye68k@ToSlnQb2{%9~HtH46a=+GZlRwggL zs*hzFOLY^J?>u-Ab8Ow;MibqgAkieE8>QY97Lp2UQO@l_-+fxsND_BtM&X@|;#%gH zIu%>|RcsL&Rvw_W6lAP*<0NtA0ptXAeblE|ki_*@yXY%x$T%=|)1cE17S+^Ldj%Yu zReC{-vBzTEY17A`KB*Zyca9k!a(T%A{~A+X4cgn0=xM`@Sn}@=ek*{xxkVjTIF|%# zxjK(gl2!=FVfj#r3$v;?bd{IKUw=U%0c}Nw+2ckDf$A?J!U4on?mQqko0s@s3&oyzVE5~XeVX-+MWQTNXN1Epbs4R(IzvNb zEZ2}MiTs;B_R=aP7Gpx-g1x_*LSp@Ku^|ShG|9KnYOLbt0U#1qxs8aKSpc2W$)C&}tKp&ScUxpTV zl&Ahj+sS(9P_qdMP1NIjgb^ZPT#c9iJ2|f7B%r^hDpoLl$KCbp5iou#(7qVrQC1Bc z|CTJkREdT@Ovh=!-cQa3MHP&!<}}fdqZ$?bkSQtog~Kjyl?{RpEuCCE1*=*9IMFbv zlmRM8%kI@H+Zi?lS@$m&^~z63Nnyo3RpD?u)VqScENgB*l|g>60gMg zgY4Klk019Ci!1NlGZM&2<1st5baj)IX%5(NcF^%aJdbM6o|#Y^Xm4PzO)@^>d9?WH z#SZfR;Ftq@yr*){jN?CrYQ*{0&uZE74ssJDL6uoz9aubbrBxfKo#Zh{N{Speenq zOy!*r7xjx2mLYNw;$F8m7PjN?sJa|yI_MNjOjxkO79c!gxN4o`(^yQbwI?- zKDAABEb?mFa8mSPeImK;H;b;oGFXcfr#9nCZIEm7z;h^WpkTAa49Nb`J;L3qfUC{@htko0&8l7*%Mz_*X=QTuMJ` z3&J9B-oTAdDF)fze(KQztx|laDoXyb3EwKtpBuR$Xxg;urly?3hu_l0{!n^8Q%cZ) zAZz;eZA+`gz#CcoMF7h@H+1uJclOf!pBA9@$%V@g5|m4u`B&A|)1;mlZ2HL#N8pM? zW#LeH{Q!S|OQ48Pr$yh5erV9ckl&J%#e=pz{X5SxOURK%{riU_a6aFT7PS9CD_fvr zMD_ft3zqA@L6XHSb+FRiTz;dh46~pSsud2(QH5(arm(@J$uw1>v{Mq193T)tHWxdj{mDM5mr`Adw89)fVw8F1&Iw&w;<+=hoAI+ z%<7PXlw_DX(*U$(qJ(t4Svu;TQoCGCB{=YrhR)7q$b7w>IyEyO^*V^APO)6zM zD0v5vMoWyw?&+yHUEf(EH5~ru%eZ*or#;QBwC`0`#&6ir&8^--aU097#)sa*Vr%Ha zXS6<~tMue#eFOQyNbP@{0FvP<G3t#xBkY_l&5I5Y@PAHk~=m0Nm8OIE_Ww`?Eko6La`Kmeo* zHko#nLGG@u5t<WDmCmu<%!G!nj6z z4gDW7JZ`uouIoAKhGRr`k&`IwdEMki5jTSor#EI?!EL6noPIIVdbUAlTdh(`q~Ca8 z@$o>W+~?K348DLwqZq^#BA~-%H3?Ky)6k^2IK8U1fi@w(Tbc=9P&2RPhjn&?HKHY< z>6@4!$|Czg4P^OtvM56CRi##@p^r}b1m}Yq3q=prG)%bjf!@dT4CRh{%~cvvZyg4r z#KfUeEc0}XM}n>N3(>HU=p=n#Wm)tes)7#c)ytQ#1#2K!CAy136D%N8Y&7*RBpk`h zTLT&)G$Uc)ZhRJ|bqu7`qaF8z84&sBA=6#nEMqnCiSVh0{BIZ9o-(j%-3jJr}Q0(1rgg?JoEoQqRu>^=CptRmC|WJ8;Vk=k|avDB$Xpc zw(KD)lF+1L$eK7x3N0dQLiQzl*-B+9Yu3SpHe|0*A@zIR=J(8Z{(8nd>YUH#zTeCB zzOL&XwLeiCAdlIrsr+A%=C7@7HbRpUYv7kHJ9_9)#?zqI(fjuA_YVj_+H_S%s5+fVCG_NMj5q|aR&N+hGb<;Q#?IO-@hLoeCNibp_Vh{<$7m# zQ8DVBaw-3!4H=x?NUU^e(>e6WrAt!-<3|l1Tmg50*e6Mg(HG_d*Ce5a?XOAbMjQtY zyna5#ae0HSvgweY+e!DvVcuLBg$^;nA$$eDt|k{*Ud%c+iN+-Ymsj7J`WCH%Elu~6VXCT9kjKAwt4^=~`c-eKZImD7lAW7- zyN08Ln88Y|Vy~-9Bq^3fq*1f{&_?03T~osx6L6LCnwr0|X}dqdHJo1n77nj%hbtRw z6y)p3L|MAS|D)Z<$uok3@A%qEgf7pFX`S-I4Dw93ZZ|oXmmSxPurW1AaNAe&=Pa}L zRJfi!gLu+;WeR*KgUY-jRQTGdqKTWKX zt(I!5#bqFlUo@01n$f(i0oXDqKb(y9uS$Gy?so{h;}0|H?}h%Rx!o)4W2(eVr@uis8iARonO9RPW@c}9=5F^?c(W*_ECOdGgKNpQO}XN6*XKx zX5#$A|1f#M4uP|b)S8s^ismt)!vDf?SzQn}nZ_1|6Fh;4h-?uO}}y z;`UB=W^tie-U{mj``WT}a4(bE6Un&1UbwI|W0$LhMSB=Se1tP-k=Y^H11F65YEFwi z6v4wg>w5PY=TCc!J7p$jTb7B!o>#^ z)lOanYmQXwdH+UP2+5K;7TpCrKsKzT@7{1N;EeP4N?or}Pr&c(L&Z{2fK!k3`)?OQ zed9Cnq;j9Ic61?L3$DKIe@W6iO9rP7SR9N5H}JgGSmG2-vG@gtEuyqB0*AxR$E`c!P|{J%BGqfdFY+9QWl1W7TvuYof5`ftExIw z_$Y{=W`7yHDJwH`-N7}*+uDBWMO423ZrUHSW(h+2qsVcQQ-w+rzW5Lc%(Wl@Zx_|; zUQgga$WESQGz$I6BhWTtA|P0!FwWz;k36@#eSlc&3&!NXE@Su*pi{&@os5ilSDu%S zVcUsE-=mt`xD51NI~Og21VOhIvOA-oz&6OLe04xaGx;yQA9ssMKhu}K?c9@1K^r#A z)-VICmRDb|tlY04ls?S7sTnf%LFJ`-qh-$B>I3yj%nHA61whaW9%jmnrWden{nTNR$MU-@>Juv zvcnPrqBU(lgzk-pOiNJuL8a$k1VP+sa`eJs(8Roorls07D%!|FM!hVS?2_X7KO?=| zbEfv#e+FG&Wy)tyLYzj&4`1kSHk~Z*skr$u6s!ClSSfCu80WH|f+Vel@dh^}w<|x@ zV8THIM66VA)8@^~nJnd?0ZOwkK(akxc=c}1Vna)EeCpNs_ zDM2U1hUn237m975q!CZ`!4yT1s0g89!DR;yQ`*tSSOIuqY-GVwLVUb%S#CH#OcO0#$P>T29xfC1J+Czs) z+~6_on|FW`pS)5hDDLuJ=6hng%G`$!@5Wk{ur5Ci_BL~S!iEy%_dd-(CM{X=J#BEb z`-~o~IB;vNv@iubY-QDe>hl-78t^v~vfzR3m8HzB9Vq0{XU5cI;>Q58f^Un^IXdo# z&ye<|N0cAMYvZ`KsNamOtLY3zh<(4`8Ula^8tT!ga}Q-H*E#N_71jy0d)$d9Mt6$R zB41HS;L`kucuk*IFQ?MWWFfSc9b#Xl!*6?_M0YJ2_h0NNi1VynoJgIB=$;A_az9?M z*Yf619<=Te?x=^YSy`TN^>{Z+Hc;}d%FqA(*y}Wj|&1xdP76YNj!K)k}eVy zl+ahUHP&mPkoWS!>%``M7u{bzZtMJ>;8k|uh|3S3c&-ap53pt{517cgxt#HQa|qH zmu_A!a~8`)5;Mou-lY4UG^wABO`BiQ+7}502?m7yy=w-@4xv~XFaQuTCE~6Z(;>D| z79IfD-4fTWY2p48C+>V+zcV!z6nvQ89uhTs@N~Pn(se-?rgQ@>%O4r;>s$Kd)Xa>H zLHfCY!AQduy&0<0RD_|7S=D%+Oz@Yufl4 zM^`j(&`g5gmX%3;AHhlZ0{dK}S+{1u;mQBm8oV8%R@a}P#{M|^obs)SIg)ELcNJ8K z1jKe)=TSq2Rm33m7lnm|m^kd7x^@vB6C_WrqE#{>+bUtf&TRw?-{*t(-6J_l6D8$> zJk-oD6E;WZ&hewxR2c6XmD+FqZqQPCFha-%w_g4F)z{Untk}>GSzEW~dsBG@i~@Aj z7E`LTUfq6Un^vvhK@5+3)BXE5auE!Z!8-;DI}CJ_Y*@s6bbU+=ii=jw7u($!2tNh+ zn2XFo0(e&TGLC=$T3`P~*PE5Ta7-mVnE3HVv^r;vdkb~Sej`pr1=M>`f>6_RUf|fe z!*`b;QKQ|t(*x%%mR~Q%sL`O)MZBj`RLBaMg<37>GIq|7I9uAq)29Sp$byUvO{?I9O1 z7+Ha4S7vE$UYWjix=uqh1wLger3(+Gy0~AjUSg$(f%R*)hACIQN}e=*o7E!*&^=5{ z_E0@v)Igc}Wk+kN93YTkEC@a_wEB<#bUCowno@Aq*4KlKoSV2_U<1c1gciEmXP@& zv1~1Nmz&>`tj*TLqYiP5Z9e&GW$>D#L|Zze-<1$nT(i_RRA%ei=Ca7*kO5Jz52KU2 z!tM<^fnN52M2_r+RBh@-W`K;idq$WF*?7CBi>|pJdZ8^vRFdnn#KtU{4c&iL7KlLk=lZ|(XwTt+_vT>lVwfexZ104U+mCTboan6 zhsDEikeU@tABN2PH>nX(v8;#!Yi-wIRFbRGi9%qQve|OZHiJA!1MvY4J$ojkrlxM+ z&THfdVTY54_d`_SpD)mA9+oL9Z3x$rQDk!`mEx$yR>>cHR2c>};PK-vUeOs(KfqhL zO}Egji!pD^x^)WkL*$;|a+H%=GCg%c>Q?$P4Guf~gCvcaKY(@X=rw*%kc4+V5Vo|h z$A4qwNF84})20VSYZ1(GK9~t;ww%cqF;_cS3(HZg2XgGxrXZ9b|7Kz#eL9XP~5Uf@~m^)CM_f#U)JW^E3hRuOShNC7%^%*%iZ#UdFq90-Xjs6{E; za#J0n{CLOylE#Y0v;LGH|#5 zCQqlL`uy1#LrQ&ppxmC1o}b1n(;>}NK7HB8KKH|X6h}r27WD4^3j)Hz{#5`bn!hb- z+`e+hGte8qk<)$snso)tmtU`{=OFWiMp~yTEltDX)Ro4CJS4?>WT0 z=9bf`#7FACx9HM^*(@|%nz{kV9UUDT(I+Qg%(ARPAL(L4PY2nm*CRiE0YwXEy_)4&YduJSK8U~{Ih5sB~Zt_gGg7v|+IMgq@06`Vl1 zNdSgp^2%qt#8#HzvwrQ`A#DH6e~{n>>U1%p*(tL?W;80e7-%iA4cT*e z<XYh-&1Gg-i-y7Bub z=jt?+E59=WfWf~qb?WEByAt~B_SRl5cSfq(%G`6c0^~$@&Mp0z>#J^G{kkJ^eJnZ| z!o}-;N2D>zR=ZX7F|>7K?lo31fivWC&{SZEoZGFWX9b#ZbIY(-jcRJ6`zgAC zHNGq^t}c1&W{YSSQ*Za2jO=Z6Yp~W)@0jQmZQ8WS*kPe1F$$xkmoHPBbt@t^i{{xu zou$=&s(6)&@*SL@V51x)~`@uLz-SBq4%@ATfbhn-J z=26X2*&Irz%aW+6Y0uYXK;HU!;=d+hBXWBLa;q||w)dOg=~QA%09iv;W1x~Yx%_hw z^D`=3=A-*>7+3xcE1%)-YAv<{b2BiCiM*+nXPYQL-h39D5F78zRX9ThK|kkLyBhl7 zB)RiUzz_V%8d-hG1DC38O%?l0*hjcp@eT?ih%#{0#)rQqBKUSk2R{~i-P5L1)kopa z;NiyGlSoA+Gs#WeeU%8C_Wk>7KZ&{>fHSCOt`?C+C${xncQTN;BMMmE6)ebeArH7( z2RNK~7$HVF{A_9?dO*9ypS&Z-8FlDjdSEIju7X)*bC(=fhS1JUHEkjzsC^bOsz&?G zjG2FkFf-MyS?due^PHnrZ%X-uHVEKDbf->nKzKnEy#I8n$ht zBkgbI2HtoAUcuy55^0AG=fP+aZYI(ivLsh2S482lC;!-$z`YnXYGXpey=DuQogVB1 z1-7=|sNq`1n}@ecWGFqFI_j~~hh5m1oDX^`YZ>uppz4k-TVCV-9HqTz;X+Atsfseg zozTL6lJcKAk0x)YMl@x?BxyDpw&z9Y_6;Sgv>75^2FLgJQUczC6IlQ48%et6pH5y= zrpO|lbgm0ut(NztA=XO4CP zd+T2j@44^dl66||L%avJ)@ao#`H7{gdO;0Co^PlxlpeIwmyVfUa_?Tx6>gHJ|LE8+ zDT=-MYBz%sfUaJ>ga>1ac5ZZx7E;vq3)TpG&ZHt&36oK^spwRA=kwUhmuSd;OJT<(=~bIo|3#Lj7BSz!>$#ZvTAW<5LynO ztgz0%#SDe_?b=hFwcQ-CP$|y#H}Whj zc(-gJ%i*}L_*-xT-#08c2d)FoU`O5~Wop|t`S2&(Jy3Hk1BOV1cec*K!h_WLL0C7mWs*Hf_ViDcX3INTwsZF ztNNE0*%tzC2*e)}INsi1>qH1x)n;~Hmzc4IWO?T{>4_jc#V>EFpPS_PXIDEJ%41>g zK4bI+lrgey_#m$&^eD#8Cnh6Pg$A}-5Ws9bhcGJWOD_ruvq1-52o^txO6Y7G?q;(G zkV+|M4CJVpGiI3gx=5E5nBm~$g!y{;r%!j9r~Vc((-t)kKZVCVSr|KE0f9R%{kl$; zM*m-JbJ3(;Wp^;d101YV+V;KP*e2M(OwX+Ejuog$(O%G-aQqbA-1ps6*5^U(ZP@Z8 z=Y{*&0pT1R)Zk>w--b1Et<~+b3sma@m~+RRiP)VpNKhX$Gwp3{t8M(`x!b%F(Y_b+ zOqop75eJwig-FeT#Pch?6y!^Y^CTX>GuYUR-R{JfM}||eVcY}?jxb_EBQ1n zSIHs#*N;CF9<=IEQ{5|f&X$i&8|FVG{KA#(#rX5gQ&ZtuK2Te(mnUKKvdN2dutbG% zJbK*5u9tB~0Azo`=Mft@8n{Q#GS&Vfh7rNv4>w*U~4 z{!=WnYNMuBgMHZ7D($T|VKuiqaVmZl$$fU}iYTq^NBS(JHS4{U8A3^h-sT30>I?Qw&?7MlFk;+M|yr8mLT80fiz4m`xfK`Ym0+IHOZ7vTn1aSm^gl?>z z5(j7pm{7V1et`%JqJig^Q*h;%1K~iKslCeTsA(gm>ET) zUm$%4T^CQ9>AkIqiA#e5gP@geUK1uR;jIJ2pz~yHU zRW_gASpd;BBeR=iEQ4my1*Zk~*7g1;az$vkW_yd7cjj%_vkX;01b^=mR`wQ);Pco2 z@M-?B6(rU_s_x~X%a#m8Z}pBp%2vDaDW=2bKh(lX0oD!bZXaA>~`t+ZVPP5 zAKq^6Is|B+%i-FILHV{ar75JA7H3o1E^DNL*S)#sus-`Ar22jU-7YQ)MSuK-r51fU zu5@mWp&^ER3p|rkcJ4GUd5vdN>D6d0+8104=#eB+YG(LXyf`+heE<2AUL|(%!>y}8 z9;t}GHPp<^bvKkg0`O@hyWSA>1zt<0&Eb26HjPS=rPu9 z3(k$J&YE>8V*a&$JCc+8v^CR{$VMgSGsa;FgY%@UsT=Ni0o>=@xrA+^&1cn!P$F~r z8SPUB9DMLL|Dr*jEt+Ylxa%H5@nEgjA69uA@Z6Dk=TL1xBAPKc;>`Bo0_NJp&{@d)ZR?pT12<)fOiggzLbS}=n785MyKeUapwRr$^a449^rRT7Qzlbhgu*r= z6Hv?M{iGRc{_%IV`?4fyepv|9^W8gkbjye#<2Djq_e`BYn_*+*3MzqbZ_rl#=TkJX}Ov0*yCy^?rba%v8PB8>#W$l(mIRv9-NkPMkKF@ zAvx!w3M#l(YF}@f%PShVG|YRnWwhyuFQ<+@{E&p)oT$wmMRt8Hj*O&`be20H${r5( z)oTP-jSCr_*DwKJw>yELes|V89JM+b17&>PW$hRXB(!bB0hdxusqn;x{tZQ#snSqa z*ZBSYyKSsM%(C|JAi|Gf>*%VXmWkRZgd!rEsgc$V8~Lh_l>rqymWm95HS;K&9UUEU z!Z_&Qf)IUGXRIk{uXyQaduflY;29b%|0c;&1W=ui z>p7|BPr24v5=hG@tF)HZePF)F@S?t=Tp0T*;1mD~*%4r9Z*<*DJWU#@Gyr6&TR{QF z^%Q#(sZ9;c&=Hh2d+}E&0#rJDKtED7)ZJEA^EBODyBb!nsqKjr3$x|HM$%zCZ6>uX zdT2znano<#z85ZBJ3`w+!J~!FT?Jw5oM)|={lb>p} z!OiUWsoOrC|7TtMb~Gz>1U04fRLil%Wlu}_)D`eGXjL|CYBT0+@2UFIW13K_nybqO z2X4Rr{P{GD@Z;k>$BbdUx+H2PiJA}({8v4R-@s#m)1)*!nrE^%^#(v#l zzaJx56U>kZb{81_NVX%HUfuDM_*iRlxper3Lmpn+u-)AaHQR3=n;CZ7$p0DtKr7Bd z_Dx!ES!0AjW6Y*acVTB@R3bZc>J*!alU>qu+HESh6!uD8uD$kBgBEh&8vgUfiiHkF z8NFE`GHR65q7QhXXrG$A_gO2^?Ug8?9*TdVlH-zuy z`__azhD*Zi4G*=4(()=|FOK*$q1-AHIrWkgv--oz$p?xgT-gSLG=*BQbg=1`jt@v* zyLSC*sHxz_WR{WA#cq(w5q_Y!xZk#P1Io){`Do3qRkWFmt+ZsoxRtlwet)@H!AzcG z5Md-8(eF?bd;M}-?!K=R@W4&{GEruUDza)S*l;aIgfSk0YK`UVjAOsz%q|p{W_RjuaXnVBc zu)E)yxdrIhskw0h=eTU8d|Xea->6|_9ag$o z!aeMSI1K@3$Mf%qinv@o1+Bq%j>^j3&Thusx!Bo{ihNUHJxgx;JinLBZ{EwvR>-XOL7W~2q?uSJ_$nLVj_>;L7_xDZ3<3?=LA&{=YjL zp^gYl%+?yn{PMnC=R8|$TU*AF6CKUa=fhfal|YI^`$jMn=Bj zh^kF-J1u8Nmhyaip`VM_V(OlV(PUvnm!VPBx7Yj2tN+36i4V~(R^?s<#^ZlMly{~r z)v5cbSIniNsb06N5QJUD!2pDw;C2A%SL?mHW*#*5JtYff%-D;G4fkV)R{*Bb`mMMK!?sSiX>Q`@2KX~~ z=FEL*X+3E{nO?1#?2zp7oDJ!Rt62+ZH=h^>4EKnKc@pG;929 zkj67i_E@{+ozhZ9m;poHHb->*FX7&`WT^(dvDj3A?!S|+Zi`tC`cd3T{0U&L4Qgn4 zv7fd|Pj@}Dp!xIbzkD$(TK$@UviiQFJRTJ6NY6C18y~?R^lNr5e5FO`1EO1&=yiE ztvxbmq_zCH`?)j~zMHv)g{hoA0@Ka!m-1t0%lSmK3PepP!c?D>yzrM^v8D`evirOP z9)Wy2QeZ}xp?j-Lq+EPDcR&#ezoO9>oV(bj%KxVJ6Rze9T8!rtv7giD@6HD7)WazW znJ_>Ud|M_)L^l`K(ffVo%k%TQK?M0{QdQg#^;-7C9a3|fI9D*s*YMuSXD2m ziumj=GDBMg?a`9)faoWZo*9f5Ij+hIsH(coEwAlbOxU^C3srwfRJ#;OyuXQWx z>0CYPT*UO4|HSMz%~CPGIl>@~a~!AbF{apU;rU*uWmv$-zLxc;+dOviR04 zf~61&;Wu?B-n`rYUC}CPE&0v^!n9%f<&@@0o{>XchjZZj;X#`b%;>xLc>KC1_c1(G zft6jJem3yl)fGQM2jRMMH95Mpo_unX;FP4K!LAFC!4Nc{Myxwwr6kriWJhE#tT;%G zPt;if62e>^Y>z_)IO5^49TNBjqSB^=Z+NV1C2JxE`0_A$@Zh}UOE z1*f>-2D>wdxu;M?BCYVd%prj2)|gF@97I(E;LmaPU1M?YO#AeI5jnmaof_j0wa<^0 zt4fbdI4XFnQN1Egu;fDw_}E|e;K4rlKZ&Prt!te)6q*3IF;q2{k1;*K^DLyfjfMvN zs>s)4krxAaQZ3)PbK=O6^|pJpi|Lmcf|ySGd7BwB7X%;-PFTO*yb~hV|!2w#VE_AMg zcTba-S1(MlrXHw#=|!6e*O6ECXJ{xxel2@5^@8uW(}DTYUo1V3IWNq*U$oxLnZZ?1 zRjoC?)N7Vt)nRzN(cGp`OchQ)zJ2?Tyr@=6*|eygmw#_EG*kA|xA<=Jo;j26(LKnt zVEiar;gCvLXqEg+so(PA1qRaQ1oNzD41Ec0v?O$X9X42;zPcr~HM0ifmJ6*MB*f*Sp&q%kbii*LuxkN<{O|4Cm+<>vGbN1a{KSoQiaaRutpxa1)lIiyY;vc)* ze)q?$vq*g0U&E5@%DhTi$@J>B%ry2 zbR*%Jk|)iG2!yAnRw#dH;ctW6B*I5T#ZL>?77aMr9 zy@A0nc`v@!oZcNy9gylvlZr~XbuhO(JGCB!1VJ=#{kl&%TSbNQ#%I6`ecFbR_KILP zSAkmdpK!of{S{0t2axzH{G! z?PApEa!*!bLWj!0eGulP zE8nJXT~|*)N85vcVQ6SiowjyN1HsQ8K6t>OKud2h!3%uM|%lRzss%T<*sKX9YO0|N4&`M21<81GZtm^NS;wSjDb0WSB9(B9C63hLXJ zFBn}bwm3nLz^{TNtPj5q1Ik9RPguc-ss`PFbHqR0x0&1QSwj>7Zs+_lrpkPQXq1=z zBxn&N-%yl(5U@W&0Nii?oCyJjGlHRkI3B5rpp5UZ@U z(4MT?V7sXR-EH*R2ZUJm0K95zf}w^J9KB89q}WCT+KI@v=w z27LTTe{__Illh&dB}g9G$)h`SV5>Ovgc^dJksa=)-?r^JsB4E`-j+}u4f?4Dt>LZy zGqz$Q>c;bEfW3Xr00IJW?%!qf)JmP($dDi^Y%->bnREIsp;B1R;#s%S{J{Rxr)$Mq z+ws<8_=D8o2mZpaiK$}R+3ow?$B=A!9zu>x!NB1u9#lL&PV2JU!5JXeAnXX<@BH=7 zX4HRdia6>f<&nt*!Ul~Mdlq?`8!-|VWCrPLcf&S6Rhb(kQk-<`cyijMD6O&M$Cr># zU0er1t7Epc_HXcH)z*euPiFe+<5$bnA2qc$VVkBIIGnlLGc@3fzhM1c%@iqb9_=Lx z+i!5^=#QA@qavFT=Zc31_$T#fTVrEdS)a+1mmq2tL&}iJg9b_a_I(V=^M1wmY}0oV zz6x0q??r1%d;|{U3Y{mW+g&xgTUo8w{55{}O$nKuR6-Sj@E^<)aCk_Qv0;G__Wh}V zeq8DLlFEdQ8%Kdm?RNncm0VhqvTYj-NwyLzoGrX{%Umkfj?@56lBB=CM8z;_zCp?LYhTl1wt9{}j;S!DbnsyHM$xnj%?wKf$$8bHxj6QnT&Yg5> zCJ{q4&Ac;wBe!jPsxk26TNvpKG(_6VZ6CEtK`ft({B}{_t%`zK#~YIw)eS>Xkb)pF z_N*bilj^u@^1N)~D!+jkYxVqAfFf!D zB@;XVFojzFsi29`GuqpW`}XC*K(t*S-7by4Gb{XdjP>XXv|)}X;bs- z9rH{3I2&ONMp0Pz0mx@#cHgxYl z&qPvGEB54HjvYQMwPR-nB^L2bxC=C_YRWe00;ls-W@gH$gM+U=8UIUiUF?b?Yi4A! zLWJo|^3uot98jhY6DMu~a$(Ax-`=&(OwY!!0EzN!rGG>d>|eNjrw3&-GvD9hQr0xP z%pSKAO+eB%?Z2*HU%`4yYxqo(Y1d{Bj80$5K;GtNR8mf zG%yP7wrt+aSm()7n3t)V7p}d5eM9pLYJf-qkPOaP_sOSSD-*cZ%$KlkL1EKjgIkns z4UOrd=B5?l9|5T0tANEVBbeD-`I<<`p;YtB?EPL}y0HR+*{Xwy(7yb+$TLaH*-0|M`G^M^Oh1t=#3T;&5eBs|nzM-29JRSA6rC_eSWWrOe;Xh6na zjfdg_#(f}0`MHOWA0O#o`JAE<4y>>6-eM=!>gt6^>s`i8A4Y44ACl(jSf)pir%(7~ zzxey_eryxK(5Y=(l_z6UwAa^n=-k4L6Jowxi4hu#2Y{Th-rkz25s>;y9yKeQkuZSU z%=2y=i$37;yuW++w*0-8!uFBE`_;_0BQ^AL9PZ?eK zjH7E01jx-#{bK4WYM}T-8iTzcvs3Q#N;3v-Td(aAw8>^VP%^LSRah25fUg4c5|`Gx zx;r;-uF)wvtS@#gJ>Bw&P!JLdpm6o;{o&=YsSrlR-6HO`j)oiG@MQ)-{{E{P?H*Iy zM2f-gS9lF4UR9vYHL^P%fK zlM1+S(*aLQ1WZ-Va{^7pT1Pr(#yP(k8O+xY=f|Rit^4%+pW}IboPdL7Sog2 z(g#%1((wNix&}@Q3(Q}REYFIwl}hoLds$d0>2Z%E2K24ncNW0b=HTJUBp5NPg;D{O zNg+gxZG4`b-z%3hOU!|loipT)za0u9jXAZFG*O~HcbIS))D-sQwX}qSikO9kif-sd zH#7}?j)|3u)$sj$iBd@F^YCY`pfd*w0D*!S(td0qJ&j#TZ>LFg$>f#{MTpx^nMyzg z%Vr%p?U?E7Zi(87=I&P0cZub!8O1G z4~{3MAB8mqhL;32q5wxOEX$j}*RkUcFc`5@Y3$v-tPmRoN{#Dh_wLt2ONxu*8yCmS zVG;;T0GCLTd(!mwxdWqBhjoUUQktXaVnY+orU5Et=Dy5Ut!-?g|9m(;Vev}0!e&(4 zl>qAL-#)hZW$L{I1q~R0ws7?#Sa!~Ml{!u%aIfN!E@`Nv`Donf+?PINMIV+SLEvB{Zs((yh% z&iK{mNQRl>bbvy-;}vE^++2)Z=t;bsV*KUio0$=Sk!Z=l;wW^SH%nQ}hpUsq(-!1) z^Ov2tc7!&4!%{$GmHU}%(+M|iqe(QtQ(eje@}E4(#M7X5KC%i@F()!6XbsGLeB8R} zjPN0PC%!?O2oQlVBlsa~#9)3X)fB(MSgfLGY`Pn^ZE?np` zx8eY+n?UOAEpOou$AW=`eD?qvha(gqrt%zzVL7Yq=*y^m^Ec(j!O5ai1_yfr*3YN{ ziY$VGbAL~tiu2$2>jsHq=+bu&T;@ArgBJ6-MIj-@4;Mp z=Ew#j!QW-w#X}Sq(=N8&Nvr%3*Xi-s^jsU$F@UmdhP zCB;6ZRKcV-godVd+{RH@=DA9_%(d_Nh>*kGy9R+xeFGOqI0Qs3AK=VWu ziBuAC=IIBbN9;E1Gi$YhL-ho=iID(tYwfsPs&9t996aBKyR+nkPVDvLe=Kalg*qf` z6wczkjhSuaOEgd{9H~13@docJVipr^&=B9(xS!3RJ-fY*&gr?2jxf6A!s44C+`leV z6p}7+q~hTCj2(-Ff%hq}vD=G`M-){dp$_K>D^Ek#8&1KQXI@-g~h46B8(p-AR6<@oShFHZiXc{P- z;0r!~dtlEVEj_*QWZB=Y)K3K9>FC(dW=B^uaKq=wq3x7Ea{$y8q&$lQU8eIzG= zIppmCFnb^m%xa{A6FBuZe*3!VN3ol3C_>d$^Acrg_5%hCF1Og;Nj)(J85i=e-P&en z&zzw#?o{@631GPes>va)k|HG8${I%E9l9I^t3{)bj8uiIj-1Ur<3CzR>sT{Iw=j^V`xmZ~g zecHTdTnJl~TiW5RU|WUpp+}F_VJ$dwu&%yGz!_Tn z?qOHM0{GpyFj)UJPcP4w@WoAm69#F1)u~Hz)m))j1_~L}PP%JUGg?4AbgwYIW#34D zDjv~TDwl*w%RBbke8K3fxsGuH2(;CKmbAI}EB-Ugx$? zt;96mQcwtbB;wY6jHWv&?b;*c9ZY{uwRV-@ArQHs<;W!QABAfa1xPVVT{JSz2O&P^7Rc(|XgHwW4elegB zlaAE)3SF0|+2&Hi9x~HgQ>@pvEokSuQ}y-rYxbR!-)R^R>N?|E+^DEg&@z>SJec11lf47GCVfKJxwVj0 zMl_cB3u(r_R#z9}2wAlx$&CvP0kQfAh;Qnd0Mq@wD@(0n^I^=-#g5JR6_+X94EjX>9IkwP2YYAfvx$bB>jhaQXuP4G(rd2# z_3iKKZhmN_ph}HoD`3Ztd7+Nx=5qPbyJB7AA~-$`^(Tl%0I0s<=NO%F`1f`iF6>yn z5C6SWt%^Fyv+D4kJt4J~?6{vjqx11SdrHg88A5w{dlOErZ;4u-a0PJ5p=|hT74wM2 zAD?ypbNC^am+W0}k9zX%=YrvBp1{7k9XjAB+gdw9ku&e*=@}0=i%&zu%!6 zIrYM*ccH1^XcWxXwnP>A%f4;mRJ$DQ7@`u{eI7)wg$uuqXZ3e*55s+%@oi-z^!Bt?4K$WR!pF)-l+}1r<0;Q|uTSjXZM7M9$`h`VUWA z_$bN>U%YU#vhtWZ6)DI9l3nh$duQAk5Mekewb+U&5MT*YBb@AbMK8lET19?_*27S< z!~GfiL$CIFjL{FVc@KY<&&=7aK_B~jibKYH+<4dtiZ@l~%z^O)6{_)!+Q`sYmj?lI z2vYumA8TXAv$+mFYzAtt-vGMfN$Ex>CKPDG+A3lv{Uy zgn=K>7xpn;TRCfVK1o_>k*FxRnDCik`hrfO2^JgP(NPucu3d=3$2vd#N=1Ck3@)BH zG4;Ym&2FlO1u)tf#K&HTSJLRS0jWMAw=$&HHoPnsF<>~{w^nSR{V|7F^k(}aj}fp4 zC_iXSX}BA|ZKqr(QG)j3)e{rLS!=nbXR>h^?nD6r$l%@f5rsDfrD@8LvEuFnKL>J4 zQ4#EVO19%Cf76DB^sQ(x9>ci~3$wfYf{Gv|i)J6Uk=8~IAfzBT{x@qk|KFsi>-AS* z*UIQS*zsIlFpx;go`XG>TINx$`}XVC44t5QsbH|r3GPV=pFTEiP*0g7r&UgHTMmMmSDhNHBjPj2Iim*mp!7M;r#g=mvdB(}{;r)BX#>H2B>kF)OD0|AD9S~Z!DEI!4Z9R@=p?hGt>6jV@kdG6ym{fLlKw06T!;`H51<++NCE`~UHbG%&MTu3ps3^Q4*y)Df?Q>h1Eit!tO?}_T!wOSbmsBgJBn+7O=&*!)>$(ZfCngguO)X6V z&*M(UJq55GIbuZVslym#93ZTGw*7!C~nJhM!sj7-xhKICt*qg8XBd zCY`OT9)>WVqsWF>N!H2rLmyXe&uTI2+NecPAX!SlSe2*1p6{aBH`I?FJopCWOAM!J zVX>T~g7j;$xh1)tVobOLXo8c?HtO5hWnzJH-%OjWh*#;4IDXvhZKDlR{N!2Ad^u10jvd zhN?pTIe^Qu-$RHiHp_hS8)68Q_O<~*(?#m#w$zP9+--M6lxt51U;WKkM{N5Q@AjX z_ebM6g;2WB@$ifC}!4&Z*@lPLVsWO4fcV_eIs1R2Hv9N zVM;o>ktf&P?+<5$cAR>=jwz5Cw&K-ZWSvG-Cy)Tr9@x7ZqU8EhS~8YDrdYKg*xb8& z*M_L$ZzPquPoDe~DbvlHHz@|a_GJeST;72H`rr{G@6);YIkQ>pV^x)9t^T2JbN={a zZU4?6gtaBUvn?)trRj-3xO*6-)XkgSeiIu`A!bN1_${aC+LdKO9mEbcS!3F(pY8v#kfmR_FuVlu_fVHrlJ zo~4*kiE3_Tr^uL4P>DwOl|nB&Dr6npEz%;e(B9tOQ>XsCLQuqvl1{)^!k^RXgk0wM zp-9miJ>7*OFQj1Ky+H6hu`A1IkxpW=0Y2f88FPjCKU-I{XKhJJGW+*?Ynl!8cMoA$**D5**xb~AbLY*2h;py_ ziU|ux0s{ivp555d{y5m5xTY9N1kas|jr|A5q+ppw^5-Hi1xM=ZWa69zF;3#l?1H0~o>Ix)rAnWhjrtqG$>uBbG(mt0=@6>496g z_kPj>4HR7EYCagnC3}^x4%pk}6Sto!Y`EiY2$G?*WWBT-!f>=?B-=STf1!-efr0c* zaHp8JfL4iAgsG|UP+F;}0z)0xkux|6mM1khz5&EE8riX_!DF{#b?6l#l~X6HO!1Y_ zjuVE3*Z(FvH_NgA!_AWRpqf2)zoGpeT4L~MCjS;w=3%MW-`?JFWn)T}15!@!PL-2YffuOs*nVM1GsTx+Aqg zOavZ9rKKjWN#8po^#h()vp&f-YFs!zgZNEIa+f2cECxF%{YLeXe|96=k4~REH`2pH zCIPk7c}%#ZT>b}8QCV3T=$P5Hii#JJYQ~nVj6oiwMn%1w0K+x_ML2Y1Fg;eD?n_T! z8#wRb)2D*(?Bjz}4#PR>u?Ey_R61p4evl`da{K6bz*UD_T6ffbqi*^XWf`{-Kg2a# zhJ!m3G>Z-KfyXHTME@5;DB)e7>675f7YsR1k;#BIWZSDZZz53nh2)@ng+8M#qpT9S zYWOqq4)qUU58Z9oZr$K>51*|dLjVHu29b)RLD$)Mp#zH6-I2g+q~~h2_>ugf=S;-i zbA5fQy+_;(I0I3KVYk8=2n3k^Q-QZ0SA?cQ(pDRmfFOzTn;8u$e_BuN3#CIM1-z;0 zoUHShyV?4wlsoD?ByqT5frh#|SX(oDax6M`{#IWPs(nX&Q8@V){6;S5X%kOQ?_Qfi zD%WV=WW4O9(cce$eQNHn7HQjJ^@yiaw-k0VwY(9wXOY(*?~gp5aPCuNrstS{jy&3! zI^_?ebHT^=TDKpr>_0zVs&zm)FuYHs&^L&i#m01+=MXGAKFbD>}A*}Jh_s0 zy}6$~i!1h}OcBQYT9*cYdla$8OknrtU+s&iIjv|n2nd`VW|j*m=7qs=kkRbh+f}X< z4Kq(XcyKm#L$TVw&xzI>KGOW4Aj3KRnj$)abwZ~xbqHLyFDq+FNJxD6w)Fn8G|wtn zyqDW7cXFoD+j|F+eAfK7`kHRsi|NyMho`EzKxbvH>FTQN7&>Po3ui6xYBuRksg4pX zrxx~w=cnOEJS`nV%kwFv@_$+@_|DhW(NXFa+k0q3`E?YI#-UG_PM`kbH%*BFMDpOl7gB79(`GxML!J)9dN?D^K*L(| z0dPL!C3V$p5Df@3C3(-)TBb=ww6UfM?j~AVT{bg)Z`qP%y6S6jm;rEUJulwf+{(sg z^vIFv58eWbGh6z}ge`4#JL%bg+a12w#UY^KG-EAfv?vp3F1Ag-IYw#uA$V=*92)>p zc)dyN<^hHNVeFQ(^x$wE7@gWo2{c#ZDXulfgXwyKf>I%A$32l!0h~DyTG{K2hM3Wn0uyDzCHiSDpovDYl^&0G~T<=nrjskJ@F zeJCx>;HkuP#~5We+ogb=@j6XWmsQ$2EMANRy?DUJhvqs`SJMQ^;fbD}#qK5DT>y_# z6wK#u%%D;OpC2SSNhrtn?BEq&9A)mmaP`BDSn9&TSw4!UePv78U`p=GQwO^|ahf zyKA?teTm3y-nD)BdoUGNEkejGiPb;^y@D(n08f9J-&eLq01sRGEwluQFy)c+9skB zWDEvSDs%L^UR?wS7|e^p?^a6BnfLb((~pASmXh+Mq$E!3^r=%Y@il6$jyXIY^-i*j zBn=cJm-6@zYO08>2@Tn6NgS|5wO-BRTJinKh{UW}r@Z)AU{2jOqs{Ge1yBCR(!df{ zST2JU;+qz?VFRL+&OYZSm}q@q6>?o)iInD?Rn@+^a<%17*_ST;15_)O4l1!5y88Ee zhoT~O_u@dnT2+T-b=D0(aju3upS43q?bYrGo6Vqr(ZID*S7$3MV?$XQ{zOAB*N)w8 zw!F471_}&48Xou&%}vaFl}r8skMi%9e6Oo)_Hb8Nr{$e+}0)XqXDTuHccpF2lKhmw3BJHa5} zyKVT%1WUI|aP%t)?qu$>%8Y$FiLWJq zU@mj6y$VY^J+&o$$e@|3&Q=|UT53tKg1}$#2ed7kcut~*vJ#b1jjD%f0@@gQYJx9k z(1cB5OuJV-utD1R$PCR&f-s zjB#3d_K5=r>R7MWcgM)|u`%kC8MNT(7FW4_z+5efGLCiK%l53*`qxNpd+#}88Q(9j zWl9)%>lqsiz@l`9eI$KSF~q*?@Oz(a?H|Jm{p~h!Pd`5E<33lnv?iRA;$M#|*ThNN z%?J`5`;I);V2kmFg#-7GV2ed+OSLf*Ca5IefqVd=RrEn6Z*&vfzcTuyH*EythU~@> zpzSa3-DTT|mSbw+gTt`gxog+Zxg95ad5MMfIW^$!ZOspE(n9?1*>evI!DinvtZ81t z{6R3N`AljryUVq9+=jTfyYC;{SD*0^##WsNn3~EOBbX=(=pO^SLCw8Fb85Bq^u8`0 z7O6Nig~DJ!8Dye%v4VQiWLPFw(*+lQ3O+=Qi;&;yv(v)6o+ z?ZgBd+H@!lF|im{kOm=Fq1UMPW{w2!a-*nT0I%Xx6xe9CXwi8(qN@yuVNG~ zk?O=y<7{U*&F&?vn6Zyuwq(g|HeW2&u(~FOO&rc?+(DW{d9EFxvbA;4!udmVctrB! zL$K}pcXCY0`S0u!cFX=`e;vazrrId)iBFutam+{)=uN=fy1g3yzT95$?j@G|rA>zY zXE3EJyu&Nz`C_GAS_vx4sXk=Lkxa3hwVoAGEM(5?s+U930h_Cxg@?hLyQ!)^s@0R) zNF=L@wz!&dHt3SD|B+avU%l!C0O0O{>n-$3Bz_mpog4Py-M#D%#v_ojJpBARyObu) zvuLDg0(JitQO%TD7(MX_kyF!QnAfue&Ch{RyBscF*qdohtU~APSFhNI8-Uw17<%4OC2RSFjyE+?5$4n&V% ziDZGy7UVNhGmm&bb<7yW3sG8B+w?6-%N}*g?g5nGA5%Ft;MUs0v%8;n4afJbA+8(?n7M(-Lf@=8Y_Kq%1rot)r?FVXqoS01H zkj(IJ*k4pi{T5=sbL5V`oZX(=MgYcBs@@prscLf!`Zhxy*&@meb}0w`>697)u`2wP zFr6Lvh(-ZZM}$hqRPS*~|Tv-#&_D%Q+JSz@+bC2W~#0D&=kM75v8)B5tf}*^c z_ySYJ^2_pi421th2s7@`q5R8tVux)TFyv`i39(nMIA4!tfwnBmVa7@EAlFJNVwFw8 z=gXvB?ltJR+mu)Uz0$dZmh*`kmNzk%0yqH+@>4LC|FzKwwe=rD|(`SBRt z-3zOp7d30b^PupOZ$wVamn+Q6tJq~I7Nb#;imU8Yf{d$;p?wIT2a|7DJ=AeL5O8v* z@gMEi7_h|AAk+&c`c&7elw_k@}UdyT;B4ZTRtH;}n$) z%|?c-852p6Vx#%;(*!O3Ie7|(Y3}BPjrgX=`T72!cVS!KG7LiD)KR$fkUA}-`4HT@ zbUI2^D3h%YZ)+Xnik1)|(*Y1VP6fAw`kmSXbfR4kZ_E0(IQj|hfal0e=0$3s4co<@ zoBz_u_drk0pYM*I1gp9}+O}_BGTsz)%vAmS5K<5v29hWMY1hRzK9MdV(1j#31=v4k zewSsLDypgk_YOaDJ#q>PW+QbAGY;Mt2bxQbO)<(pgb;#V=!5ov>HxMHYh~4f=xlsA zl?G5F|21L)fO(s*3T4BkG>cHXCAR-L4xE0j(HOW$M%&fdNj` zWYC`&iuvlhnI`1CvSSd2%!SXqFFQHmwFHnY->B=$BJI_Kx%zDra>~Q9tlPz!4C1Dd z0lN7({q^kGc3GNBjgc?8l2)u!T_q2UxxIZ|ZLQAdb}?F*mvvpWKlq5QjBBdfwk<8}or)SEV(orysKMtvZIGoc#X~2KA3tJ8-;s-^ zY#4PAzaYq`{43yU7<0`3N7S2u)ws6(|7lz$Su0Hvnq>|lgjz~T$UIYo5Go-=gVnI2 zln^o$rS>)@vyDa?j2SaFNwGs2L+byz?f3cr9LKxgeLP#N;kwW3JipUfb;;zC!mJP> z-{^jCd$E)ciT2cc`Eu5(?!0r3nTp&mHp_kf(TqD`!3z@^ItzH4mSL?al-~ZEn_t=dWWysKB{rPE%dfOm*fz(O3dc@NSLU zYW_B+WtMNapRM9ddiiHyn-xf>Z^Tk4H6VKFJ_9EPmlgJ(U#*n?JJnazhaTuaKpH&5& z!eX^-jmG~G-B06cqUAL2^CM_8rpH^Y1`#yzxf{t!XreI$-y?q-gh%`!8HWT)!>c5o z;CdaCU@5u1gZLq%u6nW6?D2X4Ycn6@RxDO%ooZAFaDP4FZ|h_D;p|vUgZu&63eCUy zyOF5;Xd&_cRv=gc?Zm}E5~K`6i!%;8mksVi6bl=PbzFLqh0UY(n;oag6De0|#@JZt zuo)nVi0Btjb3y81{Km8&EDTU=9AhzjTA)Nz>cWZrDJgj)s)=P<0>$mHI6xbb?q;S& zfQyUu6~Bi_o$RsUGN6d1lvr)`07Wn^VN2`}FE)4FPk?jx-?ZtfTwWZe(iW`*-dG4! zojC`ZryCO@ytXYcaO=wXnoQm-OQhk+n}W?7NhT<_lY8^dKl{-0ELOa_@}pei7O*@E z8!<=_f9>GymMi|m+TOTd8(EX`XBpymZr;`tS4MuBflLcTv0Kn0iIfKUC41jrbhnX# zOlvEXZ!D?0V6_*oz{%gXSJDVr`ztW{d>OSn6y@= zgeG2IUW7%UX}1l}tA)E3dPI@PVA>&#sb!Qsvv@+>$0>$-@P2ts(`D{@0@U}RJiC1< zKlQ-n;dFAX1IAqdWPCiLh3Y7NFSsN8PmR+^_+Y-c7K`>inHSHcaSz@vgjw}kOXq}-^A+0Flq^R;;Z$!F<%cofB z2!(58bA!^HE4KUvnQ_z@PxbPi0wDFHe&z~pl*pXN1%OLLgsUzEBS&_+U z()@95!iXPa+OPcb(Zz_OKrb7ykWI7rNrZis&>Hk_(iE}Y7mzR}RupQ)ar{CgM6u>S zalKw*x!cZO0C)g5GiT4<{^shOi@qkLEt=5Js2Z`P*3HEQ;UQaajO*i*6%BV+0Olby z2s7k2%tM(=xi5Iok-w|rdya>-khHpfR|36^kX9lx8I;t{CwD(Rj`dl|U$ssr0dNb5A-EHDQ$Cx)rKG5c-qtpD?DYP{5|(aq1AylY`|sf4mE$@l z*0tdK*c@R+nZxr`1f#srBq78o{@1(R@$r60+X8F2g!5tE&CI&F?Ma63O@C1NneUKK z$p6x*=dNs*C-F5#)#;KqRa*6Irl zR9}uJH;ID75AeIqHu`rO$V4!b1zN(Bb;+xE$M;LAygQh2@Xw}FRZJAK~P;Ue|Z*SYN^I3^y<7-1&rP%3e(rlq7>{nDGBCy5yI5s3DMn zP&E*XlSMC1Xo~Z1^XR2sZl}(dPu`Ox|2Epo1s9R_HXk{=g&Pn`M*CDZB^Wp!So!w| z;^9MGEd_K3Uk6Gfni+mscS2eUIc;!D#y2TM&u{-Z;o8WZ$|>5eRw**=)=&yg-DW#p z8M39uQ#9v%Nvwd7G?qBnbHa0SLc*dprZ$c+BK@X;TuEV{4Mfx`1UUpc@t&F31azkE z$acVZUC&kVd-jN4`2l?7H0Q2hLUUa$^Xk7IUHTq%#L_>%E~gXX5rJ_Oi2K_ciH^^n zJ>XY%>^grL;{kP8tV3U$;9?Fd;i;5Io5~`{O4|>R`~Uviv(#ttmo`R~P^We(OJlJg zoCSH;e2UHw5rgw5CFKnv#a>*R*iw2*QxUSPW>>Z?5B!FppeuyG5Q zdcm5iM}VhoYy!*=J3(_lcrbwP^YLSL5loY4iwSE9f!v`4aHGjY|a7|uzvZ}o( z*GP1{iU7fRy?zTY~ncbT@j%kRqK#3Wf@HpqOMeZTWvWTb;sEr9Izzz ziow4?uBepv{2L+w>{nQyUgrk}fWauln`{Z~RY#Duau`tPM$T1%JJFgO&ZS>7xEL<*QZ& zH&o;@O5pSdELT-i+j7p>E|z;3)f-|WzgkVFH=oIi%M*4#Sq`uff#Smj#=z6`No^9+{S&K1#CZ#eqSKjxbnoP6W_! zY}xK24>kELJ)=Ul5$lMd@2p=S?t2w!EUTP0Y0}}Q{1pM5k8Dhq-dE*f_V2PE9Mc_2tDFza7Q{eRK-op=od65olOaG*OvXM zMy}0j0LOtZpfJhD3fWFEqa(KqZ17-xQ(%D^SvS#OY`D57FxYe1SReNqCuta2E0Rdf zwU$tiYP}_4L9n;{x4; z!7o~@U#z!|;zb0jz}>|;@S8V&eiK6yn{3Xm!fGadsaY|lXyUY(ZOWSYmTr5D?@07? zbcpKKp0^sQGUHL)?9>NuQPwm2X#XDYAK@KR*3j4}nc!B`7D>hHdpo;NzV`E7Px;wX zM0}z$Cb+`x0Rt|NJS|$sBV&N!(dGI@bW-?v)kMfZ;wK`8JM;T|Rx2wyFsNaXP1Qjv z%C#X<@}(XVC(~KoAR_dx_15b@v3o>}^PyL<=+;dsX5!45OfpKQsHR^LC?a}Gc-CTw z?8{F^*zx*UTkd2kfyEiTO7F>X#bG?-lbigwW63xSO+?c>e(u}@fua2b6~ezfJ1fx* zvX13hNvR{c0h{~wq8~qh;tuwJwxM|jtUWM)UbUd>8;j$@om@mEmL2Ng_3- z$VDm8MQ$Gqm&dyVP&I&Ab%r}d;LFcV7(H0wl+nd)s9knIp#t#MN#pigVfIGJPJ z+;(Q(eo|Vxmo*8e7fM9Rw6d7+t4>+?%DA<2(3o~HH2hoCYZm9-dI`JKSpS|kxKqJA zlqWn1vfHnT*0LPG-|E`SJnLJi&MO2p9vABMP1AJSFitYrWLj-Ezf86(AB6n?Y}0Iq zSNwXX_A@`PS(5@Dz(6$BesX!KBY!ov13~VsqN-Xl;s>ZCkAt%ay%ZPHOo!;hqotfL z)b~Q2yM1u@>~8_)+VVjwo{?cZqZjR8e3*fBn=-Nhv$@7%w}W;+4w!ubbEIh4#^RqN z{Ac2&8?7U--g+)a?I>zr1+0M*J3kAtJj;}=|E8V%7!+B<>OU=)IrqG60>%Bykd6u@ zAghtpqNmyQXfyPHmII9S#@e)K$HQ<_)_dIsC9R-OUe~_;rG@0vs}4?W4Wcal)3!UZG?1`=}_G837 z=!smd8oOe}p~;DC7{NgThXaeHD-u?cpJv<8-&E(75MQ*1P^2&&jw`O2y6c!Z>@(YG zeD_tP*%UYXE+wqu;)Z4%2Gu0W^(m1(HZv($+nb7@?1fdEf1|ksO z*!NWLv15rvriqIizhddqA#2|0G)ypvI5GF1X?^DET97xUBHswmh6X@u_PY#t0c~xm zc?sF@;Ki+LLlL@!1&>I)c|d5oCmtfM0VC;*iu-TBaQ)JWa5{uiVf}@!Q;7QaelwD5 z2##J)&Wz{H!;*jWBM7M6Q89Yw?Zw?JEJ7dL1jPvXW*qN=sx#Vnsgsar7V49C|NbXd zz?zOwGb{0Yn#;+B@5Xo17<({B!uGg{lGmT+^g-Z!HMxmJS!L|kn%F7Sa}UC2#5iom zoPcNoNYm2}6qPMX7$}&x&Yw9m`O0mW!B~DIYth!Af8Lo2Uc{XDM){cnZ~bsI@d=U( z5u@LTfT%zVnG*}Qbp+%2OoaC)Mmhh~eaWv$tPd;eZz3D<<8=bAl&yzGr%j?@ChHy} zB+*5MTkx8D-aU;2kPP8lx0I*4QbcioHLZmR1XxCX6=iuYnhhiz+=}t2oJaS+feKZv zbVzJ9qzW$#A)P?_KQb~oi~dw4$jKRvj*?w_WPDJu5xW*@TvMj(#Dfi39oX7g76(57U%0D2+2{Y^ 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 z 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

    D2?@*X}V?`#%TcIMi;+I|F zhYRbEt>LqGyJvdey$Z2ZA??c+8x9UWv%^zIJ`6CN=pZ&+wmMwB8Js2#gh$N!qYbk3 zb4uxj_1d25=?B`~191&;r>>iwAIKpT3(uTbv(DX^Bqb5k07L~+*r>0f1MUi0>T6g+?Mx!&@{oZ$ZmOkqYJ2_fJ8Ls{)`En|wX~%y1a9O9 z@3ucv7~elD5V@>)A`yI>LMJfCG?2Bq)vsO zLP2Eaeu8URT7{Ow({@7>)}@PoIJo~|vmtQ9ExNc*vagM=GxqB8M>C_?3!cC49A7yT zl%!jw+cs*^>}Gvasd)$8c($wEapShL>dx8@b9TdVvcqvl-=l4pPA_94vT&cx6-{i1 z8|(CspWl37UX zu~}fdu`oSFJ$Pxm^JNDDPaJ`#T*_Y1O+^p?SZ6#ba}MeKdM)(Pj6>pv3qu9s zz*QO#E=UlHB2II6p<03$G_9dRUFz9BdWZHd9AMz84MqZSHk+9taM0D&1^R_}Js#h_ zeS=sSwlRQ~%g_w_>4fov$IBRa3tVtG%2;2$dKG0>S65e4(+qg^=FD$U@35O0m9o6N|E3+2yz>q`miM$Z zRcY@N0K2MMH2**CYR=qAs>J6lz$}2E9hz>-6|9|=f0{;;P1_mZ3aWK?Q`|lbka}i`_fx|+k znwBP~k%h$t2o#{OkH$@#i5>ik5F3h=9jAnJIPn<3LIawT!$nU|9~~1T3bA{el&W@< zd`xdK5OV$k?D_fn9z~G@Jw}3I*l#qqus#sM*8l@a@`48s${iUwIsY6P+~qGka74o% zM6tcpTt#SUR&(@-QM9-9_q*Y_;zj|NT%3{unJuu@$Qeb2`1$!cI6hqsc_?=wZF0(_ zu=l?q1zopJ<&Q-DK}10V+Cjz)R6hiJiCo6Om*d}nta6>msr|r2R4fESMwY#4u96z8 zZlyV*!w?W@k*~lsW%dvSc>;eqSir}}SBg$3ev?6QY-nf>!YB0g7m0s`KNe{st4C6D zGSPlkHu=v7W8eclsRXhF)P3<$K_e3rXOLocGxGo;4Qq{t*Rg_@aaLDQ|3)hxA@Nr| z_QtBJA+XQDnVop!il+&*7^?>tuy>%*Fqmj*YW4#T3UKw2(iSwuz8!F1e(Y6x=lb<# zbf*zW3!6FIzwb0%wiFc|9q+#BB>52n%BcjpcsJ<*{FmUsgG*$ft)kv!aI@<1P4;9c z6EXq6vzrbskwt=|3m^$RLA=y9vOBATv4 zy2vBfL#pyytl);@&*5Pr{ikvfy<^ZYf7@ziE&~5U5%*_XhB~pS{hDZY1>>t%Z6@SX zL}M!OIbf(#Q8gSrbi}}Z+Q}Xo|!O?wwyM>Wa1iaRA5Na7fx4<3V zKVa7EYp157A}XvBzXPL0r;*5epX0?Xt)w;rVG+b>W^7@BR8@ew33rU1j!ww`#wn_j z8*h|liM>pEqRSg(l|-;8W4>;140?SR0u^bj>t6ifw~tGb#3)FoOGkGJUAUn8zylpA z-`FWkEx26f5$Y&~18!Qhv`a*ppc5x9bzftq)ODH62fyIe zt5-1MNyqY!bR{Q;2rGc7yBDv@ujFvuRbU7J{*sZIw_eMvH=0a1auhY!p7XpKhQ5c?&0zDDG$zyjjg7xPA0MW zFJiy@dQRZ}zj^jHDN8?`+#ms28JgZU2GUn*Qc@9ta6=YwTnHOT-GXB0KMZ@@&a(V` z)!tVs93b{ZL`6YSryFYyzD=AYnDCWlXQlM@^${Z7ya|B5vLBv#0@+t2tnWm5xtsW8Z!Oc+6VFplz&mEFb-c5 zYc=Q3d%eBZJsv;<* zG4OT5q6nTj$5*2N;;MSof@R4$6K_ltk3r`fDe^gaYJ70a1In29^l3%124FhjvkA-f zg9m~`Lk({`G$R~^hZY>y8>9F^R0JqtUlq1mT3VrdpO4n#xxY8p+J$t^rI1~5N9m68wL5GfmR}TgW1g0;O z;hq5Ayyb!w6et~-GxGX^xB@H#k@dx*4T`ht^6y)BwgKn}Z4-b1k$RCWsde^OOTB!o z$n*^)=iNO-DV6^IEcYG$Lmw`WVp3F3g5z~K_yck(J+SBlEh%-cF)_vC!hr8xldMOE zp%Iob+X+^cs!aZ)js; zSB6b$RqhwK^F-|0Da-CW89~eI|5g(?;VU_QjFt6@kiLY(pUELi-XT}vQjNVH{Hg~) zJnz$OP7Juf@f>iU7Gr(UCXBsQNhjci5Mr#FV-18~ChK$Thp)7N@F9AIB&3GY0eh`ywKHX$FyUXkIChIr)US)VfWS8+!}KY`%`m_kqVpX09~HVtwtLT>S?KrW zsqw=33LD2UtbGv=RT7&4oZPuxsS=YbF?%HWP-;f@#22H-eB1#NC> zi{}9WO>b^h?VCbi7!w$%Z+1k|72r;Q4k~FsAu+x%`|$(=9q`zCr=Hy*-^~mjLFEhV zdXZ64CKKPjYsccg^DgK9C5S>kLSw!v{R>{2boeWjSjd=>IE6$=(Clu4cn)}|B{HuO zOu!+GQYz)S2_IdKS*Q_f{8#(`X*oy`5|WRPcsI`byp{td!{)C)fdYEeRaIB_w8hfg z9A!NQ8?>tm)Oe59?oQ)+eC}CP)?xA^XFq6!qD|m<1-K4Dh6b2-K7r5yrLW=9-OT^i zIvBre^@K0(4Z-VAH5gg(GF;t-z?NnYk#+dAfgEPTfB1O>LUqWi2L%m}N??+2w*T7x zS~=~59XYl<<2MSjvfl3}3tU)y56x-85aPR#Vxc$qz}JQ0gnaIRG`l%e6A1R^PZPVI zkA`$_W!ckF{AaRUoHocfcod6e{Rp&2q7M37A!3h9V&vcDQX$10NvXBRFsrP zB!%UR@yHP(Z-W^ngeOlTKPKCyzj<>l;z)~r$yeLVkKYV@Ut%-$QHN)$fiP`?{_&wx zv_e+j5IK3=L3Xo;zB-Ku8L`pr0oaJ)zJ>1(63)l5Q&=x(!R3N8Zq$U zk*8G8LV_TWbQi%b+y2ud9v_qu4vPoD4Yt#0o2M#fAQjCQ6LO1 z-QC?EVluk30ngQ7Q|P2@THtq_Uhrm2Op2-9_H(Y(Gv30D?$5f) z6>SPhpE?Tg^Y7l->Y!06^aisBfJm68o{{)?KPHg&21h6uw&l~tiRpx;x zgpVNCg3nY5-hfu`91Pr6bzm`VcCM2)X9IHb^soAu}ft=fypK zW@d>kR$U_Q{xE1R|H0k+`1q(wo_*zWGQV19GHmU?p4u@zF>VRC4$1GkpYi%N)gHDm z4qI5MAa7Pw)Ge~Q1ra1vZcu~JCK7S#;H?E2v+&__lgME`4&p4JNg`)zmtRmYmU`~W z_uJlVBkYHFkdg`r2vA6ljgO-Tq>x9Y&Pnx?hZS;1L^oSXNe``Cz7g7dOjW#F@ntRt zd|#H(UvRr?Z&PtdTJoU>bc9*|wAy{_n8Oa0gYWATmG*>SS%|cW2 z7n^6jyUD1=4_|-tii}M9$MHX`_c9;+V>h!$piAz#xy>`K1;)8I?Zf*f6c^W4#^3ym z=^FU_xr=oz+UB$Gk#qfyL*&dKKvx0ApN%e)hp6+#HVHq~1<4^l7O_t{;U2DRA;yp| zTcvgd-=~$*gZRIu3#~00kX7l|Aj1RkMR}#C$)E6qKK`rDy*AFD|Lk^`<~~clh_iw1 zfk-}Ll~~;(3t@P~$tatdO+076LG(ke0SVY*KuB=#OB%564O4F_DERU3G*YPE%?vS=EE>1v!CfMKlT4+oiC6tv_O5Cs zLb+j;)DJ-;NWcno4wKz&LDHgwS5NPj@?{d&<3cHLYwH{L=$C}YaTQ!BdUJDcK(k^#l~{C${^|0XvK}V@8~EOq zGswuM5a@2iwyBTW#+<~}VWskEemH#A_NfF|=gQ5nO|Z z{Y2BAeoj~a{7>xdIg+?rQiT1~T0Sv!mV5i8-*ri+3?e%NH9q#xLe(L9F%x6Ju>D%< z>Zvs7Xio098yXUF!XuESPWZ&9nQk_8Rt8!)TYmQJW`^;%RbS*YNNa!Dizo|yBX0hl zH@6<^MhM%bH%_60)V|Rl^${(fQ>WfAU6sR9d%7$zB;+jw#>&iW9@n5(fc&rwe9oG% z{YBuHx{6o4e$Dl^t2o(K?7j?Nd&^EPnHKZHzLapBBQk3HGBejVXU2VUExvX1A`!M^ zVO+ZKKU`!bz{%Y!IsJ;}SFTheVe4L>!H(w&K{@BHD4!N`IzDM0h|zSkw+l>Xobu*3 z4+{z!ytuq2-v{T&-n%BB+{`I~_=By6#ildv@*M@Iew!w-4lzX)m9L1P=4Y{uB1n%a z?7|*_fqZ0V&2UYr4E==kx< zyF=Ku$DR-~4XAQCJq8c83?xS974R?|1Zb7CDg3&MA423Ig9f@2FHg!s!xI@^bEzTN zM)Q@3xaPf*{EVXtZdH+P$ZIm)XJ!=q-|t0l14lqG3p;#=<6{di0PNhrLLl4)|JaDBBGTInn%8(=i3VuOiX%c=cuU(fKdYG)GMTv zD3b@Xw80Yw>otJ>sF_hpLV|=}sSV9n9Uh`o2Vx4zXV^C?Dh2JfKwO^NuaDpjd7b}J6Vzjf9Gw(n)Zz|^{(grd393F2XyR8O zie#WSG>`c*#*J*?FdbcY(i%1k4B$*-&6_v7wjU6E1Kt!QWzqJ#a^0UvoT$tPe~DsT zV;1*MS_p^S{_qTQ@@7X^UR>u0@SAZ++v4MSJRDIbBZ@qle0>GwAcaV+6%)sMs3bgy9!NFrm**L*p>s-q_{XyPPCwt@g7qS{-6y9<(4#K$ok`jq#|# z*+`4^V}Gqqj@@=NDb^k=GV9FyNG#sh=F)eq+c5|;$sXLpoT3=p?YJVVs>+bv));gC zoQQ}Eak4B9Z6$1msGyTa<4Tw_kxXtX9@;~8WG*G@l^mL@rKnmKz31+9s**aw!1_tv< zT{X4E=z;YVnaA*uhRTEJ{Fh7nYg#{G^?!Y?42BtYtsh2k4mr20adDu`@hiAMH^f|# z9`Y7MsVbdkKS+7J!i221$pza&fkx|O%Wh^MB`oKRurWb!h^O@Tqk}J#l77t8GFi5~ zGZP@vUDBvSjDl8YH9W{PHHEPuQnGY#>+TGdGS`To$|x5dc1EY4H? zbu-I(SXe%A7xw%?()%dDP&?d``;0IM?!=BGD{J-LmTI?5qR2Q=5Wg`Uq4w6sz?Pr4 zw~20OOibzR>OCTftir<1m`vDs_Lq_+Wbf-TNWHYnG@2fbaST;6HT0;nH1j!N!Vom{ z3p7f#Oiif3^eaC`%J1HlYseit2;?^EG0eQYZ=B%fP>God#q?=MUWNIwwtD9uT9##P zo2wZpk`OBZB5;HaqrKjD8ARpimWt|A~uQQd8*t3y#8 z2z8>~24F8owR>~VU%9@BS`@-q#1>E)#LUF8p>Fr|$rGJI7aSV+e5~+pP$ByAT;w_lY&ZxEl|CesZ4CWg-V( z?6%a?(7-R(plk}V8{g^ES%{8_%4ksZTre5HvI~7haF2<$NsxWz*As2uDL9+ND4d~t z9&q4)y=brZ$RBhTIr`0WeX+0t3T;7#E6@nZIj=|g_eb6tmgj#s^$_KANU@MHPZbUA z&wc#Qo0HN9|0pM}q&)J_ej)_@{l-H9uN}kx77H{(j|Agwg(U2$FTO-I9O5L-Hj0DnM*+R2kt7N)fyK9==J@wOBYEg*p@_YW>PpVQ3v?TxashrJ z61@$03P8Af=H0uCg--{W@fOdu*1Vbk)2I}2v?UylZ~rF8r!ds|*Man@*g{501Ku%J zl6<@RfQ#V6B9q&o992*(F7fL*c6<7<_p#OJ?XU-pz1%%^vm#i47bo0XkiVLj|!c>JIAi)8(&pu^^PcYa2`He9z-lV6^K zGr~Zg@eMb(%_j6wAbuf`g{lckMeksoq7)-^AG<#!^pT$;79^(^;9_M>Ub*}q@uPQ^ zPUj>#9d9;cJ+4tjbmP3S(DCFTbbjSgO92gI2Jz~sYE%lp#R@d4Hf93c^E9~HrH}xF z&H=~U83em0p+7-%A1jZ)CnMu!xB1BeT5!`=5o6by8{ zNTY8}HMJ!IpO>$GqVS|Ok;o*Y^za$a+w5z@EF~ZOl z*P?tH%yI=(T&4@6{QL{|#MdGZ-bNY@l9*ZPkz3l@z0j}Ha&CcCr{3mlxU&b&t9_c z)8c3MOepqa_^tnv67MEBSjw4u?q?Y54&vnC02?#&l=qD?usv^TXq@r@k<@wc3C$nd zx9d3C|8q|7?m1bJ4q{oc6c7|EPccSK0L_h!DZ(`I`J|_F_*Cbj4hCrHX~@~FNFZr+ z=sVfMmswP_jNk{MKJ;qDp?ZUM#NKVFdj0(I6FP;-=Q<|bqo%}#D3Gl zESekLu#!&wugSabeYBh}DicJNMCTQpAV!=`I-&YDQTJwRy=jZDZhutc2%2<6$r31L z*!;eRCZ7{-#{4X5JE-bSs<8Zhsw5AuJ6e$@oFH0UosFE2{eviY_Wbz|#{sm5Knhgh z0pMW8Uu5bRmIzEmbSG|{L5)gl?m2V=uteFXi~Mse{bRa{2XWDijKJBP_5cmhw)Bpj z-2$pefBiI+Mkgm`lS***a6oujP@qQEjGGFM48i{BKSz0Yu(bHYtAmxdbAEM6FkiN{ z%^PAs5}8Q@V+tg2hUYR;mE`j<5b%_yh?2z5kemX(g{F=W<3Gg<5`7_@45NsBgQlX4 z%>B|k_wVQEer#xXZrGFk9iw&ez$~^9V#|{!Tf6uya2YfIoB;{RiP^i4-e9vpC%B~^ zODr+XT;h-3Di$&!_Uz4}C2IPU45^nR(usvxN+r>tn3##>xBvNh(%~m!Wc&(9-H0Fp z)9f;g@j_E>(8d&uTu(Lb208sLpeZ#0d=`P>;Y(11*X2PEybNd) zt3}oSoY>VL?{^I9IP|qWFukbIs-kK>`we#>HuhaOG;OG;RM0l_aQqP@MT~6`%iat_ z*&U_WNRShwqdDuX(O-h-*1?x3r2w$ePD|wFk0+q6(}zM;(`TE;f2HDzPj->$-caRX zDm^wFCaZ3me=+14`Hi(|Q?c6~W_PmH?gUXO2rIK}SjlK-QADwIKTR*Yo!{{J_`CXg zL&BeIN$Y1@Ue)1~cXi_o{zEoCS-Z z;5&Gx#!=LajByyEF)?O3f}e}{6gc5d0Bt9faWXQ{3i0vtn?!PhnN@hn=J7~XCbUB^ zE|=`Je{+YAk1gt!*p{b{X+lAutM@#YnU%c!&o9?3YoDZuF8|phdZ<$yg(L(bNUmbU zis}qB5iafJDLv+Kik;mFC9G&Z-snc?g{)&IU$DCjNq!a&jkk9d&@Bm2n`1>&S2qk(bwf7 zF`dXDX36cPRK0%99;H8Dk7jOf>B>z}%}w&1%xa)JCQP@`admY5aBKA156dP&r(PD1 zutSzBqsO?*64n2(d7MkvS~C5kvA|R7fyY^!hiBVAR++cnZ}q(NvXpy&q2=eu{%)S( z9$JgB&Z)4-?Uze$J=N;}>tIKTY}&V*^dT4wAl@gw+(WB7}Xht&{_r`G{7qBxEv$YN445 z4d6RaFz|lKmu3Q2XaLUE7RFE3dzR{{U4M?0auI7e0!<`EGcT+ zIN$<82Wo5(X~8UMC|M%3Ib$)lil<40I-m#?tq#?3e|kZGF+5Nqo$E8JUX%mSPskpl zXAl$Q&SV-j(7R2T{{ia!ArfmZr$*9V9d&;XB0G4FiyeukzkRVg0n{P-}| zEFhrd$c0_mLNKl<(sQ-^!i})!vQz;Sj$^Qp(>G^_hrA{%-f9h6YMSLIV-*hC7_@;=X=xt`h`0+ zM=ioRwo_Z4F&ASsG2bt!dSTriL>{B6O!8Kb_2vWHnjSF6@)X|W!I28$DDug#9<^v$ zqS?;W!^KcyWK$%gx-c7O)6y*0{pIo@gJmxUlBjc$@(F^XIf=OEGYl(nDaPHv(h%`f z;bCFjW>%Bk#1ecK-o+y08Exr+5LOTo3z7r{dxaKsx**i_9rAggP;A>qN^qG*T!5H) zRjaiW!;mN#1#l9+i_k5Tso(F71{X0VC&n!;Jd!Ay0xnOin1foxS!@58PhkycLHMCt zt;`xmlcT6<4=Fvg=1%Mv95DD8V94BG-=ydETVG=DrEkjSlVO#W!m5)436FcdJ7ge& zFEM@IXlHO^_L1%ETwD->C8CXh2&AK->3|d(N-Y?w^{X;@$O#Z-AX&;s5QdgLVdnl$ zk`BWh4eXC!O_WZ$yY#QmtM!P-ZHjOwyH@!W`W&oU-#Dg|zHfP$?SAs=szTKt^tvTF za%UU-9D}d$b9Wtg*ZU!TXE^8jr)3qTr$4UZVMlM;e+&v6OX7oJBz4WooJ6<7g>R!x zPpmiEcIH_$lk+H^`C=fFmh`FADA6`~WL4qPb~@dtm#p;nirAx!>!?@0T#=&FVq{3` zZ>ef!E)HT}duTlA;Gd^rIeN>tEHLdTLqQ;vDIXa${|aKb8KzQx{}e z_~oJJz(7{ARY0Z^Z<*_t`9%pSO^%@(p?ZF2TdYJJ-_MUXu~h%LCC_$6OHHBKcW#pO zodmEe0@L3dWDz+5j?_mHaH4#5ECXADNDI;03{#U-RujVEnL32_Al61`GNF2Y=LJ*w zzr$clGDAIm{lB!SmmRAb>eW^4Fo3|u@#iZa@)sf9NBeCwu-6C`Up?1_&=cHPupA&Z zOlMTI!ga!ptHNpQtDcPwgYo$sx$u*#3#R!QEW`}9@>Ts#r>N+A2Mfv}PKOSKQnbJ% zQ3VRega|wZJ$=veIcz$kpApSqxT4>qU*uv!LaSo>`PlQ*+IuxtBVSpT{CGB5W60m~ z#B}VHMTh0sb`~EKH)|i;_lq$RDRfk=n|nWTr(KP_eB0SB+qm|~E_%81JG;N_Xns$- zR3xqUGkR;?FVRma-22tB%zLk;E?GzWbo@bW8Fcihg}Q07qMxkn(UeDesTIZTSFpqu}|sv>lG!Jqh?&gI?cx)<>`_hvAA-0 z}$_Oc(B^ts;Mj^HCi}CzuEN2&>gL@)^zYmu7u3636fg`tvb&J;*Y`E4oE*ou|KVI{n!Ml=Az}2C znI@f-L?g0&f>L$CzcAckL`2B1X8eazfLyA1_Tf7{)ut&93>(u$4l?&X$un(zyX=A| zW0%eQA!f_DdVNec)j6VH_?S+{+%=&oL*2JZ>Gr=4*u}2fl|Oj##b%}`sC1>s_EUoR z*R8ao%6Wf3!+zJm=Xz}JKYFds9l2K&scU0V6uDKR*QwpwxfC&AWnDCRfs?_gWM`sf zu($|&tnKY0nd;D4(mVSK3tmg`myP`@;}LGvJHXea({eAeznACyLVTU&XPGtMGrc5v zs@^Xjpy{A!*HvJy#W}tGdoIeNm1#`##9{@w7){kfS5d~~;}=lGlMJ z?JrYYZ_-w619Ri+!M$tj-Yt&Xd8EvG3Nq1}xis@muq|VZv)2E7n_*+x4fXNZWu8-_ z9-uSRMyOmn?H)UA#^V%w$*0*VGQIe>%PZ_n1xt!^XHp!>9RxOxF>r_Z3*|AUIn?R> zkPVMruOg)D_W!!PS)-LyX*&D)!9ABcfA0J-!*sT-#?e7GcdM_9{l*t+=9j8I?-yL@ zWLG6vfH3H|0RUo_j6HRw64It;#te zRhcR8e>C*%*1e&^Y-zK4&+sjCc^^rh4$5El_RXcMtE(|FM-Sj`pb8FUhWiB_=-!DP z@^wK?SF*qoCSi7Vq(!l|DARnty9Vs6)rfEF){wa?K5U#(MQ$-){bX;rNen?StNZrK z?}?iSs<+n$p~kyC#DOrY%E5FxnB&2Qd*^Igy>i<2=yUwnO@j{T{=O(Q#d1^SeCoR= z{%qm>Q)(lcf$(k1l!d(@9^`a+R`zMP_X zAH8h^U*2AAXXBRU;{$jz_OzXFCa1?Mb(Y8SQWhE7^g^>!HWmRNCVz-!?O@-y#`Vef zj5x)`XxoN5Cyo+`zZ1kL3PtXJ_t;y8>e>USuOa>0>ymJjiyDVZOHVhojA72I6A|G<4L z)u&2KQ9#mG5<8;A~IYC##S=VbnD=)uN8ySnbOGg?|*00{aenor~5bk$^5Pk zw*0=r%*k?N$!7)Yv)xl20{b#1_rp>2c-o{JSK(kd>GJujg$cxKJu!Z8OjA75^_k1| z`y!2P;cT}nd+DC-vb8!R+0i7Ev766$@XynW=F@Z8pWEc>mj;Buzdo>|@#g@4xKjDH z3oMPf&-U5pSoV~$>1;kU7dw-s8^Z8lBg(9bFNCGLc=i0~Y;~`uc6r`|t&klTwG7Wj zR#s}S*T6r?!IIbFr?m$JQm6brOk%gxEWVRrT*adhHcT93r|-&m@3EaJe6c7YF-1=L z`8XWHNmCX^f5{4lyiTqz5up}rb*{~~u5EA4kEN#jCMlj-m|bBiNtPMF`BQJaUt&j8 zFC*q`nRZE@fz15;g5c+T2L;+&MaEv&PqSsV>a5zD>!i!Y@79!cIb>k(!aJ3WQE4bM zd1PoxqgUQ;qoA`0?h(u)_8y-ZQd-S3{VACZ!v1?U zy)EunjVJ!PwEarf$?A7ccBDGxQ)f!~Qjf2@nj~x+BhlL;NS~AG<_+&JI#f61U%l~l zur6!9q$^Z6Q=Xx}EmYTaD4&e@AvwS1?=cmGb1WAbevMZtL>Sn;;q6t1zxw*0Zb~=f*OcAW+`OVy{kVaY*1|kWpfaDw;>3gCedA-5w;MFCme#-J z*!HbvVXCj^3DKz3+J(iU$cpI}k*t7S(T=e`MLo+29by?A*u<&uQYc6I4~a@`O;=tHOrcGgRI z8~gmok#g}v3SYsI--S_*cw{QxfH7(`MMZ5HEZk`8YkGJJ0 zZc!xlTOzh89vHQ}W$0W&HnZSmWF9Rne8twM_lgE%6!-=mmX`k(tW478! zNCZE9{AeZ6M)g-VdX2a-zyTNCf4hPX@C1k(gG}%b$r!;t+Yn~9mwvQoB*S9YB0+vW z!(Kn7t`?;1FsW)y4*e%WR`s%T3V$UXP8#OiSc&5o$&@ghr43x!YCFa7Ks z<+33)zNOeut}11B;~e?fUbf^kMe;($L!WdIq3QInI|$FwyFN5qDVFfm!oQx_RQ)gZ z-oh=bbPXS6#sU?=Q9=+ErIa%05Je?KKpH>Fpj*0e1VuqWK~PDR1_|j#ML|)zTO_2B zmOl4#?{W6|o%0`@!!_6JJ;oPnz3Y9S=ebLM>C++Z2KrzBo4{6-BFMh7zSAbsMUJXD zDa5D$NRz9-wUJ_Y{4)c;;9kqnb}u*2izy1v#6KqSUbjCVB$DCx{o5b6#!f!F1Uof{ zb|;(%09jwpRp&xzVQlEj;hW(ZTcAKGG;O(4|}0rovT`X@2W{&c7P zWooVd$!@^o3idE(elVqIa>sSc#~Cko9G{k!B$7Uv;8~Yw6M!i_0e_HnFsP|vK1Uk) z^v>(N>4W062X)zFxy>%`HKQ1KzicfKJs+C=mSRT3C^qaae*N8_+_j3dC+;puBnmwD zvl|amRlT_uAC$3b`q(f+?_2hJ2TAg!mBZ2uoO$!A9%6O$pXiy-J3Sn(Kf8QPv2fjZ z4{32%XhNkQ*``Y-G?L_38t$@GiPp$8@7kGHNA`I(_`<7e3lXi$j-d*VW$2jbEhRP4 z^cXBH3$Uw`Bp*Ly{nZGi1iK!~+R4}T=LOP(?1}6f<*6kGOPh~S=Di5mCAgcm?MIis zMoZV-A`=Rwuq&Zuw&wZr7gY?YPJXf8v170Jjkxp~mS(g&4t}{@Wgn<_2#{I(e$wAg zNm=sN3(~ZvmX<=+sIgHH-9G7yAR*_7|28=DfjNDS<%Tykq?;ILoVFRbH}t0WCk7Ej zoAJeP6W2I+jVwo@yukPc^VwF5{aD@zG677cPX2{EC^#4yb4W;{k!I+HnFzY%s9m3Q zA{Z<|69D{FtaLY=_f%J<&2`PET(Z5w0-G%rFRhx8NGUq3Py*b2>NidUcoA`8;MloA z?ABdqKfA`QuN53`LL@SdrRITu2fp60YHI(h?-Hj^pN0Tw`6pOL;4)@mjle~Fi%|5l zvbU38H!r_Xm!}@(JhpZdVzxPY2E!z+(qv@yq#H>ytb%F8kAlOiKe{iup~Xz zAacRvyH^#{O^4!4Modv5sO6yr=GkQ{tmeadQ8#VC}voBjFtr;Vpi=9#u zI`yn)Me@(=6Gt1aZM)k!8=S+E;5hcTkMX`;l!Ei6mp-q)z@TKyf=_s8$W>>Dm)3?S z#mQohck$Ls65<2!I>4;&RE+iCFWkVPlJg4eK}6d)fYX6NgJ`v~ii+o4%)xzQ8dCrs zfed3umI$X>-^Yz{E+LPZZZ46fy>d&6-#ayLK!K15Wh1X?1NNZYz868r%)o~>?HKo1G z-bVxreukj2MzjF)d&nH&L)uRg+3Y4ejJf25Yul^qWXj!prqM2Y%o>R9cO%j+F5bk#>A8)g z-h7SSZu2*Nw1>0~>^tf|YUp8B_SnoUUw-(-s?)8r-%XC@Eh?Y-ChytJJC)1dby76R zHZwx2vBAiAgo$ze@O(&_lJ0}maEA@16@W?`>pUHl#QZwz>~9_x>D(>FWIEr!VK?RJxepBoFIZtcTFadaml)$pSknOWYXZpN5X7@CbGC@r-(o{xff4pHBn%p zT7RIGjl$mgwusPK5j~!R9DAP}yWoG}ypOSL{pgz-sj_o=a=y7@{JPqyCni83|NJ3` zw0n2#2WjIg6kFxXSLO6GPI@NJ1fQL8q>Jln$Q9^{Ur@K4VrAle6u5WIEPvj&dD-FQ zvUTHDcD_}!e6{;wTk8R>Sf*2serqyJJ1rw;YIH^5*Q@jH-C|*yZ}RADRq>0eal>*< zUt3a3Ui*a0D#y>MTaK|tb36*zD;L)s_R^F|@&u^Zzg|zkcHeq#UWFDfWZVj=8S z@lwy^GBPlr+%NP4C>f|z2;NrR%X;|mOMuH5;j;z5rpr_Sb5&j*62WS`ip#ty6jhKC z|4MmEuK#}g!~6G$rWMD8283mpx3=_!Lf%yOid~FZSZq~r)3(s&W_JuXFJHN$t9Ni8 zJ^jJIPQrJUr;T3AZiVI-;c~n-^W@zASC}`blAo{{kO|kbSukS~bp6Hg=>52xj9&NQ zm6D{C{G^n;{vTvl8X828jWCrk`A8d6C|#6fT-&fKqBf9B&hz^5;82zbpVChGIfwlR z4#|#q-A6f{$kFe z+XDdx;rQFykzR8%lZ%~&CA(SJ2-0}WcfImre%!+!gfiMn`VT!iroDWxZH=s?{~qfv zZE8UdN<6QGxW}p!*7a{dQtTL|FDRA>3W7a|+Iz zs6A1fgD_}*G#$~a>rdQpW{3n;Wgbuj-92)|)XM0@dB zj1)aI!wdF{=J0qwj*MKxq;>~0Edsd>ztD%L&(*y+hxf^|9M;s*DgsQ2N9)4*^RF~t zA4ch7$or&d%qorg8**4jdP-KHH1j}gMoIFttEn`E$PlDnfhmvs0F)R+*;DtToM5rT zg%vj%;twd{KuFDTUW8BrJxvIu=jP@tbdl5I`#@Mam4ILA4ksWDJh)*9i9!JVI8231Wc%K z7h9x3xQ#oA%vd6yFD?LQSW;r*<-H&DPYRa`n2$xD`R8%E#U{q@^#hR#elIxy0)gUw zMqOPUi1AAfjP(gahJ@v{vW>+JkZSa86?*)gpo2xky^69jEth6ps+M5zC)F!gh$je3 zzm{NPkHIH`LbHvi6;J z%g1`bImbrAv4c1PGOxaCMeOW~XME%~?5k=2TO)klM zdI*689_nCiozYo@58;`s$pxAZmH6Cc1dnd|^Upbx=MDf=xvylN=XC^7io`|({EpY{ z+y7ySt^0uQ2KNZUZAY$KO-%b?hM6mu90z#$|-W13nLvlW1#H_ zgWL~2q-@X^Lt7Bp<#B0qtYy-3rL$K6{_?=K@I}a^bml2MG<)P$`QgmWg=Mo4+sMqmLjA^YfQ4J9q8EC&P|)>eMWV zc{yc=Mgy2c=awAM_oNrkR6&Y?VHS&Qkby7{k0nklLIT}$lAT?;xBIdqI7(x8xV3WI zBK(q)M~AAz=V!3F+jP@4XqrX+yLyMjLJsYfggHp;(s{IG9AlOi%Pia}6%>I(pQu)H z{Kc0Bf~$?}0ClequuIf`My`%k#QafC&c+Scy>w%58rrsB#4gd}4Wd_#9NodYd8+%d zs>-Ig#g-Y~n1m2npthEvbEtRP& zwYc+$Viug@_&ft`_;bZQb`BjaPxL&FdJlt2fTxWqeO_BeV(F72i9}_K1>^DpdE0S$ zLsEt8?Chc;a#@Coc&Anr6bKXD{h?9Xt(UXUr2wfKrck zqGcACnqT@JJ+@^```osip??>8#wQgD$+7*fhF(z=V;9Pkn8Fj=rK|=X9`cBEdAPW; zPxrilRKfXE5UanfkD$!2W7Lnfg)T>Btl;7D;#9A)8uAS0SG9|s>L79k+=T{8=nXK4zX(LGctS_%Xgr5FDe|#*J_viJVZF zkEC!^`ERxb3WRfD$B43MPSlQ1L$~A9VH(%CgPWDyDz<2?v>G{7>bwZ9Z#QXah%F@U z@fJ~GVV!i{k~zu#48*3)Wnaf`kA{IE55XdjKV07}xpTkJaWS#3^|#^1)&HJISA*Nn zRf0-5U-xJNGsIjKGx>wMpqEbf6Dlp#1quoQ>$M;|6M1dWIX2)N1RWy+LWp-gR#?r{ zlIPE#e_vTCsx@~1A&czE@QIRsthS~dSVERllU!q5wH32R!~O2v%UE*K)s524C|J3A zqorT4^w<}}S)?3Ez@OunV3UEDbr~DNIbJBMy4N3t8xQ{P$4oG!CM8hhW@4ie&1spt z6#U~7TO$_cFU<&ay3_q(@q+^cg!>CptrtNehzu)9FbRv24a31UYw_nqs|yESJ~N3L;Wp6>EY%m>XUVi zGj)xjwt_MZ2d0V12z=q@p5242-^=3-;MTQ5iZV9R(*HizkNTP~1N7>N8glZy=$c~$tv03}d&6;SLQKW)h zC7tdx1MA zhuB*vu0VqCO&!E?<@=Jb?%0cmcEo`+vN{|*;7tuz2xAl7ek`?%a1EQ2U*CJ-3aX@8 z!ID?6#vzZiNLsn|$EG<+=+5D?CRdFp)aP`N+?i&*1#dHs4lhmUGoMV1?>Eu=pF@%? z+>MXwwd0ho=oLJhc<#~qn9f$hwrmaxwlM>Fet|mynj2 z_!BNiIJmU%f(^nCe0>=N%%AEa93if{`e>ddEXfCCOWZmw^4i5}uw~7^1F<7!iV$$^ z+r7K5!_V~pwg*lO_-e=mJ&uaW*hlgR?$|*@qlS+kV3%z`K+)U-1zf6AwPKCRPUz;WN@pYNKggJgp(m@`0k@%Qs_SJf z+|2E-VZVTjPYCB*jx!)UcJ%|VM_<9Dz=&Uxuar^4#n3^}T#2M^OkH$m1Az8`PVq8Q zXAPv+GV}J6YvXvpa_txpP#$*an8kJYW-fP~W%mB0pE+qc{By_anfDalR=`hiYR&yu zw4xoYBEge`DGdt;$8bg9CbF*(!*to4>^#Q;5l% z9VD)X$2cI`14x3EP@RI|y~Zoe>CWEumLbW}yna-D9_2mnCx+}1P{9ah}M*n0rVje+h+ZfNPEycIQQ4B&Z{0#!=Ph5xm z7|jlzI2XC_gAQP|6YCa#8RMktej;cURc`cJ?L9O@Tefb6j9~|}WyyR?C@sJJdGu&b zPzf!wh5g!Uer{T)-bxnzuJUA>TxM2t^xt1#X{6UZotoF<8xX+5$+Fp?@||vQmWw2eMmZIIdG&sVgcg zT?PNu1D^YlDo*}K3R1LiP;X~OMS?~?%&I*xc@(<$`5BzxZ6&{zl5ci@IpiJa3(2Ri z?_ZLdT%qXQQ6mR~ioEQbkAK6BQ3o7mj0)x}@aW7K{+s(=@ph-=PY&52LeYg|a62=Y zJs$HZdO1x*=sUzMCLij4f8?DH_Idx%!8Y#>0W&Iek}2dtKhgGfd8|yN?)x2Md+Wss zFY42&5Pd={A&N$lSQl8weebrYsloFCgC9f-3qVqfvdOCQ_Z;FXOHgSNX3fzhr;nY=9e*FIf5bc==g zYhg&jQ>hoUH+^+wnd>+Jcq|6PA`hlos& zf7aiD^HYDfLYyOpwGQC{c;VRel9Ek6vHSK~rwYjryV(KirfN?3nA6W)()2|w6&_7DxtmuLx3I8ek=6#c2D<}VdPEvfXl zCg5M;aN75Z&3#ZMpeB(3L%!$=x-Iw(O2BLZ4>y==g}evY6O(}hUrH^;WCJeWHU8Uf z?v z_yWYVotzx4T@Z_>=LXa_C|4{&tkYFWvQwWANgqk5xz$|E5J;hPwl zc0=!N=0ESU26F{J2m{PE+~9fuLsXpu|6?EKKlX&)9aP?(TfL|yLkD3B+Lo1yBla{x zGItS*$R!+t=�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

    !G$+UNj(ApE&Q^P6==#Kgo9wgzem=`+r; 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

    J*k63&?sF8#c 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 !is.na(type) # this happens rn because of GBIF collectory search not being properly integrated ) |> select(-functional, -url) |> - bind_rows( # add cached gbif data + bind_rows( + # add cached gbif data tibble( atlas = "Global", type = c("data/species", "metadata/fields", "metadata/assertions")), + # some atlases link directly to media rather than support an API; add those here tibble( atlas = c("Austria", "Brazil", "Guatemala", "Portugal", "Spain", "United Kingdom"), type = "files/media" - )) |> + ) + ) |> arrange(atlas, type) |> left_join( select(node_metadata, region, url), by = c("atlas" = "region") ) |> - mutate(atlas_label = paste(atlas, url, sep = "\n")) - -# clean up -df_functions <- lapply(strsplit(df$type, "/"), - function(a){ - tibble(group = a[1], type = a[2]) - }) |> - bind_rows() |> - mutate(atlas = df$atlas_label) + mutate(atlas_label = glue::glue("{atlas}
    + {gsub('^https://', '', url)}"), + api_group = case_when( + grepl("^data/occurrences", type) ~ "occurrences", + grepl("^data/species|lists", type) ~ "species", + grepl("collections|datasets|providers", type) ~ "sources", + grepl("assertions|profiles|fields|licences", type) ~ "filters", + grepl("taxa|identifiers", type) ~ "taxa", + # grepl("media", type) ~ "media", + # grepl("reasons", type) ~ "other", + .default = "other" + )) # order groups -df_functions$group <- factor( - case_match(df_functions$group, "metadata" ~ 1, "data" ~ 2, "files" ~ 3), +df$group <- factor( + case_when(grepl("^metadata/", df$type) ~ 1, + grepl("^data/", df$type) ~ 2, + grepl("^files/", df$type) ~ 3), levels = seq_len(3), - labels = c("metadata", "data", "files")) - -# add extra space to distinguish between "metadata/media" and "files/media" -df_functions$type[df_functions$type == "media" & df_functions$group == "files"] <- "media " - -# order types -count_df <- df_functions |> - group_by(type) |> - summarize(count = n()) |> - arrange(desc(count)) |> - mutate(order = seq_len(24)) + labels = c("Metadata", + "Data", + "Files")) -type_seq <- lapply(df_functions$type, function(a){ - count_df$order[which(count_df$type == a)] -}) |> unlist() +df$subgroup <- factor( + case_match(df$api_group, + "sources" ~ 1, + "taxa" ~ 2, + "filters" ~ 3, + "occurrences" ~ 4, + "species" ~ 5, + "other" ~ 6 + ), + levels = seq_len(6), + labels = c("Sources", + "Taxa", + "Filters", + "Occurrences", + "Species", + "Other")) -df_functions$type <- factor(type_seq, - levels = count_df$order, - labels = count_df$type) +df$atlas_label <- factor( + case_match(df$atlas, + "Global" ~ 1, + "Australia" ~ 2, + "Spain" ~ 3, + "Sweden" ~ 4, + "Flanders" ~ 5, + "United Kingdom" ~ 6, + "Kew" ~ 7, + "Austria" ~ 8, + "Brazil" ~ 9, + "Guatemala" ~ 10, + "Portugal" ~ 11, + "France" ~ 12 + ), + levels = seq_len(12), + labels = c( + "Global
    \ngbif.org", + "Australia
    \nwww.ala.org.au", + "Spain
    \ngbif.es", + "Sweden
    \nbiodiversitydata.se" , + "Flanders
    \nnatuurdata.inbo.be", + "United Kingdom
    \nnbn.org.uk", + "Kew
    \ndata.kew.org", + "Austria
    \nbiodiversityatlas.at", + "Brazil
    \nsibbr.gov.br", + "Guatemala
    \nsnib.conap.gob.gt", + "Portugal
    \nwww.gbif.pt", + "France
    \nopenobs.mnhn.fr" + ) +) -# order atlases in descending order of availability -count_df <- df_functions |> - group_by(atlas) |> - summarize(count = n()) -count_df$count[grepl("Global", count_df$atlas)] <- 100 # put GBIF on top -count_df$count[grepl("Australia", count_df$atlas)] <- 90 # Australia second -count_df <- count_df |> - arrange(count) |> - mutate(order = seq_len(nrow(node_metadata))) - -atlas_seq <- lapply(df_functions$atlas, function(a){ - count_df$order[which(count_df$atlas == a)] -}) |> unlist() - -df_functions$atlas <- factor(atlas_seq, - levels = count_df$order, - labels = count_df$atlas) +df$type_label <- factor( + case_match(df$type, + "metadata/providers" ~ 1, + "metadata/collections" ~ 2, + "metadata/datasets" ~ 3, + "metadata/taxa-single" ~ 4, + "metadata/taxa-unnest" ~ 5, + "metadata/taxa-multiple" ~ 6, + "metadata/identifiers" ~ 7, + "metadata/fields" ~ 8, + "metadata/fields-unnest" ~ 9, + "metadata/assertions" ~ 10, + "metadata/licences" ~ 11, + "metadata/profiles" ~ 12, + "metadata/profiles-unnest" ~ 13, + "metadata/lists" ~ 14, + "metadata/lists-unnest" ~ 15, + "metadata/media" ~ 16, + "metadata/reasons" ~ 17, + "data/occurrences" ~ 18, + "data/occurrences-doi" ~ 19, + "data/occurrences-count" ~ 20, + "data/occurrences-count-groupby" ~ 21, + "data/species" ~ 22, + "data/species-count" ~ 23, + "files/media" ~ 24 +), + levels = seq_len(24), + labels = c("providers", + "collections", + "datasets", + "taxa-single", + "taxa-unnest", + "taxa-multiple", + "identifiers", + "fields", + "fields-unnest", + "assertions", + "licences", + "profiles", + "profiles-unnest", + "lists", + "lists-unnest", + "media", + "reasons", + "occurrences", + "occurrences-doi", + "occurrences-count", + "occurrences-count-groupby", + "species", + "species-count", + "media" + )) # plot -p <- ggplot(df_functions, - aes(x = type, y = atlas, color = group)) + +p <- ggplot(df, + aes(x = type_label, y = atlas_label, color = group)) + geom_point() + - scale_x_discrete(position = "top") + - facet_grid(cols = vars(group), - scales = "free_x", - space = "free_x") + + scale_x_discrete(position = "bottom") + + scale_y_discrete(limits = rev(levels(df$atlas_label))) + + ggh4x::facet_nested(~ group + subgroup, + scales = "free_x", + space = "free_x") + scale_color_manual( values = c("#eb2d83", "grey40", "#279c9c")) + theme( axis.title = element_blank(), - axis.text.y = element_text(hjust = 0), - axis.text.x = element_text(angle = 45, - hjust = 0), + axis.text.y = element_markdown(hjust = 0, + vjust = 0.6), + axis.text.x.bottom = element_text(angle = 45, + hjust = 1, + size = 8), axis.ticks = element_blank(), panel.grid.minor.x = element_blank(), panel.grid.major.x = element_blank(), @@ -106,13 +188,37 @@ p <- ggplot(df_functions, panel.grid.major.y = element_line(color = "grey90", lineend = "round", linewidth = 7), + # panel.grid.major.y = element_blank(), panel.background = element_blank(), - strip.background = element_roundrect(fill = "#fce7f0", + strip.background = element_roundrect(color = "white", + fill = "#fce7f0", r = grid::unit(2, "mm")), + strip.text = element_text(hjust = 0, + size = 8), plot.margin = margin(2, 8, 0, 2, unit = "mm"), legend.position = "none") + +# recolor the strip elements +## code modified from https://github.com/tidyverse/ggplot2/issues/2096 +# g <- ggplot_build(p) |> +# ggplot_gtable() +# +# strip_elements <- which(grepl('strip-t', g$layout$name)) +# fills <- c("#fce7f0", "#d9dbdb", "#defafa", +# "#eb2d83", "#eb2d83", "#eb2d83", "#eb2d83", "#eb2d83", +# "grey40", "grey40", +# "#279c9c") +# ## length(fills) == length(strip_elements) +# for (i in strip_elements) { +# j <- which(grepl('rect', g$grobs[[i]]$grobs[[1]]$children)) +# g$grobs[[i]]$grobs[[1]]$children[[j]]$gp$fill <- fills[i] +# } +# grid::grid.draw(g) +# +## fails + ggsave("./man/figures/atlases_plot.png", - width = 8, - height = 5.5, + width = 8, + height = 6.0, units = "in") diff --git a/vignettes/choosing_an_atlas.Rmd b/vignettes/choosing_an_atlas.Rmd index c6767479..2491021f 100644 --- a/vignettes/choosing_an_atlas.Rmd +++ b/vignettes/choosing_an_atlas.Rmd @@ -16,8 +16,9 @@ The GBIF network consists of a series of a series of 'node' organisations who collate biodiversity data from their own countries, with GBIF acting as an umbrella organisation to store data from all nodes. Several nodes have their own APIs, often built from the 'living atlas' codebase developed by the ALA. -`galah` enables you to download data either from GBIF (see also `rgbif`) or -from one of 9 GBIF nodes: +`galah` enables you to download data either from GBIF itself as an alternative +to [rgbif](https://docs.ropensci.org/rgbif/); or from one of 11 living atlases, +as listed below. ```{r, echo = FALSE} diff --git a/vignettes/object_oriented_programming.Rmd.orig b/vignettes/object_oriented_programming.Rmd.orig index bfd7bb05..a07dc9d3 100644 --- a/vignettes/object_oriented_programming.Rmd.orig +++ b/vignettes/object_oriented_programming.Rmd.orig @@ -148,10 +148,6 @@ request_data() |> collapse() ``` -The `collapse()` stage includes an additional argument (`.expand`) that, -when set to `TRUE`, shows all the APIs called to construct the user-requested -query. This is especially useful for debugging. - ## Object classes Under the hood, the different query-building verbs each amend the supplied From e73e5545beae9813936bd76c073b3b0849a48c8f Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Mon, 15 Sep 2025 11:14:47 +1000 Subject: [PATCH 27/94] clean up code as per R style guide, commit 3 no more `rlang::abort()` left, minor refactoring to atlas_media() --- R/atlas_media.R | 77 +++++++++++++++++++-------------------- R/print.R | 39 ++++++++++---------- R/read_zip.R | 10 ++--- R/utilities_internal.R | 11 ++++++ R/utilities_occurrences.R | 54 --------------------------- 5 files changed, 74 insertions(+), 117 deletions(-) delete mode 100644 R/utilities_occurrences.R diff --git a/R/atlas_media.R b/R/atlas_media.R index 33c9e66e..bc558a1b 100644 --- a/R/atlas_media.R +++ b/R/atlas_media.R @@ -9,19 +9,8 @@ atlas_media <- function(request = NULL, data_profile = NULL ) { - atlas <- potions::pour("atlas", "region", - .pkg = "galah") - supported_atlases <- c("Australia", - "Austria", # not currently working - "Brazil", - "Guatemala", - "Kew", - "Sweden", - "Spain", - "United Kingdom") - if(!(atlas %in% supported_atlases)){ - cli::cli_abort("`atlas_media` is not supported for atlas = {atlas}") - } + # check media is available + media_supported() # capture supplied arguments args <- as.list(environment()) @@ -34,6 +23,37 @@ atlas_media <- function(request = NULL, cli::cli_abort("You must specify a valid `filter()` to use `atlas_media()`") } + # ensure media filters are present + query_collapse <- update_media_filters(.query) + + # get occurrences + occ <- query_collapse |> + collect(wait = TRUE) |> + tidyr::unnest_longer(col = any_of(present_fields)) + + if(!any(colnames(occ) == "all_image_url")){ + occ$media_id <- build_media_id(occ) + } + + # collect media metadata + media <- request_metadata() |> + filter(media == occ) |> + collect() + + # join and return + if(any(colnames(occ) == "all_image_url")){ + occ <- dplyr::rename(occ, "media_id" = "all_image_url") + } + occ_media <- dplyr::right_join(occ, + media, + by = dplyr::join_by("media_id" == "image_id")) + dplyr::relocate(occ_media, "media_id", 1) +} + +#' Ensure media filters are present in the query, and add them if not +#' @noRd +#' @keywords Internal +update_media_filters <- function(.query){ # ensure media columns are present in `select` if(is.null(.query$select)){ .query <- update_data_request(.query, @@ -61,7 +81,7 @@ atlas_media <- function(request = NULL, selected_text <- glue::glue_collapse(selected_fields, sep = ", ") c("No media fields requested by `select()`", i = "try `galah_select({selected_text}, group = 'media')` instead") |> - cli::cli_abort() + cli::cli_abort() }else{ present_fields <- selected_fields[selected_fields %in% image_select] query_collapse <- x @@ -74,35 +94,13 @@ atlas_media <- function(request = NULL, media_fq <- parse_regional_media_filters(present_fields) # add back to source object if(length(media_fq) > 1){ - media_fq <- glue::glue("({glue_collapse(media_fq, ' OR ')})") + media_fq <- glue::glue("({glue::glue_collapse(media_fq, ' OR ')})") } url <- httr2::url_parse(query_collapse$url) url$query$fq <- glue::glue("{url$query$fq} AND {media_fq}") query_collapse$url <- httr2::url_build(url) } - - # get occurrences - occ <- query_collapse |> - collect(wait = TRUE) |> - tidyr::unnest_longer(col = any_of(present_fields)) - - if(!any(colnames(occ) == "all_image_url")){ - occ$media_id <- build_media_id(occ) - } - - # collect media metadata - media <- request_metadata() |> - filter(media == occ) |> - collect() - - # join and return - if(any(colnames(occ) == "all_image_url")){ - occ <- dplyr::rename(occ, "media_id" = "all_image_url") - } - occ_media <- dplyr::right_join(occ, - media, - by = dplyr::join_by("media_id" == "image_id")) - dplyr::relocate(occ_media, "media_id", 1) + query_collapse } #' Set filters that work for media in each atlas @@ -114,8 +112,9 @@ parse_regional_media_filters <- function(present_fields, atlas <- potions::pour("atlas", "region") switch(atlas, "Austria" = "(all_image_url:*)", - "Australia" = glue("({present_fields}:*)"), + "Australia" = glue::glue("({present_fields}:*)"), "Brazil" = "(all_image_url:*)", + # Flanders? "Guatemala" = "(all_image_url:*)", "Kew" = "(all_image_url:*)", "Portugal" = "(all_image_url:*)", diff --git a/R/print.R b/R/print.R index ab32c5e7..95b28091 100644 --- a/R/print.R +++ b/R/print.R @@ -93,12 +93,13 @@ switch_slot_text <- function(x, a){ first_entry <- x[[a]][[1]][1] n_entries <- nrow(x[[a]]) if(n_entries > 1){ - glue("{first_col}s: {first_entry} + {n_entries - 1} more") + glue::glue("{first_col}s: {first_entry} + {n_entries - 1} more") }else{ - glue("{first_col}: {first_entry}") + glue::glue("{first_col}: {first_entry}") } }, "filter" = { + # FIXME: predicates no longer have a class, so this won't work if(inherits(x[[a]], "galah_filter_predicate")){ glue::glue_collapse(unlist(x[[a]]), sep = " ") # messy but functional }else{ @@ -126,10 +127,10 @@ switch_slot_text <- function(x, a){ #' @export print.query <- function(x, ...){ if(!is.null(x$arrange)){ - arrange <- galah_pale_green(glue("\n + arrange <- galah_pale_green(glue::glue("\n arrange: {x$arrange$variable} ({x$arrange$direction})")) if(x$arrange$slice_called == TRUE){ - slice <- galah_pale_green(glue("\n + slice <- galah_pale_green(glue::glue("\n slice: {x$arrange$slice_n}")) }else{ slice <- "" @@ -145,10 +146,10 @@ print.query <- function(x, ...){ url_temp <- paste0(substr(url_temp, 1, 70), "...") } if(nrow(x$url) > 1){ - url_text <- glue("\n + url_text <- glue::glue("\n url: {url_temp} + {nrow(x$url) - 1} more") }else{ - url_text <-glue("\n + url_text <- glue::glue("\n url: {url_temp}") } }else{ @@ -156,15 +157,15 @@ print.query <- function(x, ...){ if(nchar(url_temp) > 70){ url_temp <- paste0(substr(url_temp, 1, 70), "...") } - url_text <-glue("\n + url_text <- glue::glue("\n url: {url_temp}") } subtext <- galah_grey(url_text) }else if(!is.null(x$data)){ - subtext <- galah_grey(glue("\n + subtext <- galah_grey(glue::glue("\n data: {x$data[1]}")) }else if(!is.null(x$status)){ - subtext <- galah_grey(glue("\n + subtext <- galah_grey(glue::glue("\n status: {x$status[1]}")) }else{ subtext <- "" @@ -202,10 +203,10 @@ print.computed_query <- function(x, ...){ url_temp <- paste0(substr(url_temp, 1, 70), "...") } if(nrow(x$url) > 1){ - url_text <- glue("\n + url_text <- glue::glue("\n url: {url_temp} + {nrow(x$url) - 1} more") }else{ - url_text <-glue("\n + url_text <- glue::glue("\n url: {url_temp}") } }else{ @@ -213,15 +214,15 @@ print.computed_query <- function(x, ...){ if(nchar(url_temp) > 70){ url_temp <- paste0(substr(url_temp, 1, 70), "...") } - url_text <-glue("\n + url_text <- glue::glue("\n url: {url_temp}") } subtext <- galah_grey(url_text) }else if(!is.null(x$data)){ - subtext <- galah_grey(glue("\n + subtext <- galah_grey(glue::glue("\n data: {x$data[1]}")) }else if(!is.null(x$status)){ - subtext <- galah_grey(glue("\n + subtext <- galah_grey(glue::glue("\n status: {x$status[1]}")) }else{ subtext <- "" @@ -244,7 +245,7 @@ print.query_set <- function(x, ...){ galah_pink("`query_set` "), crayon::silver(glue("containing ")), ifelse(n_queries > 1, - crayon::silver(glue("{n_queries} queries:")), + crayon::silver(glue::glue("{n_queries} queries:")), crayon::silver("1 query:")))) purrr::map(x, function(a){ type_text <- galah_green(a$type) @@ -254,13 +255,13 @@ print.query_set <- function(x, ...){ if(sum(c(pretext_length, nchar(url_temp))) > 80){ url_temp <- paste0(substr(url_temp, 1, (80 - pretext_length - 3)), "...") } - subtext <- galah_grey(glue("url: {url_temp}")) + subtext <- galah_grey(glue::glue("url: {url_temp}")) }else if(!is.null(a$data)){ - subtext <- galah_grey(glue("data: {a$data[1]}")) + subtext <- galah_grey(glue::glue("data: {a$data[1]}")) }else{ subtext <- "" } - glue("{type_text} {subtext}") + glue::glue("{type_text} {subtext}") }) |> unlist() |> rlang::format_error_bullets() |> @@ -296,7 +297,7 @@ print.galah_config <- function(x, ...){ cat("\n") cli::cli_inform(galah_pink("Atlas")) atlas_text <- galah_green(x$atlas$organisation) - atlas_subtext <- galah_grey(glue("({x$atlas$acronym}), {x$atlas$region}")) + atlas_subtext <- galah_grey(glue::glue("({x$atlas$acronym}), {x$atlas$region}")) atlas_settings <- glue::glue("{atlas_text} {atlas_subtext}") |> rlang::format_error_bullets() |> cat() diff --git a/R/read_zip.R b/R/read_zip.R index fd7cd3e0..6e421425 100644 --- a/R/read_zip.R +++ b/R/read_zip.R @@ -29,16 +29,16 @@ read_zip <- function(file){ # basic checks if(missing(file)){ - rlang::abort("`file` is missing, with no default") + cli::cli_abort("`file` is missing, with no default") } if(!is.character(file) | length(file) > 1){ - rlang::abort("Argument `file` should be a length-1 character") + cli::cli_abort("Argument `file` should be a length-1 character") } if(!file.exists(file)){ - rlang::abort("`.zip` file not found") + cli::cli_abort("`.zip` file not found") } if(!grepl(".zip$", file)){ - rlang::abort("`file` should end in `.zip`") + cli::cli_abort("`file` should end in `.zip`") } # get names of files stored in .zip all_files <- utils::unzip(file, list = TRUE)$Name @@ -99,7 +99,7 @@ read_zip <- function(file){ trimws() # exit safely if(is.null(result)){ - rlang::abort("No data loaded") + cli::cli_abort("No data loaded") }else{ result } diff --git a/R/utilities_internal.R b/R/utilities_internal.R index fa334ec7..55f75259 100644 --- a/R/utilities_internal.R +++ b/R/utilities_internal.R @@ -144,6 +144,17 @@ source_type_id_lookup <- function(region){ "2004") # ALA default for galah } +#' @noRd +#' @keywords Internal +email_notify <- function() { + notify <- as.logical(potions::pour("package", "send_email")) + if (is.na(notify)) { + notify <- FALSE + } + # ala api requires lowercase + ifelse(notify, "true", "false") +} + ##---------------------------------------------------------------- ## Functions to change behaviour depending on selected `atlas` -- ##---------------------------------------------------------------- diff --git a/R/utilities_occurrences.R b/R/utilities_occurrences.R deleted file mode 100644 index a38b02b8..00000000 --- a/R/utilities_occurrences.R +++ /dev/null @@ -1,54 +0,0 @@ -# Q: delete this? - - -# check_count <- function(count, max_count, error_call = caller_env()) { -# if (count < 1) { -# abort("This query does not match any records.", call = error_call) -# } else if (count > max_count) { -# too_many_records(max_count) -# } else { -# if (pour("package", "verbose")) { -# count_text <- formatC(count, big.mark = ",", format = "f", digits = 0) -# inform(glue("This query will return {count_text} records")) -# } -# } -# } - -# too_many_records <- function(max_count){ -# max_text <- formatC(max_count, big.mark = ",", format = "f", digits = 0) -# bullets <- c( -# "Your data request was too large.", -# i = glue("A maximum of {max_text} records can be retrieved at once."), -# i = "Please narrow the query and try again." -# ) -# abort(bullets, call = caller_env()) -# } - - -email_notify <- function() { - notify <- as.logical(potions::pour("package", "send_email")) - if (is.na(notify)) { - notify <- FALSE - } - # ala api requires lowercase - ifelse(notify, "true", "false") -} - -# NOTE: Do we need this anymore? Delete? -occ_error_handler <- function(code, error_call = rlang::caller_env()) { - if (code == 403) { - bullets <- c( - "Status code 403 was returned.", - i = glue("Is the email you provided to `galah_config()` registered with the selected atlas?") - ) - inform(bullets) - } - if (code == 504) { - bullets <- c( - "Status code 504 was returned.", - i = "This usually means that the selected API is down.", - i = "If you continue to receive this error, please email support@ala.org.au" - ) - inform(bullets) - } -} \ No newline at end of file From 7a3439516c716d4a92a5a90f721d9538ef30c8d9 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Tue, 16 Sep 2025 10:55:03 +1000 Subject: [PATCH 28/94] Implement caching for all metadata types #282 --- R/as_query-metadata.R | 223 ++++++++++++-------- R/as_query.R | 13 +- R/check_internal_cache.R | 55 ----- R/collapse_lists.R | 48 +++-- R/collapse_query.R | 3 +- R/collect_metadata.R | 347 +++++++++++++++++-------------- R/onload.R | 8 +- R/utilities_caching.R | 65 ++++++ man/as_query.data_request.Rd | 3 + tests/testthat/test-caching.R | 40 ++++ tests/testthat/test-search_all.R | 337 +++++++++++++++++------------- tests/testthat/test-show_all.R | 2 +- 12 files changed, 660 insertions(+), 484 deletions(-) delete mode 100644 R/check_internal_cache.R create mode 100644 R/utilities_caching.R create mode 100644 tests/testthat/test-caching.R diff --git a/R/as_query-metadata.R b/R/as_query-metadata.R index 3579705a..5981f7f1 100644 --- a/R/as_query-metadata.R +++ b/R/as_query-metadata.R @@ -1,13 +1,48 @@ # These functions are called by `as_query.metadata_request()` +# Utility functions to build default forms of query + +#' Internal function to define a standard query +#' @param query_type the sting used to define that query type; see `show_all_apis()` +#' @noRd +#' @keywords Internal +default_query <- function(query_type){ + list(type = query_type, + url = url_lookup(query_type), + headers = build_headers()) +} + +#' Ditto for cache +#' @noRd +#' @keywords Internal +default_cache <- function(query_type){ + specific_type <- stringr::str_remove(query_type, "^metadata/") + list(type = query_type, + data = glue::glue("galah:::retrieve_cache(\"{specific_type}\")")) +} + +#' Modified version of `default_query()` that supports filtering +#' @noRd +#' @keywords Internal +filtered_query <- function(query_type, .query){ + url <- query_type |> + url_lookup() |> + httr2::url_parse() + url$query <- list(q = .query$filter$value[1]) + list(type = query_type, + url = httr2::url_build(url), + headers = build_headers()) +} + +# Actual functions called to build those queries + #' Internal function get a tibble of APIs #' @noRd #' @keywords Internal as_query_apis <- function(){ - result <- list(type = "metadata/apis", - data = "galah:::node_config") - class(result) <- "query" - return(result) + list(type = "metadata/apis", + data = "galah:::node_config") |> + as_query() } #' Internal function to create an assertions query @@ -15,51 +50,52 @@ as_query_apis <- function(){ #' @noRd #' @keywords Internal as_query_assertions <- function(){ + query_type <- "metadata/assertions" if(is_gbif()){ - result <- list(type = "metadata/assertions", + result <- list(type = query_type, data = "galah:::gbif_internal_archived$assertions") }else{ - update_needed <- internal_cache_update_needed("assertions") - if(update_needed){ - result <- list(type = "metadata/assertions", - url = url_lookup("metadata/assertions"), - headers = build_headers()) + if(check_if_cache_update_needed("assertions")){ + result <- default_query(query_type) + }else{ - result <- list(type = "metadata/assertions", - data = "galah:::check_internal_cache()$assertions") + result <- default_cache(query_type) } } - class(result) <- "query" - return(result) + as_query(result) } #' Internal function to create an atlases query #' @noRd #' @keywords Internal as_query_atlases <- function(){ - result <- list(type = "metadata/atlases", - data = "galah:::node_metadata") - class(result) <- "query" - return(result) + list(type = "metadata/atlases", + data = "galah:::node_metadata") |> + as_query() } #' Internal function to create a collections query #' @noRd #' @keywords Internal as_query_collections <- function(.query){ - url <- url_lookup("metadata/collections") + # set `type` + query_type <- "metadata/collections" + # If `filter()` is supplied, we always need a query if(is_gbif() & !missing(.query)){ if(!is.null(.query$filter)){ - url <- httr2::url_parse(url) - url$query <- list(q = .query$filter$value[1]) - url <- httr2::url_build(url) + result <- filtered_query(query_type, .query) + }else{ + result <- default_query(query_type) + } + # If no `filter()`, check cache instead + }else{ + if(check_if_cache_update_needed("collections")){ + result <- default_query(query_type) + }else{ + result <- default_cache(query_type) } } - result <- list(type = "metadata/collections", - url = url, - headers = build_headers()) - class(result) <- "query" - return(result) + as_query(result) } # NOTE: LA collectory functions do not accept `max` or `offset` # Therefore they cannot be paginated. GBIF collectory funs can. @@ -68,19 +104,24 @@ as_query_collections <- function(.query){ #' @noRd #' @keywords Internal as_query_datasets <- function(.query){ - url <- url_lookup("metadata/datasets") + # set `type` + query_type <- "metadata/datasets" + # If `filter()` is supplied, we always need a query if(is_gbif() & !missing(.query)){ if(!is.null(.query$filter)){ - url <- httr2::url_parse(url) - url$query <- list(q = .query$filter$value[1]) - url <- httr2::url_build(url) + result <- filtered_query(query_type, .query) + }else{ + result <- default_query(query_type) + } + # If no `filter()`, check cache instead + }else{ + if(check_if_cache_update_needed("datasets")){ + result <- default_query(query_type) + }else{ + result <- default_cache(query_type) } } - result <- list(type = "metadata/datasets", - url = url, - headers = build_headers()) - class(result) <- "query" - return(result) + as_query(result) } #' Internal function to create a fields query @@ -89,101 +130,100 @@ as_query_datasets <- function(.query){ #' @noRd #' @keywords Internal as_query_fields <- function(){ - update_needed <- internal_cache_update_needed("fields") - if(update_needed){ - result <- list(type = "metadata/fields", - url = url_lookup("metadata/fields"), - headers = build_headers()) + query_type <- "metadata/fields" + if(check_if_cache_update_needed("fields")){ + result <- default_query(query_type) }else{ - result <- list(type = "metadata/fields", - data = "galah:::check_internal_cache()$fields") + result <- default_cache(query_type) } - class(result) <- "query" - return(result) + as_query(result) } #' Internal function to create a licences query #' @noRd #' @keywords Internal as_query_licences <- function(){ - result <- list(type = "metadata/licences", - url = url_lookup("metadata/licences"), - headers = build_headers()) - class(result) <- "query" - return(result) + query_type <- "metadata/licences" + if(check_if_cache_update_needed("licences")){ + result <- default_query(query_type) + }else{ + result <- default_cache(query_type) + } + as_query(result) } #' Internal function to create a lists query #' @noRd #' @keywords Internal as_query_lists <- function(.query){ - url <- url_lookup("metadata/lists") |> - httr2::url_parse() - url$query <- list(max = 10000) - if(!missing(.query)){ - if(!is.null(.query$slice)){ - url$query <- list(max = .query$slice$slice_n) - } + query_type <- "metadata/lists" + if(check_if_cache_update_needed("lists")){ + url <- url_lookup(query_type) |> + httr2::url_parse() + url$query <- list(max = 10000) + if(!missing(.query)){ + if(!is.null(.query$slice)){ + url$query <- list(max = .query$slice$slice_n) + } + } + result <- list(type = query_type, + url = httr2::url_build(url), + headers = build_headers(), + slot_name = "lists") + }else{ + result <- default_cache(query_type) } - result <- list(type = "metadata/lists", - url = httr2::url_build(url), - headers = build_headers(), - slot_name = "lists") - class(result) <- "query" - return(result) + as_query(result) } #' Internal function to create a profiles query #' @noRd #' @keywords Internal as_query_profiles <- function(){ - update_needed <- internal_cache_update_needed("profiles") - if(update_needed){ - result <- list(type = "metadata/profiles", - url = url_lookup("metadata/profiles"), - headers = build_headers()) + query_type <- "metadata/profiles" + if(check_if_cache_update_needed("profiles")){ + result <- default_query(query_type) }else{ - result <- list(type = "metadata/profiles", - data = "galah:::check_internal_cache()$profiles") + result <- default_cache(query_type) } - class(result) <- "query" - return(result) + as_query(result) } #' Internal function to create a providers query #' @noRd #' @keywords Internal as_query_providers <- function(.query){ - url <- url_lookup("metadata/providers") + # set `type` + query_type <- "metadata/providers" + # If `filter()` is supplied, we always need a query if(is_gbif() & !missing(.query)){ if(!is.null(.query$filter)){ - url <- httr2::url_parse(url) - url$query <- list(q = .query$filter$value[1]) - url <- httr2::url_build(url) + result <- filtered_query(query_type, .query) + }else{ + result <- default_query(query_type) + } + # If no `filter()`, check cache instead + }else{ + if(check_if_cache_update_needed("providers")){ + result <- default_query(query_type) + }else{ + result <- default_cache(query_type) } } - result <- list(type = "metadata/providers", - url = url, - headers = build_headers()) - class(result) <- "query" - return(result) + as_query(result) } #' Internal function to create a reasons query #' @noRd #' @keywords Internal as_query_reasons <- function(){ - update_needed <- internal_cache_update_needed("reasons") - if(update_needed){ - result <- list(type = "metadata/reasons", - url = url_lookup("metadata/reasons"), - headers = build_headers()) + query_type <- "metadata/reasons" + if(check_if_cache_update_needed("reasons")){ + result <- default_query(query_type) }else{ - result <- list(type = "metadata/reasons", - data = "galah:::check_internal_cache()$reasons") + result <- default_cache(query_type) } - class(result) <- "query" - return(result) + as_query(result) } #' Internal function to create a ranks query @@ -197,6 +237,5 @@ as_query_ranks <- function(){ result <- list(type = "metadata/ranks", data = "galah:::galah_internal_archived$ranks") } - class(result) <- "query" - return(result) + as_query(result) } diff --git a/R/as_query.R b/R/as_query.R index f4ce8a0f..4ea4c54e 100644 --- a/R/as_query.R +++ b/R/as_query.R @@ -51,10 +51,17 @@ as_query <- function(x, ...){ UseMethod("as_query") } +#' @rdname as_query.data_request +#' @order 2 +as_query.list <- function(x){ + # TODO add some checks here? + structure(x, class = c("query", "list")) +} + #' @rdname as_query.data_request #' @param mint_doi Logical: should a DOI be minted for this download? Only #' applies to `type = "occurrences"` when atlas chosen is "ALA". -#' @order 2 +#' @order 3 #' @export as_query.data_request <- function(x, mint_doi = FALSE, @@ -77,7 +84,7 @@ as_query.data_request <- function(x, } #' @rdname as_query.data_request -#' @order 3 +#' @order 4 #' @export as_query.metadata_request <- function(x, ...){ switch(x$type, @@ -109,7 +116,7 @@ as_query.metadata_request <- function(x, ...){ #' @rdname as_query.data_request #' @param thumbnail Logical: should thumbnail-size images be returned? Defaults #' to `FALSE`, indicating full-size images are required. -#' @order 4 +#' @order 5 #' @export as_query.files_request <- function(x, thumbnail, diff --git a/R/check_internal_cache.R b/R/check_internal_cache.R deleted file mode 100644 index 98d63462..00000000 --- a/R/check_internal_cache.R +++ /dev/null @@ -1,55 +0,0 @@ -#' Internal function to store objects generated from some `request_metadata` calls -#' -#' This increases speed by ensuring that the atlas is only queried when needed. -#' When run with no arguments, it returns a list with currently stored objects. -#' When a named field is given, it stores that field in options("galah_internal") -#' @noRd -#' @keywords Internal -check_internal_cache <- function(...){ - - # set all options - ala_option_name <- "check_internal_cache" - current_options <- getOption(ala_option_name) - atlas <- potions::pour("atlas", "region") - user_options <- list(...) - - # load an archived version as the default - default_options <- galah_internal_cached # stored in R/sysdata.rda - # get0("galah_internal_cached", envir = asNamespace("galah")) # alternate code - - # deal with different kinds of query - if (length(user_options) == 0 && !is.null(current_options)) { - return(current_options) - } - if (is.null(current_options)) { - ## galah options have not been set yet, so set them to the defaults - current_options <- default_options - ## set the global option - temp <- list(current_options) - names(temp) <- ala_option_name - options(temp) - return(current_options) - } else { - # check all the options are valid, if so, set as options - for (x in names(user_options)) { - current_options[[x]] <- user_options[[x]] - } - ## set the global option - temp <- list(current_options) - names(temp) <- ala_option_name - } - options(temp) -} - -#' Internal function to decide whether to update the internal cache -#' @noRd -#' @keywords Internal -internal_cache_update_needed <- function(function_name){ - df <- check_internal_cache()[[function_name]] - is_local <- !is.null(attr(df, "ARCHIVED")) - is_wrong_atlas <- attr(df, "region") != potions::pour("atlas", "region") - is_too_short <- nrow(df) < 10 - result <- is_local | is_wrong_atlas | is_too_short # if any, update is needed - if(length(result) < 1){result <- TRUE} # bug catcher - result -} \ No newline at end of file diff --git a/R/collapse_lists.R b/R/collapse_lists.R index be40ecdb..0c9cb1bb 100644 --- a/R/collapse_lists.R +++ b/R/collapse_lists.R @@ -5,30 +5,34 @@ #' @noRd #' @keywords Internal collapse_lists <- function(.query){ - url <- httr2::url_parse(.query$url) - n_requested <- as.integer(url$query$max) - # make decisions about how much pagination is needed - if(n_requested <= 500){ # we haven't hit pagination limit + if(is.null(.query$url)){ + .query + }else{ + url <- httr2::url_parse(.query$url) + n_requested <- as.integer(url$query$max) + # make decisions about how much pagination is needed + if(n_requested <= 500){ # we haven't hit pagination limit + .query + }else{ # more lists are requested + n <- get_max_n(.query) + n_pages <- ceiling(n$max_requested / n$paginate) + offsets <- (seq_len(n_pages) - 1) * n$paginate + result <- tibble::tibble( + offset = offsets, + max = c( + rep(n$paginate, n_pages - 1), + n$max_requested - offsets[n_pages])) + result$url <- purrr::map( + split(result, seq_len(nrow(result))), + function(a){ + url$query <- list(offset = a$offset, max = a$max) + httr2::url_build(url) + }) |> + unlist() + .query$url <- dplyr::select(result, "url") + } .query - }else{ # more lists are requested - n <- get_max_n(.query) - n_pages <- ceiling(n$max_requested / n$paginate) - offsets <- (seq_len(n_pages) - 1) * n$paginate - result <- tibble::tibble( - offset = offsets, - max = c( - rep(n$paginate, n_pages - 1), - n$max_requested - offsets[n_pages])) - result$url <- purrr::map( - split(result, seq_len(nrow(result))), - function(a){ - url$query <- list(offset = a$offset, max = a$max) - httr2::url_build(url) - }) |> - unlist() - .query$url <- dplyr::select(result, "url") } - .query } #' Internal function to retrieve max number of entries for an API diff --git a/R/collapse_query.R b/R/collapse_query.R index f28636ec..684cd4ca 100644 --- a/R/collapse_query.R +++ b/R/collapse_query.R @@ -36,6 +36,7 @@ collapse_query <- function(x){ "metadata/profiles-unnest" = collapse_profile_values(x), # check this # some "metadata/" functions require pagination under some circumstances "metadata/lists" = collapse_lists(x), # always paginates - x # remaining "metadata/" functions are passed as-is # fixme to make a new object type + x # remaining "metadata/" functions are passed as-is + # fixme to make a new object type ) } \ No newline at end of file diff --git a/R/collect_metadata.R b/R/collect_metadata.R index 15e159f8..46e2acc2 100644 --- a/R/collect_metadata.R +++ b/R/collect_metadata.R @@ -1,13 +1,55 @@ -#' Internal function to `collect()` APIs +# Utilities + +#' Internal function to update attributes on a metadata tibble +#' as per `galah` requirements #' @noRd #' @keywords Internal -collect_apis <- function(.query){ - result <- .query$data |> +update_attributes <- function(df, type){ + attr(df, "call") <- type # needed for `show_values()` to work + attr(df, "region") <- potions::pour("atlas", "region") # needed for caching to work + df +} + +#' Often a `.query` will have a `data` slot in place of a `url` +#' This may call e.g. `retrieve_cache()` OR an internal object +#' Either way, the string will be a valid function call set in `as_query()` +#' Our job at this point is to parse that function, NOT just `retrieve_cache()` +#' @noRd +#' @keywords Internal +retrieve_internal_data <- function(.query){ + if(is.null(.query$data)){ + cli::cli_abort("Query is missing a `data` slot") + } + .query$data |> parse(text = _) |> eval() - attr(result, "call") <- "apis" - attr(result, "region") <- potions::pour("atlas", "region") - result +} + +#' Internal function to remove `list()` entries inside lists +#' This supports passing to `bind_rows()`, but loses data +#' @noRd +#' @keywords Internal +flat_lists_only <- function(x){ + purrr::map(x, + \(a){ + purrr::map(a, \(b){ + if(is.list(b)){ + NULL + }else{ + b + } + }) + }) +} + +# collect functions + +#' Internal function to `collect()` APIs +#' @noRd +#' @keywords Internal +collect_apis <- function(.query){ + retrieve_internal_data(.query) |> + update_attributes(type = "apis") } #' Internal function to `collect()` assertions @@ -15,11 +57,7 @@ collect_apis <- function(.query){ #' @keywords Internal collect_assertions <- function(.query){ if(!is.null(.query$data)){ - result <- .query$data |> - parse(text = _) |> - eval() - attr(result, "call") <- "assertions" # needed for `show_values()` to work - attr(result, "region") <- potions::pour("atlas", "region") # needed for caching to work + result <- retrieve_internal_data(.query) }else{ result <- purrr::map(query_API(.query), \(a){a[names(a) != "termsRequiredToTest"]}) |> @@ -27,9 +65,8 @@ collect_assertions <- function(.query){ names(result) <- rename_columns(names(result), type = "assertions") result <- result[wanted_columns("assertions")] result$type <- "assertions" - attr(result, "call") <- "assertions" # needed for `show_values()` to work - attr(result, "region") <- potions::pour("atlas", "region") # needed for caching to work - check_internal_cache(assertions = result) + result <- update_attributes(result, type = "assertions") + update_cache(assertions = result) } result } @@ -38,86 +75,73 @@ collect_assertions <- function(.query){ #' @noRd #' @keywords Internal collect_atlases <- function(.query){ - result <- .query$data |> - parse(text = _) |> - eval() - attr(result, "call") <- "atlases" - attr(result, "region") <- potions::pour("atlas", "region") - result + retrieve_internal_data(.query) |> + update_attributes(type = "atlases") } #' Internal function to `collect()` collections #' @noRd #' @keywords Internal collect_collections <- function(.query){ - if(is_gbif()){ - result <- query_API(.query) - if(any(names(result) == "results")){ # happens when `filter()` not specified - # Note: This assumes only one API call; will need more potentially - result <- purrr::pluck(result, "results") - } - result <- flat_lists_only(result) |> - dplyr::bind_rows() - }else if(potions::pour("atlas", "region", .pkg = "galah") == "France"){ - result <- query_API(.query) |> - purrr::pluck("_embedded", "producers") |> - unlist() - result <- tibble::tibble(name = result) + if(!is.null(.query$data)){ + retrieve_internal_data(.query) }else{ - result <- query_API(.query) |> - dplyr::bind_rows() - result_reordered <- dplyr::relocate(result, "uid") - result <- result_reordered |> - dplyr::rename("id" = "uid") + if(is_gbif()){ + result <- query_API(.query) + if(any(names(result) == "results")){ # happens when `filter()` not specified + # Note: This assumes only one API call; will need more potentially + result <- purrr::pluck(result, "results") + } + result <- flat_lists_only(result) |> + dplyr::bind_rows() + }else if(potions::pour("atlas", "region", .pkg = "galah") == "France"){ + result <- query_API(.query) |> + purrr::pluck("_embedded", "producers") |> + unlist() + result <- tibble::tibble(name = result) + }else{ + result <- query_API(.query) |> + dplyr::bind_rows() + result_reordered <- dplyr::relocate(result, "uid") + result <- result_reordered |> + dplyr::rename("id" = "uid") + } + result <- update_attributes(result, type = "collections") + update_cache(collections = result) + result } - attr(result, "call") <- "collections" - attr(result, "region") <- potions::pour("atlas", "region") - result -} - -#' Internal function to remove `list()` entries inside lists -#' This supports passing to `bind_rows()`, but loses data -#' @noRd -#' @keywords Internal -flat_lists_only <- function(x){ - purrr::map(x, - \(a){ - purrr::map(a, \(b){ - if(is.list(b)){ - NULL - }else{ - b - } - }) - }) } #' Internal function to `collect()` datasets #' @noRd #' @keywords Internal collect_datasets <- function(.query){ - result <- query_API(.query) - if(is_gbif()){ - if(any(names(result) == "results")){ # happens when `filter()` not specified - # Note: This assumes only one API call; will need more potentially - result <- purrr::pluck(result, "results") - } - result <- result |> - flat_lists_only() |> - dplyr::bind_rows() - }else if(potions::pour("atlas", "region", .pkg = "galah") == "France"){ - result <- result |> - purrr::pluck("_embedded", "datasets") |> - dplyr::bind_rows() + if(!is.null(.query$data)){ + retrieve_internal_data(.query) }else{ - result <- result |> - dplyr::bind_rows() |> - dplyr::relocate("uid") |> - dplyr::rename("id" = "uid") + result <- query_API(.query) + if(is_gbif()){ + if(any(names(result) == "results")){ # happens when `filter()` not specified + # Note: This assumes only one API call; will need more potentially + result <- purrr::pluck(result, "results") + } + result <- result |> + flat_lists_only() |> + dplyr::bind_rows() + }else if(potions::pour("atlas", "region", .pkg = "galah") == "France"){ + result <- result |> + purrr::pluck("_embedded", "datasets") |> + dplyr::bind_rows() + }else{ + result <- result |> + dplyr::bind_rows() |> + dplyr::relocate("uid") |> + dplyr::rename("id" = "uid") + } + result <- update_attributes(result, type = "datasets") + update_cache(datasets = result) + result } - attr(result, "call") <- "datasets" - attr(result, "region") <- potions::pour("atlas", "region") - result } #' Internal function to `collect()` distributions @@ -145,8 +169,8 @@ collect_distributions_metadata <- function(.query){ "label" = "area_name", "common_name" = "common_nam") |> dplyr::mutate("common_name" = trimws(.data$common_name)) # remove leading or trailing spaces - attr(result, "call") <- "distributions" - attr(result, "region") <- potions::pour("atlas", "region") + result <- update_attributes(result, type = "distributions") + update_cache(distributions = result) result } @@ -154,7 +178,9 @@ collect_distributions_metadata <- function(.query){ #' @noRd #' @keywords Internal collect_fields <- function(.query){ - if(!is.null(.query$url)){ # i.e. there is no cached `tibble` + if(!is.null(.query$data)){ + retrieve_internal_data(.query) + }else{ result <- query_API(.query) |> dplyr::bind_rows() @@ -180,13 +206,9 @@ collect_fields <- function(.query){ dplyr::bind_rows(galah_internal_archived$media, galah_internal_archived$other) } - - attr(result, "call") <- "fields" - attr(result, "region") <- potions::pour("atlas", "region") - check_internal_cache(fields = result) + result <- update_attributes(result, type = "fields") + update_cache(fields = result) result - }else{ # this should only happen when `data` slot is present in place of `url` - check_internal_cache()[["fields"]] } } @@ -194,65 +216,73 @@ collect_fields <- function(.query){ #' @noRd #' @keywords Internal collect_licences <- function(.query){ - result <- query_API(.query) - if(length(result) > 0){ - if (any(duplicated(names(result[[1]])))) { # remove duplicate columns (i.e. Spain atlas) - result <- purrr::map(result, \(x) x[unique(names(x))]) - } - result <- result |> - dplyr::bind_rows() |> - dplyr::select(dplyr::all_of(c("id", "name", "acronym", "url"))) |> - dplyr::arrange(result$id) + if(!is.null(.query$data)){ + retrieve_internal_data(.query) }else{ - result <- tibble::tibble(id = character(), - name = character(), - acronym = character(), - url = character()) + result <- query_API(.query) + if(length(result) > 0){ + if (any(duplicated(names(result[[1]])))) { # remove duplicate columns (i.e. Spain atlas) + result <- purrr::map(result, \(x) x[unique(names(x))]) + } + result <- result |> + dplyr::bind_rows() |> + dplyr::select(dplyr::all_of(c("id", "name", "acronym", "url"))) |> + dplyr::arrange(result$id) + }else{ + result <- tibble::tibble(id = character(), + name = character(), + acronym = character(), + url = character()) + } + result <- update_attributes(result, type = "licences") + update_cache(licences = result) + result } - attr(result, "call") <- "licences" - attr(result, "region") <- potions::pour("atlas", "region") - result } #' Internal function to `collect()` lists #' @noRd #' @keywords Internal collect_lists <- function(.query){ - if(inherits(.query$url, "data.frame")){ - result <- purrr::map(query_API(.query), - \(a){a$lists}) |> - dplyr::bind_rows() + if(!is.null(.query$data)){ + retrieve_internal_data(.query) }else{ - result <- query_API(.query) |> - purrr::pluck("lists") |> - dplyr::bind_rows() - } - if(any(colnames(result) == "dataResourceUid")){ - result <- result |> - dplyr::rename("species_list_uid" = "dataResourceUid") + # here we run and parse an API call + if(inherits(.query$url, "data.frame")){ + result <- purrr::map(query_API(.query), + \(a){a$lists}) |> + dplyr::bind_rows() + }else{ + result <- query_API(.query) |> + purrr::pluck("lists") |> + dplyr::bind_rows() + } + if(any(colnames(result) == "dataResourceUid")){ + result <- result |> + dplyr::rename("species_list_uid" = "dataResourceUid") + } + result <- update_attributes(result, type = "lists") + update_cache(lists = result) + result } - attr(result, "call") <- "lists" - attr(result, "region") <- potions::pour("atlas", "region") - result } #' Internal function to `collect()` profiles #' @noRd #' @keywords Internal collect_profiles <- function(.query){ - if(!is.null(.query$url)){ + if(!is.null(.query$data)){ + retrieve_internal_data(.query) + }else{ result <- query_API(.query) |> dplyr::bind_rows() result <- result |> dplyr::filter(!duplicated(result$id)) |> dplyr::arrange("id") |> dplyr::select(dplyr::all_of(wanted_columns(type = "profile"))) - attr(result, "call") <- "profiles" - attr(result, "region") <- potions::pour("atlas", "region") - check_internal_cache(show_all_profiles = result) + result <- update_attributes(result, type = "profiles") + update_cache(profiles = result) result - }else{ - check_internal_cache()[["profiles"]] } } @@ -260,62 +290,61 @@ collect_profiles <- function(.query){ #' @noRd #' @keywords Internal collect_providers <- function(.query){ - result <- query_API(.query) - if(is_gbif()){ - if(any(names(result) == "results")){ # happens when `filter()` not specified - # Note: This assumes only one API call; will need more potentially - result <- purrr::pluck(result, "results") - } - result <- result |> - flat_lists_only() |> - dplyr::bind_rows() - }else if(potions::pour("atlas", "region", .pkg = "galah") == "France"){ - result <- tibble::tibble(name = { - purrr::pluck(result, "_embedded", "providers") |> - unlist() - }) + if(!is.null(.query$data)){ + retrieve_internal_data(.query) }else{ - result <- result |> - dplyr::bind_rows() - if(nrow(result) > 0){ # exception added because this API isn't always populated (e.g. France) + result <- query_API(.query) + if(is_gbif()){ + if(any(names(result) == "results")){ # happens when `filter()` not specified + # Note: This assumes only one API call; will need more potentially + result <- purrr::pluck(result, "results") + } + result <- result |> + flat_lists_only() |> + dplyr::bind_rows() + }else if(potions::pour("atlas", "region", .pkg = "galah") == "France"){ + result <- tibble::tibble(name = { + purrr::pluck(result, "_embedded", "providers") |> + unlist() + }) + }else{ result <- result |> - dplyr::relocate("uid") |> - dplyr::rename("id" = "uid") + dplyr::bind_rows() + if(nrow(result) > 0){ # exception added because this API isn't always populated (e.g. France) + result <- result |> + dplyr::relocate("uid") |> + dplyr::rename("id" = "uid") + } } + result <- update_attributes(result, type = "providers") + update_cache(providers = result) + result } - attr(result, "call") <- "providers" - attr(result, "region") <- potions::pour("atlas", "region") - result } #' Internal function to `collect()` APIs #' @noRd #' @keywords Internal collect_ranks <- function(.query){ - result <- .query$data |> - parse(text = _) |> - eval() - attr(result, "call") <- "ranks" - attr(result, "region") <- potions::pour("atlas", "region") - result + retrieve_internal_data(.query) |> + update_attributes(type = "ranks") } #' Internal function to `collect()` reasons #' @noRd #' @keywords Internal collect_reasons <- function(.query){ - if(!is.null(.query$url)){ + if(!is.null(.query$data)){ + retrieve_internal_data(.query) + }else{ result <- query_API(.query) |> dplyr::bind_rows() result <- result |> dplyr::filter(!result$deprecated) |> dplyr::select(dplyr::all_of(wanted_columns("reasons"))) |> - arrange("id") - attr(result, "call") <- "reasons" - attr(result, "region") <- potions::pour("atlas", "region") - check_internal_cache(reasons = result) + arrange("id") |> + update_attributes(type = "reasons") + update_cache(reasons = result) result - }else{ - check_internal_cache()[["reasons"]] } -} +} \ No newline at end of file diff --git a/R/onload.R b/R/onload.R index d0ddf3e2..1215d509 100644 --- a/R/onload.R +++ b/R/onload.R @@ -7,13 +7,15 @@ # set up storage of standard information via {potions} potions::brew(.pkg = "galah") galah_config() # to cache defaults - options(list("check_internal_cache" = galah_internal_cached)) - + reset_cache() + # get information to display to the user + ## get the galah version, if we can galah_version <- "version unknown" suppressWarnings( try(galah_version <- utils::packageDescription("galah")[["Version"]], - silent = TRUE)) ## get the galah version, if we can + silent = TRUE)) + # show currently-selected atlas current_node <- potions::pour("atlas", .pkg = "galah") |> purrr::pluck("acronym") current_url <- show_all_atlases() |> diff --git a/R/utilities_caching.R b/R/utilities_caching.R new file mode 100644 index 00000000..aa565bdd --- /dev/null +++ b/R/utilities_caching.R @@ -0,0 +1,65 @@ +# Internal functions to store objects generated from some `request_metadata` calls +# This increases speed by ensuring that the atlas is only queried when needed. + +#' Internal function to overwrite a cached list in a specified place +#' @param x list +#' @noRd +#' @keywords Internal +overwrite_cache <- function(x){ + result <- list(x) + names(result) <- "check_internal_cache" + options(result) +} + +#' Internal function to reset cache to package defaults +#' @noRd +#' @keywords Internal +reset_cache <- function(){ + overwrite_cache(galah_internal_cached) +} + +#' Internal function to add a slot to current cache +#' @noRd +#' @keywords Internal +update_cache <- function(...){ + dots <- list(...) + if(length(dots) < 1){ + cli::cli_abort("cache not updated") + } + result <- retrieve_cache() + for(i in seq_along(dots)){ + result[[names(dots)[i]]] <- dots[[i]] + } + overwrite_cache(result) +} + +#' Internal function to retrieve current state of the cache +#' @noRd +#' @keywords Internal +retrieve_cache <- function(slot_name){ + full_cache <- getOption("check_internal_cache") + if(missing(slot_name)){ + full_cache + }else{ + if(any(names(full_cache) == slot_name)){ + full_cache[[slot_name]] + }else{ + NULL + } + } +} + +#' Internal function to decide whether to update the internal cache +#' @noRd +#' @keywords Internal +check_if_cache_update_needed <- function(function_name){ + df <- retrieve_cache(function_name) + is_local <- !is.null(attr(df, "ARCHIVED")) + is_wrong_atlas <- attr(df, "region") != potions::pour("atlas", "region") + is_too_short <- nrow(df) < 10 + result <- is_local | is_wrong_atlas | is_too_short # if any, update is needed + if(length(result) < 1){ # bug catcher + result <- TRUE + } + result +} \ No newline at end of file diff --git a/man/as_query.data_request.Rd b/man/as_query.data_request.Rd index 67f0feed..e2cbd8c9 100644 --- a/man/as_query.data_request.Rd +++ b/man/as_query.data_request.Rd @@ -3,12 +3,15 @@ \name{as_query.data_request} \alias{as_query.data_request} \alias{as_query} +\alias{as_query.list} \alias{as_query.metadata_request} \alias{as_query.files_request} \title{Convert an object to class \code{query}} \usage{ as_query(x, ...) +\method{as_query}{list}(x) + \method{as_query}{data_request}(x, mint_doi = FALSE, ...) \method{as_query}{metadata_request}(x, ...) diff --git a/tests/testthat/test-caching.R b/tests/testthat/test-caching.R new file mode 100644 index 00000000..2a70288e --- /dev/null +++ b/tests/testthat/test-caching.R @@ -0,0 +1,40 @@ +reset_cache() + +test_that("`retrieve_cache()` works without any arugments", { + x <- retrieve_cache() + expect_equal( + names(x), + c("assertions", "fields", "profiles", "reasons")) +}) + +test_that("`retrieve_cache()` works with a valid arugment", { + x <- retrieve_cache("fields") + expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) + expect_gt(nrow(x), 10) +}) + +test_that("`retrieve_cache()` returns `NULL` with an invalid arugment", { + x <- retrieve_cache("something") + expect_true(is.null(x)) +}) + +test_that("`update_cache()` adds arbitrary slots to the cache", { + update_cache(something = list(1, 2, 3)) + # check 'full' cache contains the requisite information + expect_equal( + names(retrieve_cache()), + c("assertions", "fields", "profiles", "reasons", "something")) + # check we can return a single slot + expect_equal( + retrieve_cache("something"), + list(1, 2, 3)) + reset_cache() +}) + +test_that("`overwrite_cache()` overwrites whole cache", { + overwrite_cache(list(x = 1, y = 2)) + x <- retrieve_cache() + expect_equal(names(x), c("x", "y")) + expect_equal(x, list(x = 1, y = 2)) + reset_cache() +}) \ No newline at end of file diff --git a/tests/testthat/test-search_all.R b/tests/testthat/test-search_all.R index 6c6948be..343807a0 100644 --- a/tests/testthat/test-search_all.R +++ b/tests/testthat/test-search_all.R @@ -1,7 +1,3 @@ -# NOTE: Tests are skipped on GH Actions using `skip_on_ci()` to avoid throttling -# API -# (these are probably not built for many fast queries if output is large) - test_that("search_all checks inputs, returns helpful error", { skip_if_offline(); skip_on_ci() expect_error(search_all(attributes, ""), "Unrecognised metadata requested") @@ -16,9 +12,9 @@ test_that("search_all returns correct output for type", { expect_s3_class(fields, c("tbl_df", "tbl", "data.frame")) expect_s3_class(reasons, c("tbl_df", "tbl", "data.frame")) expect_s3_class(profiles, c("tbl_df", "tbl", "data.frame")) - expect_equivalent(fields, search_fields("year")) - expect_equivalent(reasons, search_reasons("genus")) - expect_equivalent(profiles, search_profiles("ala")) + expect_equal(fields, search_fields("year")) + expect_equal(reasons, search_reasons("genus")) + expect_equal(profiles, search_profiles("ala")) expect_equal(attributes(fields)$call, "fields") expect_equal(attributes(reasons)$call, "reasons") expect_equal(attributes(profiles)$call, "profiles") @@ -49,180 +45,225 @@ test_that("search_assertions returns a filtered result", { expect_equal(search, search2) }) -test_that("search_apis returns a filtered result", { - skip_if_offline(); skip_on_ci() - all <- show_all_apis() - search <- search_apis("image") - search2 <- search_all(apis, "image") - search_result_check <- all(grepl(pattern = "image", search$url, - ignore.case = TRUE)) - - expect_lt(nrow(search), nrow(all)) - expect_equal(attributes(search)$call, "apis") - expect_s3_class(search, c("tbl_df", "tbl", "data.frame")) - expect_true(search_result_check) - expect_equal(search, search2) +test_that("`search_apis()` returns a filtered result", { + search_string <- "image" + result <- search_all(apis, search_string) + result |> + dplyr::pull(url) |> + tolower() |> + stringr::str_detect(search_string) |> + all() |> + expect_true() + expect_equal(attributes(result)$call, "apis") + expect_s3_class(result, c("tbl_df", "tbl", "data.frame")) + # check synonymous functions call the above syntax + # this is a parsimonious way to do checking, as it reduces API calls + deparse(search_apis) |> + stringr::str_detect("search_all\\(\"apis\"") |> + any() |> + expect_true() }) -test_that("search_atlases returns a filtered result", { - skip_if_offline(); skip_on_ci() - all <- show_all_atlases() - search <- search_atlases("guat") - search2 <- search_all(atlases, "guat") - search_result_check <- all(grepl(pattern = "guat", search$region, - ignore.case = TRUE)) - - expect_lt(nrow(search), nrow(all)) - expect_equal(attributes(search)$call, "atlases") - expect_s3_class(search, c("tbl_df", "tbl", "data.frame")) - expect_true(search_result_check) - expect_equal(search, search2) +test_that("`search_atlases()` returns a filtered result", { + search_string <- "guat" + result <- search_all(atlases, search_string) + result |> + dplyr::pull(region) |> + tolower() |> + stringr::str_detect(search_string) |> + all() |> + expect_true() + expect_equal(attributes(result)$call, "atlases") + expect_s3_class(result, c("tbl_df", "tbl", "data.frame")) + # check synonymous function calls the above syntax + deparse(search_atlases) |> + stringr::str_detect("search_all\\(\"atlases\"") |> + any() |> + expect_true() }) -test_that("search_collections returns a filtered result", { +test_that("`search_collections()` returns a filtered result", { skip_if_offline(); skip_on_ci() - all <- show_all_collections() - search <- search_collections("dna") - search2 <- search_all(collections, "dna") - search_result_check <- all(grepl(pattern = "dna", search$name, - ignore.case = TRUE)) - - expect_lt(nrow(search), nrow(all)) - expect_equal(attributes(search)$call, "collections") - expect_s3_class(search, c("tbl_df", "tbl", "data.frame")) - expect_true(search_result_check) - expect_equal(search, search2) + search_string <- "dna" + result <- search_all(collections, search_string) + result |> + dplyr::pull(name) |> + tolower() |> + stringr::str_detect(search_string) |> + all() |> + expect_true() + expect_equal(attributes(result)$call, "collections") + expect_s3_class(result, c("tbl_df", "tbl", "data.frame")) + # check synonymous function calls the above syntax + deparse(search_collections) |> + stringr::str_detect("search_all\\(\"collections\"") |> + any() |> + expect_true() }) -test_that("search_datasets returns a filtered result", { +test_that("`search_datasets()` returns a filtered result", { skip_if_offline(); skip_on_ci() - all <- show_all_datasets() - search <- search_datasets("endangered") - search2 <- search_all(datasets, "endangered") - search_result_check <- all(grepl(pattern = "endangered", search$name, - ignore.case = TRUE)) - - expect_lt(nrow(search), nrow(all)) - expect_equal(attributes(search)$call, "datasets") - expect_s3_class(search, c("tbl_df", "tbl", "data.frame")) - expect_true(search_result_check) - expect_equal(search, search2) + search_string <- "endangered" + result <- search_all(datasets, search_string) + result |> + dplyr::pull(name) |> + tolower() |> + stringr::str_detect(search_string) |> + all() |> + expect_true() + expect_equal(attributes(result)$call, "datasets") + expect_s3_class(result, c("tbl_df", "tbl", "data.frame")) + # check synonymous function calls the above syntax + deparse(search_datasets) |> + stringr::str_detect("search_all\\(\"datasets\"") |> + any() |> + expect_true() }) -test_that("search_fields returns a filtered result", { +test_that("`search_fields()` returns a filtered result", { skip_if_offline(); skip_on_ci() - all <- show_all_fields() - search <- search_fields("precipitation") - search2 <- search_all(fields, "precipitation") - search_result_check <- all(grepl(pattern = "precipitation", search$description, - ignore.case = TRUE)) - - expect_lt(nrow(search), nrow(all)) - expect_equal(attributes(search)$call, "fields") - expect_s3_class(search, c("tbl_df", "tbl", "data.frame")) - expect_true(search_result_check) - expect_equal(search, search2) + search_string <- "precipitation" + result <- search_all(fields, search_string) + result |> + dplyr::pull(description) |> + tolower() |> + stringr::str_detect(search_string) |> + all() |> + expect_true() + expect_equal(attributes(result)$call, "fields") + expect_s3_class(result, c("tbl_df", "tbl", "data.frame")) + # check synonymous function calls the above syntax + deparse(search_fields) |> + stringr::str_detect("search_all\\(\"fields\"") |> + any() |> + expect_true() }) -test_that("search_fields helpful warning with blank argument", { +test_that("`search_fields()` helpful warning with blank argument", { skip_if_offline(); skip_on_ci() expect_error(search_fields(), "We didn't detect a search") }) -test_that("search_licenses returns a filtered result", { +test_that("`search_licenses()` returns a filtered result", { skip_if_offline(); skip_on_ci() - all <- show_all_licences() - search <- search_licences("3.0") - search2 <- search_all(licences, "3.0") - search_result_check <- all(grepl(pattern = "3.0", search$acronym, - ignore.case = TRUE)) - - expect_lt(nrow(search), nrow(all)) - expect_equal(attributes(search)$call, "licences") - expect_s3_class(search, c("tbl_df", "tbl", "data.frame")) - expect_true(search_result_check) - expect_equal(search, search2) + search_string <- "3.0" + result <- search_all(licences, search_string) + result |> + dplyr::pull(acronym) |> + tolower() |> + stringr::str_detect(search_string) |> + all() |> + expect_true() + expect_equal(attributes(result)$call, "licences") + expect_s3_class(result, c("tbl_df", "tbl", "data.frame")) + # check synonymous function calls the above syntax + deparse(search_licences) |> + stringr::str_detect("search_all\\(\"licences\"") |> + any() |> + expect_true() }) -test_that("search_lists returns a filtered result", { +test_that("`search_lists()` returns a filtered result", { skip_if_offline(); skip_on_ci() - all_lists <- show_all_lists() - search1 <- search_lists("threatened") - search2 <- search_all(lists, "threatened") - # check whether search_lists is correctly detecting the target string - chr_lookup <- purrr::map(colnames(search1), is.character) |> + search_string <- "threatened" + quiet_lists_search <- purrr::quietly(search_lists) + result <- quiet_lists_search(search_string) |> + purrr::pluck("result") + # elaborate check for matching terms in any character column + chr_lookup <- purrr::map(colnames(result), + is.character) |> unlist() - chr_cols <- colnames(search1)[chr_lookup] - contains_threatened <- purrr::map(chr_cols, \(a){ - grepl(pattern = "threatened", search1[[a]], ignore.case = TRUE) + chr_cols <- colnames(result)[chr_lookup] + contains_term <- purrr::map(chr_cols, + \(a){ + result[[a]] |> + tolower() |> + stringr::str_detect("threatened") }) - search_result_check <- purrr::map( - purrr::list_transpose(contains_threatened), any) |> + purrr::map(purrr::list_transpose(contains_term), any) |> unlist() |> - all() - expect_lt(nrow(search1), nrow(all_lists)) - expect_equal(attributes(search1)$call, "lists") - expect_s3_class(search1, c("tbl_df", "tbl", "data.frame")) - expect_true(search_result_check) - expect_equal(search1, search2) + all() |> + expect_true() + expect_equal(attributes(result)$call, "lists") + expect_s3_class(result, c("tbl_df", "tbl", "data.frame")) + # check synonymous function calls the above syntax + deparse(search_lists) |> + stringr::str_detect("search_all\\(\"lists\"") |> + any() |> + expect_true() }) -test_that("search_reasons returns a filtered result", { +test_that("`search_reasons()` returns a filtered result", { skip_if_offline(); skip_on_ci() - all <- show_all_reasons() - search <- search_reasons("sci") - search2 <- search_all(reasons, "sci") - search_result_check <- all(grepl(pattern = "sci", search$name, - ignore.case = TRUE)) - - expect_lt(nrow(search), nrow(all)) - expect_equal(attributes(search)$call, "reasons") - expect_s3_class(search, c("tbl_df", "tbl", "data.frame")) - expect_true(search_result_check) - expect_equal(search, search2) + search_string <- "sci" + result <- search_all(reasons, search_string) + result |> + dplyr::pull(name) |> + tolower() |> + stringr::str_detect(search_string) |> + all() |> + expect_true() + expect_equal(attributes(result)$call, "reasons") + expect_s3_class(result, c("tbl_df", "tbl", "data.frame")) + # check synonymous function calls the above syntax + deparse(search_reasons) |> + stringr::str_detect("search_all\\(\"reasons\"") |> + any() |> + expect_true() }) -test_that("search_ranks returns a filtered result", { - skip_if_offline(); skip_on_ci() - all <- show_all_ranks() - search <- search_ranks("kingdom") - search2 <- search_all(ranks, "kingdom") - search_result_check <- all(grepl(pattern = "kingdom", search$name, - ignore.case = TRUE)) - - expect_lt(nrow(search), nrow(all)) - expect_equal(attributes(search)$call, "ranks") - expect_s3_class(search, c("tbl_df", "tbl", "data.frame")) - expect_true(search_result_check) - expect_equal(search, search2) +test_that("`search_ranks()` returns a filtered result", { + search_string <- "kingdom" + result <- search_all(ranks, search_string) + result |> + dplyr::pull(name) |> + tolower() |> + stringr::str_detect(search_string) |> + all() |> + expect_true() + expect_equal(attributes(result)$call, "ranks") + expect_s3_class(result, c("tbl_df", "tbl", "data.frame")) + # check synonymous function calls the above syntax + deparse(search_ranks) |> + stringr::str_detect("search_all\\(\"ranks\"") |> + any() |> + expect_true() }) -test_that("search_profiles returns a filtered result", { +test_that("`search_profiles()` returns a filtered result", { skip_if_offline(); skip_on_ci() - all <- show_all_profiles() - search <- search_profiles("base") - search2 <- search_all(profiles, "base") - search_result_check <- all(grepl(pattern = "base", search$description, - ignore.case = TRUE)) - - expect_lt(nrow(search), nrow(all)) - expect_equal(attributes(search)$call, "profiles") - expect_s3_class(search, c("tbl_df", "tbl", "data.frame")) - expect_true(search_result_check) - expect_equal(search, search2) + search_string <- "base" + result <- search_all(profiles, search_string) + result |> + dplyr::pull(description) |> + tolower() |> + stringr::str_detect(search_string) |> + all() |> + expect_true() + expect_equal(attributes(result)$call, "profiles") + expect_s3_class(result, c("tbl_df", "tbl", "data.frame")) + # check synonymous function calls the above syntax + deparse(search_profiles) |> + stringr::str_detect("search_all\\(\"profiles\"") |> + any() |> + expect_true() }) -test_that("search_providers returns a filtered result", { +test_that("`search_providers()` returns a filtered result", { skip_if_offline(); skip_on_ci() - all <- show_all_providers() - search <- search_providers("inaturalist") - search2 <- search_all(providers, "inaturalist") - search_result_check <- all(grepl(pattern = "inaturalist", search$name, - ignore.case = TRUE)) - - expect_lt(nrow(search), nrow(all)) - expect_equal(attributes(search)$call, "providers") - expect_s3_class(search, c("tbl_df", "tbl", "data.frame")) - expect_true(search_result_check) - expect_equal(search, search2) + search_string <- "inaturalist" + result <- search_all(providers, search_string) + result |> + dplyr::pull(name) |> + tolower() |> + stringr::str_detect(search_string) |> + all() |> + expect_true() + expect_equal(attributes(result)$call, "providers") + expect_s3_class(result, c("tbl_df", "tbl", "data.frame")) + # check synonymous function calls the above syntax + deparse(search_providers) |> + stringr::str_detect("search_all\\(\"providers\"") |> + any() |> + expect_true() }) diff --git a/tests/testthat/test-show_all.R b/tests/testthat/test-show_all.R index 5dda8230..d5b89132 100644 --- a/tests/testthat/test-show_all.R +++ b/tests/testthat/test-show_all.R @@ -11,7 +11,7 @@ test_that("show_all parses ... correctly", { skip_if_offline(); skip_on_ci() fields1 <- show_all(fields) fields2 <- show_all("fields") - expect_equivalent(fields1, fields2) + expect_equal(fields1, fields2) }) test_that("all show_all() functions return correctly with all syntax", { From a261e844a788a9de005abfa0516e70cc05666726 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Fri, 26 Sep 2025 17:14:25 +1000 Subject: [PATCH 29/94] Move to testthat edition 3 - commit 1 More modern test suite --- DESCRIPTION | 3 +- R/as_query-species_count.R | 2 +- R/atlas_media.R | 55 +++--- R/atlas_taxonomy.R | 4 +- R/check_queue.R | 8 +- R/collect_media.R | 4 +- R/galah_filter.R | 1 + man/filter.data_request.Rd | 1 + tests/testthat/test-atlas_counts.R | 78 ++++++--- tests/testthat/test-atlas_media.R | 94 ++++++---- tests/testthat/test-atlas_occurrences.R | 92 ++++++---- tests/testthat/test-atlas_species.R | 62 +++++-- tests/testthat/test-atlas_taxonomy.R | 12 +- tests/testthat/test-galah_apply_profile.R | 87 ++++++---- tests/testthat/test-galah_bbox.R | 74 ++++---- tests/testthat/test-galah_call.R | 2 - tests/testthat/test-galah_config.R | 8 +- tests/testthat/test-galah_filter.R | 198 +++++++++------------- tests/testthat/test-galah_geolocate.R | 29 +++- tests/testthat/test-galah_group_by.R | 39 +++-- tests/testthat/test-galah_identify.R | 108 ++++++------ tests/testthat/test-galah_polygon.R | 13 +- tests/testthat/test-galah_radius.R | 41 ++--- tests/testthat/test-galah_select.R | 108 ++++-------- tests/testthat/test-search_taxa.R | 122 ++++++++----- tests/testthat/test-show_values.R | 84 +++++---- 26 files changed, 740 insertions(+), 589 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 97cda488..694a283d 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Package: galah Type: Package Title: Biodiversity Data from the GBIF Node Network -Version: 2.1.2 +Version: 2.2.0.9999 Authors@R: c(person(given = "Martin", family = "Westgate", @@ -65,3 +65,4 @@ VignetteBuilder: knitr RoxygenNote: 7.3.2 Encoding: UTF-8 Roxygen: list(markdown = TRUE) +Config/testthat/edition: 3 diff --git a/R/as_query-species_count.R b/R/as_query-species_count.R index 8eb37b0b..9368cbf6 100644 --- a/R/as_query-species_count.R +++ b/R/as_query-species_count.R @@ -39,7 +39,7 @@ as_query_species_count_atlas <- function(identify = NULL, list(flimit = 1, facets = species_facets())) result <- list(type = "data/species-count", - url = url_build(url), + url = httr2::url_build(url), headers = build_headers(), filter = filter) }else{ diff --git a/R/atlas_media.R b/R/atlas_media.R index bc558a1b..4d35530f 100644 --- a/R/atlas_media.R +++ b/R/atlas_media.R @@ -23,37 +23,6 @@ atlas_media <- function(request = NULL, cli::cli_abort("You must specify a valid `filter()` to use `atlas_media()`") } - # ensure media filters are present - query_collapse <- update_media_filters(.query) - - # get occurrences - occ <- query_collapse |> - collect(wait = TRUE) |> - tidyr::unnest_longer(col = any_of(present_fields)) - - if(!any(colnames(occ) == "all_image_url")){ - occ$media_id <- build_media_id(occ) - } - - # collect media metadata - media <- request_metadata() |> - filter(media == occ) |> - collect() - - # join and return - if(any(colnames(occ) == "all_image_url")){ - occ <- dplyr::rename(occ, "media_id" = "all_image_url") - } - occ_media <- dplyr::right_join(occ, - media, - by = dplyr::join_by("media_id" == "image_id")) - dplyr::relocate(occ_media, "media_id", 1) -} - -#' Ensure media filters are present in the query, and add them if not -#' @noRd -#' @keywords Internal -update_media_filters <- function(.query){ # ensure media columns are present in `select` if(is.null(.query$select)){ .query <- update_data_request(.query, @@ -100,7 +69,29 @@ update_media_filters <- function(.query){ url$query$fq <- glue::glue("{url$query$fq} AND {media_fq}") query_collapse$url <- httr2::url_build(url) } - query_collapse + + # get occurrences + occ <- query_collapse |> + collect(wait = TRUE) |> + tidyr::unnest_longer(col = tidyselect::any_of(present_fields)) + + if(!any(colnames(occ) == "all_image_url")){ + occ$media_id <- build_media_id(occ) + } + + # collect media metadata + media <- request_metadata() |> + filter(media == occ) |> + collect() + + # join and return + if(any(colnames(occ) == "all_image_url")){ + occ <- dplyr::rename(occ, "media_id" = "all_image_url") + } + occ_media <- dplyr::right_join(occ, + media, + by = dplyr::join_by("media_id" == "image_id")) + dplyr::relocate(occ_media, "media_id", 1) } #' Set filters that work for media in each atlas diff --git a/R/atlas_taxonomy.R b/R/atlas_taxonomy.R index 806ba619..46ef85af 100644 --- a/R/atlas_taxonomy.R +++ b/R/atlas_taxonomy.R @@ -87,7 +87,7 @@ drill_down_taxonomy <- function(df, return(df) }else{ result <- children |> - dplyr::mutate(name = str_to_title(children$name), + dplyr::mutate(name = stringr::str_to_title(children$name), taxon_concept_id = children$guid, parent_taxon_concept_id = children$parentGuid) |> dplyr::select("name", @@ -101,7 +101,7 @@ drill_down_taxonomy <- function(df, if(nrow(result) < 1){ return(df) }else{ - result_list <- lapply( + result_list <- purrr::map( split(result, seq_len(nrow(result))), function(a){drill_down_taxonomy(a, down_to, diff --git a/R/check_queue.R b/R/check_queue.R index 213866cb..682c317c 100644 --- a/R/check_queue.R +++ b/R/check_queue.R @@ -38,9 +38,9 @@ check_queue_loop <- function(.query){ if(continue){ iter <- iter + 1 if(iter > 99){ - inform(c("No data were returned after 100 tries.", - i = "If you have saved this output using e.g. `x <- collect(.query)`,", - i = "you can try again later using `collect(x)`")) + cli::cli_inform(c("No data were returned after 100 tries.", + i = "If you have saved this output using e.g. `x <- collect(.query)`,", + i = "you can try again later using `collect(x)`")) return(.query) }else{ current_queue <- check_queue_size(.query, current_queue) @@ -70,7 +70,7 @@ check_queue_size <- function(.query, current_queue){ if(.query$queue_size < current_queue & .query$queue_size > 0){ current_queue <- .query$queue_size if(verbose){ - inform(glue("Queue length: {current_queue}")) + cli::cli_inform("Queue length: {current_queue}") } }else{ if(verbose){cat("-")} diff --git a/R/collect_media.R b/R/collect_media.R index be3ff3e0..0ee80c93 100644 --- a/R/collect_media.R +++ b/R/collect_media.R @@ -128,9 +128,7 @@ collect_media <- function(df, # suggest option to set directory in galah_config() user_directory <- potions::pour("package", "directory") if (stringr::str_detect(user_directory, "Temp")) { - inform( - cli::cli_text("{cli::col_magenta('To change which file directory media files are saved to, use `galah_config(directory = )`.')}") - ) + cli::cli_inform("{cli::col_magenta('To change which file directory media files are saved to, use `galah_config(directory = )`.')}") } request_files() |> diff --git a/R/galah_filter.R b/R/galah_filter.R index d12d730e..f387be1a 100644 --- a/R/galah_filter.R +++ b/R/galah_filter.R @@ -37,6 +37,7 @@ #' * `<` or `<=` (e.g. `year <= 2020`) #' * `OR` statements (e.g. `year == 2018 | year == 2020`) #' * `AND` statements (e.g. `year >= 2000 & year <= 2020`) +#' * Field names can be parsed from objects using `{{}}` syntax, e.g. `field <- "year"; value <- "2025"; galah_filter({{field}} == value)` #' #' Some general tips: #' * Separating statements with a comma is equivalent to an `AND` statement; diff --git a/man/filter.data_request.Rd b/man/filter.data_request.Rd index f4fb1ddc..279ba14c 100644 --- a/man/filter.data_request.Rd +++ b/man/filter.data_request.Rd @@ -51,6 +51,7 @@ by \code{collect()} or one of the \code{atlas_} family of functions (such as \item \code{<} or \code{<=} (e.g. \code{year <= 2020}) \item \code{OR} statements (e.g. \code{year == 2018 | year == 2020}) \item \code{AND} statements (e.g. \code{year >= 2000 & year <= 2020}) +\item Field names can be parsed from objects using \code{{{}}} syntax, e.g. \verb{field <- "year"; value <- "2025"; galah_filter(\{\{field\}\} == value)} } Some general tips: diff --git a/tests/testthat/test-atlas_counts.R b/tests/testthat/test-atlas_counts.R index 175d7688..4dcb94d1 100644 --- a/tests/testthat/test-atlas_counts.R +++ b/tests/testthat/test-atlas_counts.R @@ -1,5 +1,11 @@ galah_config(verbose = FALSE) +quiet_collect <- function(x){ + quiet_fun <- purrr::quietly(collect.data_request) + quiet_fun(x) |> + purrr::pluck("result") +} + test_that("`collapse()` doesn't ping an API for type = `'occurrences-count'`", { skip_if_offline(); skip_on_ci() result <- request_data() |> @@ -10,7 +16,7 @@ test_that("`collapse()` doesn't ping an API for type = `'occurrences-count'`", { expect_equal(result$type, "data/occurrences-count") }) -test_that("atlas_counts works with no arguments", { +test_that("`atlas_counts()` works with no arguments", { skip_if_offline(); skip_on_ci() count <- atlas_counts() expect_s3_class(count, c("tbl_df", "tbl", "data.frame")) @@ -89,7 +95,7 @@ test_that("`count()` handles 'species' as a 'group by' variable", { expect_true(all(grepl("^Perameles", counts$species))) }) -test_that("atlas_counts handles 'taxonConceptID' as a 'group by' variable", { +test_that("`atlas_counts()` handles 'taxonConceptID' as a 'group by' variable", { skip_if_offline(); skip_on_ci() counts <- galah_call() |> identify("Perameles") |> @@ -104,7 +110,7 @@ test_that("atlas_counts handles 'taxonConceptID' as a 'group by' variable", { }) # test added to address Issue #265 -test_that("atlas_counts handles `identify()` in combination with `OR` statements in `filter()`", { +test_that("`atlas_counts()` handles `identify()` in combination with `OR` statements in `filter()`", { skip_if_offline(); skip_on_ci() regions <- c("Dampierland","Wet Tropics") counts <- galah_call() |> @@ -119,7 +125,7 @@ test_that("atlas_counts handles `identify()` in combination with `OR` statements expect_true(all(counts$count > 0)) }) -test_that("atlas_counts returns same result with filter using `,` and `&`", { +test_that("`atlas_counts()` returns same result with filter using `,` and `&`", { skip_if_offline(); skip_on_ci() count_comma <- galah_call() |> filter(year >= 2010, year < 2020) |> @@ -132,8 +138,7 @@ test_that("atlas_counts returns same result with filter using `,` and `&`", { expect_equal(count_comma, count_and) }) -# Spatial not checked -test_that("atlas_counts filters correctly with galah_geolocate/galah_polygon", { +test_that("`atlas_counts()` filters correctly with galah_geolocate/galah_polygon", { skip_if_offline(); skip_on_ci() wkt <- "POLYGON ((146.5425 -42.63203, 146.8312 -43.13203, 147.4085 -43.13203, 147.6972 -42.63203, 147.4085 -42.13203, 146.8312 -42.13203, 146.5425 -42.63203))" |> sf::st_as_sfc() @@ -143,15 +148,15 @@ test_that("atlas_counts filters correctly with galah_geolocate/galah_polygon", { count() counts <- base_query |> collect() counts_filtered <- base_query |> - galah_geolocate(wkt) |> - collect() + geolocate(wkt) |> + quiet_collect() expect_s3_class(counts_filtered, c("tbl_df", "tbl", "data.frame")) count_1 <- counts_filtered$count[1] count_2 <- counts$count[1] expect_lt(count_1, count_2) }) -test_that("atlas_counts filters correctly with galah_geolocate/galah_bbox/galah_radius", { +test_that("`atlas_counts()` filters correctly with galah_geolocate/galah_bbox/galah_radius", { skip_if_offline(); skip_on_ci() wkt <- "POLYGON ((146.5425 -42.63203, 146.8312 -43.13203, 147.4085 -43.13203, 147.6972 -42.63203, 147.4085 -42.13203, 146.8312 -42.13203, 146.5425 -42.63203))" |> sf::st_as_sfc() @@ -161,14 +166,14 @@ test_that("atlas_counts filters correctly with galah_geolocate/galah_bbox/galah_ count() counts <- base_query |> collect() counts_filtered <- base_query |> - galah_geolocate(wkt, type = "bbox") |> - collect() + geolocate(wkt, type = "bbox") |> + quiet_collect() counts_filtered_radius <- base_query |> - galah_geolocate(lon = 147, - lat = -42.9, - radius = 20, - type = "radius") |> - collect() + geolocate(lon = 147, + lat = -42.9, + radius = 20, + type = "radius") |> + quiet_collect() expect_s3_class(counts_filtered, c("tbl_df", "tbl", "data.frame")) expect_s3_class(counts_filtered_radius, c("tbl_df", "tbl", "data.frame")) count_1 <- counts_filtered$count[1] @@ -177,7 +182,7 @@ test_that("atlas_counts filters correctly with galah_geolocate/galah_bbox/galah_ expect_lt(count_1, count_2, count_3) }) -test_that("atlas_counts returns species counts", { +test_that("`atlas_counts()` returns species counts", { skip_if_offline(); skip_on_ci() count_species <- galah_call(type = "species") |> count() |> @@ -255,10 +260,43 @@ test_that("order of `group_by()` doesn't affect result in `atlas_counts()", { ## so rows are not in the same order }) +test_that("`group_by()` works when > 1 `filter()`", { + skip_if_offline(); skip_on_ci() + chosen_species <- c("Eolophus roseicapilla", "Platycercus elegans") + x <- request_data() |> + filter(species == chosen_species) |> + group_by(species) |> + count() |> + collect() + expect_s3_class(x, c("tbl_df", "tbl", "data.frame")) + expect_equal(x$species, chosen_species) + expect_equal(colnames(x), c("species", "count")) + expect_equal(nrow(x), 2) + # previously, adding an additional field (`year` below) removed one species from resulting tibble + y <- request_data() |> + filter(species == chosen_species, + year == 2023) |> + group_by(species) |> + count() |> + collect() + expect_s3_class(y, c("tbl_df", "tbl", "data.frame")) + expect_equal(y$species, chosen_species) + expect_equal(colnames(y), c("species", "count")) + expect_equal(nrow(y), 2) + expect_true(all(x$count > y$count)) # extra filter + # compare to different syntax + z <- galah_call() |> + galah_filter(species == c("Eolophus roseicapilla", "Platycercus elegans"), + year == 2023) |> + galah_group_by(species) |> + atlas_counts() + expect_equal(y, z) +}) + ## BELOW HERE TESTS WILL FAIL # capture_requests("count_piped_2", { -# test_that("atlas_counts ignores superfluous piped arguments", { +# test_that("`atlas_counts()` ignores superfluous piped arguments", { # counts <- galah_call() |> # filter(year >= 2018) |> # group_by(year) |> @@ -271,7 +309,7 @@ test_that("order of `group_by()` doesn't affect result in `atlas_counts()", { # }) # }) -# test_that("atlas_counts handles pagination", { +# test_that("`atlas_counts()` handles pagination", { # vcr::use_cassette("count_with_pagination", { # counts <- galah_call() |> # group_by(year) |> @@ -286,3 +324,5 @@ test_that("order of `group_by()` doesn't affect result in `atlas_counts()", { # FIXME: check non-piped args work # FIXME: check `galah_` functions work # FIXME: check `atlas_counts` + +rm(quiet_collect) \ No newline at end of file diff --git a/tests/testthat/test-atlas_media.R b/tests/testthat/test-atlas_media.R index c3e3aa98..a5802e5f 100644 --- a/tests/testthat/test-atlas_media.R +++ b/tests/testthat/test-atlas_media.R @@ -1,14 +1,37 @@ +quiet_collapse <- function(x, ...){ + collapse_fun <- purrr::quietly(dplyr::collapse) + collapse_fun(x, ...) |> + purrr::pluck("result") +} +quiet_compute <- function(x){ + compute_fun <- purrr::quietly(dplyr::compute) + compute_fun(x) |> + purrr::pluck("result") +} +quiet_collect <- function(x, ...){ + purrr_collect <- purrr::quietly(dplyr::collect) + purrr_collect(x, ...) |> + purrr::pluck("result") +} +purrr_media <- purrr::quietly(atlas_media) +quiet_media <- function(...){ + purrr_media(...) |> + purrr::pluck("result") +} +purrr_collect_media <- purrr::quietly(collect_media) +purrr_config <- purrr::quietly(galah_config) + test_that("atlas_media fails when no filters are provided", { expect_error(atlas_media()) }) test_that("`atlas_media()` works", { skip_if_offline(); skip_on_ci() - galah_config(email = "ala4r@ala.org.au") + purrr_config(email = "ala4r@ala.org.au") media_data <- galah_call() |> identify("Microseris lanceolata") |> filter(year == 2019) |> - atlas_media() + quiet_media() expect_s3_class(media_data, c("tbl_df", "tbl", "data.frame")) expect_gte(nrow(media_data), 3) expect_gte(ncol(media_data), 3) @@ -19,15 +42,18 @@ test_that("collect_media suggests `galah_config(directory =)` when a temp folder atlas_query <- galah_call() |> identify("Anthochaera (Xanthomyza) phrygia") |> # Regent Honeyeater filter(year == 2012) |> - atlas_media() + quiet_media() media_dir <- "Temp" unlink(media_dir, recursive = TRUE) dir.create(media_dir) - galah_config(directory = media_dir) - expect_message( - collect_media(atlas_query), - cli::cli_text("{cli::col_magenta('To change which file directory media files are saved to, use `galah_config(directory = )`.')}") - ) + x <- purrr_config(directory = media_dir) # assigned to prevent message + # we don't run tests on this object + result <- purrr_collect_media(atlas_query) + result |> + purrr::pluck("messages") |> + stringr::str_detect("To change which file directory media files are saved to") |> + any() |> + expect_true() unlink(media_dir, recursive = TRUE) }) @@ -39,23 +65,23 @@ test_that("`collapse()` and `collect()` work for `type = 'media'`", { "/", list.files(dir, pattern = ".jpg$|.jpeg$")) |> file.info() |> - pull(size) |> + dplyr::pull(size) |> sum() } ## PART 1: request occurrence data - galah_config(email = "ala4r@ala.org.au") + purrr_config(email = "ala4r@ala.org.au") occ_collect <- request_data() |> identify("Litoria peronii") |> filter(year == 2010, !is.na(images)) |> select(group = "media") |> - collect(wait = TRUE) + quiet_collect(wait = TRUE) ## PART 2: request media metadata # collapse media_collapse <- request_metadata() |> filter(media == occ_collect) |> - collapse() + quiet_collapse() expect_true(inherits(media_collapse, "query")) expect_equal(length(media_collapse), 5) expect_equal(names(media_collapse), @@ -66,7 +92,7 @@ test_that("`collapse()` and `collect()` work for `type = 'media'`", { "filter")) expect_true(media_collapse$type == "metadata/media") # compute - media_compute <- compute(media_collapse) + media_compute <- quiet_compute(media_collapse) expect_true(inherits(media_compute, "computed_query")) expect_equal(length(media_compute), 5) expect_equal(names(media_compute), @@ -76,7 +102,7 @@ test_that("`collapse()` and `collect()` work for `type = 'media'`", { "body", "filter")) # collect - media_collect <- collect(media_compute) + media_collect <- quiet_collect(media_compute) expect_s3_class(media_collect, c("tbl_df", "tbl", "data.frame")) expect_gte(nrow(media_collect), 1) expect_gte(ncol(media_collect), 6) @@ -86,23 +112,23 @@ test_that("`collapse()` and `collect()` work for `type = 'media'`", { media_dir <- "test_media" unlink(media_dir, recursive = TRUE) dir.create(media_dir) - galah_config(directory = media_dir) + purrr_config(directory = media_dir) # collapse df <- slice_head(media_collect, n = 3) files_collapse <- request_files() |> filter(media == df) |> - collapse(thumbnail = TRUE) + quiet_collapse(thumbnail = TRUE) expect_true(inherits(files_collapse, "query")) expect_equal(length(files_collapse), 3) expect_equal(names(files_collapse), c("type", "url", "headers")) expect_equal(files_collapse$type, "files/media") # compute - files_compute <- compute(files_collapse) + files_compute <- quiet_compute(files_collapse) expect_true(inherits(files_compute, "computed_query")) expect_equal(length(files_compute), 3) expect_equal(names(files_compute), c("type", "url", "headers")) # collect - files_collect <- collect(files_compute) + files_collect <- quiet_collect(files_compute) expect_s3_class(files_collect, c("tbl_df", "tbl", "data.frame")) expect_gte(nrow(files_collect), 1) expect_gte(ncol(files_collect), 2) @@ -111,16 +137,20 @@ test_that("`collapse()` and `collect()` work for `type = 'media'`", { # check passing `thumbnail` via `collect()`, filesize files_collapse <- request_files() |> filter(media == df) |> - collect(thumbnail = FALSE) + quiet_collect(thumbnail = FALSE) fullsize <- get_image_sizes(media_dir) expect_lt(thumbsize, fullsize) # PART 4: check collect_media() expect_error(collect_media(df, path = NULL)) # check gives error when `path` is set - expect_message(collect_media(df), - "Downloaded 3 files successfully") + result <- purrr_collect_media(df) + result |> + purrr::pluck("messages") |> + stringr::str_detect("Downloaded 3 files successfully") |> + any() |> + expect_true() fullsize <- get_image_sizes(media_dir) - collect_media(df, thumbnail = TRUE) + purrr_collect_media(df, thumbnail = TRUE) thumbsize <- get_image_sizes(media_dir) expect_lt(thumbsize, fullsize) unlink(media_dir, recursive = TRUE) @@ -128,7 +158,8 @@ test_that("`collapse()` and `collect()` work for `type = 'media'`", { test_that("atlas_media gives a warning when old arguments are used", { skip_if_offline(); skip_on_ci() - galah_config(email = "ala4r@ala.org.au", atlas = "Australia") + x <- purrr_config(email = "ala4r@ala.org.au", + atlas = "Australia") expect_error({ media_data <- atlas_media( identify = galah_identify("Microseris lanceolata"), @@ -146,14 +177,14 @@ test_that("collect_media handles different file formats", { media_data <- galah_call() |> identify("Regent Honeyeater") |> filter(year == 2024) |> - atlas_media() + quiet_media() # sample one of each multimedia type to shorten testing time media_data <- media_data |> dplyr::group_by(multimedia) |> dplyr::sample_n(size = 1) expect_equal(sort(unique(media_data$multimedia)), c("Image", "Image | Sound", "Sound")) - collect_media(media_data, thumbnail = TRUE) + result <- purrr_collect_media(media_data, thumbnail = TRUE) downloads <- list.files(path = media_dir) expect_true(any(grepl(".mpg$", downloads))) # sounds expect_true(any(grepl(".jpg$", downloads))) # images @@ -165,15 +196,19 @@ test_that("collect_media handles different file formats", { test_that("collect_media handles thumbnails", { skip_if_offline(); skip_on_ci() media_dir <- "test_media" - galah_config(email = "ala4r@ala.org.au", + purrr_config(email = "ala4r@ala.org.au", directory = media_dir) z <- galah_call() |> identify("Candovia aberrata") |> filter(year == 2023) |> - atlas_media() + quiet_media() # successfully downloads, messages number of failed downloads - expect_message(collect_media(z, thumbnail = TRUE), - "Failed 3 downloads") + result <- purrr_collect_media(z) + result |> + purrr::pluck("messages") |> + stringr::str_detect("Downloaded 31 files successfully") |> + any() |> + expect_true() downloads <- list.files(path = media_dir) expect_true(any(grepl(".jpg$", downloads))) # images unlink(media_dir, recursive = TRUE) @@ -186,3 +221,4 @@ cache_dir <- tempfile() dir.create(cache_dir) galah_config(directory = cache_dir) rm(cache_dir) +rm(quiet_media, purrr_collect_media, purrr_config) \ No newline at end of file diff --git a/tests/testthat/test-atlas_occurrences.R b/tests/testthat/test-atlas_occurrences.R index d2077254..1d8a3717 100644 --- a/tests/testthat/test-atlas_occurrences.R +++ b/tests/testthat/test-atlas_occurrences.R @@ -1,4 +1,26 @@ -test_that("atlas_occurrences fails nicely if no email is provided", { +# set up quiet functions for testing reasons +quiet_collapse <- function(x){ + collapse_fun <- purrr::quietly(dplyr::collapse) + collapse_fun(x) |> + purrr::pluck("result") +} +quiet_compute <- function(x){ + compute_fun <- purrr::quietly(dplyr::compute) + compute_fun(x) |> + purrr::pluck("result") +} +quiet_collect <- function(x, ...){ + purrr_collect <- purrr::quietly(dplyr::collect) + purrr_collect(x, ...) |> + purrr::pluck("result") +} +quiet_occurrences <- function(...){ + occ_fun <- purrr::quietly(atlas_occurrences) + occ_fun(...) |> + purrr::pluck("result") +} + +test_that("`atlas_occurrences()` fails nicely if no email is provided", { galah_config(email = "", run_checks = FALSE) expect_error({ galah_call() |> @@ -8,12 +30,12 @@ test_that("atlas_occurrences fails nicely if no email is provided", { galah_config(email = "ala4r@ala.org.au", run_checks = TRUE) }) -test_that("atlas_occurrences doesn't allow large downloads", { +test_that("`atlas_occurrences()` doesn't allow large downloads", { galah_config(atlas = "Australia") expect_error(atlas_occurrences()) }) -test_that("atlas_occurrences gives a nice error for invalid emails", { +test_that("`atlas_occurrences()` gives a nice error for invalid emails", { galah_config(email = "test@test.org.au") expect_error({ galah_call() |> @@ -45,7 +67,7 @@ test_that("`compute(type = 'occurrences')` works", { expect_equal(length(query_collapse), 4) expect_equal(query_collapse$type, "data/occurrences") # compute - response <- compute(base_query) + response <- quiet_compute(base_query) expect_true(inherits(response, "computed_query")) expect_true(response$type == "data/occurrences") expect_equal(names(response), @@ -60,21 +82,21 @@ test_that("`compute(type = 'occurrences')` works", { }) # test all filters and type of columns in one call -test_that("atlas_occurrences accepts all narrowing functions inline", { +test_that("`atlas_occurrences()` accepts all narrowing functions inline", { skip_if_offline(); skip_on_ci() expected_cols <- c("decimalLatitude", "decimalLongitude", "eventDate", - "scientificName", "taxonConceptID", "recordID", - "dataResourceName", "occurrenceStatus", "stateProvince", - "ZERO_COORDINATE") + "basisOfRecord", "scientificName", "taxonConceptID", + "recordID", "dataResourceName", "occurrenceStatus", + "stateProvince", "ZERO_COORDINATE") poly <- "POLYGON((146.7 -34.6,147.9 -34.6,147.9 -35.7,146.7 -35.7,146.7 -34.6))" - base_query <- galah_call(type = "occurrences") |> + base_query <- galah_call() |> identify("Polytelis swainsonii") |> filter(year == 2018) |> select(group = "basic", stateProvince, ZERO_COORDINATE) |> st_crop(poly) # w <- collapse(base_query) # collect with wait = FALSE - ensure `type` is specified - x <- compute(base_query) + x <- quiet_compute(base_query) expect_equal(names(x), c("type", "status", @@ -86,19 +108,19 @@ test_that("atlas_occurrences accepts all narrowing functions inline", { "fields")) expect_s3_class(x, "computed_query") # collect with wait = TRUE - y <- collect(x, wait = TRUE) + y <- quiet_collect(x, wait = TRUE) expect_s3_class(y, c("tbl_df", "tbl", "data.frame")) expect_setequal(names(y), expected_cols) expect_equal(unique(y$stateProvince), "New South Wales") }) # repeat above using `galah_` functions -test_that("atlas_occurrences accepts all narrowing functions in pipe", { +test_that("`atlas_occurrences()` accepts all narrowing functions in pipe", { skip_if_offline(); skip_on_ci() expected_cols <- c("decimalLatitude", "decimalLongitude", "eventDate", - "scientificName", "taxonConceptID", "recordID", - "dataResourceName", "occurrenceStatus", "stateProvince", - "ZERO_COORDINATE", "COORDINATE_INVALID") + "basisOfRecord", "scientificName", "taxonConceptID", + "recordID", "dataResourceName", "occurrenceStatus", + "stateProvince", "ZERO_COORDINATE", "COORDINATE_INVALID") poly <- "POLYGON((146.7 -34.6,147.9 -34.6,147.9 -35.7,146.7 -35.7,146.7 -34.6))" occ <- galah_call() |> galah_filter(year >= 2018) |> @@ -108,12 +130,12 @@ test_that("atlas_occurrences accepts all narrowing functions in pipe", { COORDINATE_INVALID) |> galah_identify("Polytelis swainsonii") |> galah_geolocate(poly) |> - atlas_occurrences() + quiet_occurrences() expect_setequal(names(occ), expected_cols) expect_equal(unique(occ$stateProvince), "New South Wales") }) -test_that("atlas_occurrences() and friends accept a file name", { +test_that("`atlas_occurrences()`() and friends accept a file name", { skip_if_offline(); skip_on_ci() # set up directory for testing purposes directory <- "TEMP" @@ -128,23 +150,23 @@ test_that("atlas_occurrences() and friends accept a file name", { # base_query |> count() |> collect() # n = 49 on 2023-11-15 # test `atlas_occurrences` occ1 <- base_query |> - atlas_occurrences(file = "crinia_file") + quiet_occurrences(file = "crinia_file") expect_s3_class(occ1, c("tbl_df", "tbl", "data.frame")) expect_true(any(list.files(directory) == "crinia_file.zip")) # test `collect` - occ2 <- base_query |> collect(file = "crinia_collect") + occ2 <- base_query |> quiet_collect(file = "crinia_collect") expect_equal(occ1, occ2) expect_true(any(list.files(directory) == "crinia_collect.zip")) # test DOIs doi <- "10.26197/ala.0c1e8744-a639-47f1-9a5f-5610017ba060" - occ3 <- atlas_occurrences(doi = doi, file = "test_doi") + occ3 <- quiet_occurrences(doi = doi, file = "test_doi") expect_true(any(list.files(directory) == "test_doi.zip")) # doi with collect - occ3 <- atlas_occurrences(doi = doi, file = "test_doi") + occ3 <- quiet_occurrences(doi = doi, file = "test_doi") expect_true(any(list.files(directory) == "test_doi.zip")) occ4 <- request_data() |> filter(doi == doi) |> - collect(file = "test_doi2") + quiet_collect(file = "test_doi2") expect_equal(occ3, occ4) expect_true(any(list.files(directory) == "test_doi2.zip")) # clean up @@ -155,29 +177,31 @@ test_that("atlas_occurrences() and friends accept a file name", { rm(cache_dir) }) -test_that("atlas_occurrences() errors with an invalid DOI", { +test_that("`atlas_occurrences()`() errors with an invalid DOI", { expect_error(atlas_occurrences(doi = "random_doi")) }) -test_that("atlas_occurrences downloads data from a DOI", { +test_that("`atlas_occurrences()` downloads data from a DOI", { skip_if_offline(); skip_on_ci() doi <- "10.26197/ala.0c1e8744-a639-47f1-9a5f-5610017ba060" - result1 <- atlas_occurrences(doi = doi) + result1 <- quiet_occurrences(doi = doi) expect_s3_class(result1, c("tbl_df", "tbl", "data.frame" )) expect_equal(nrow(result1), 9) expect_equal(ncol(result1), 68) # and with other syntax result2 <- request_data() |> filter(doi == doi) |> - collapse() + quiet_collapse() expect_equal(length(result2), 4) expect_s3_class(result2, "query") expect_equal(result2$type, "data/occurrences-doi") - result3 <- collect(result2) + result3 <- quiet_collect(result2) expect_equal(result1, result3) # TODO add file name tests }) +# TODO check DOIs still placed correctly in as_query(), collapse() etc + test_that("`atlas_occurrences()` places DOI in `attr()` correctly", { skip_if_offline(); skip_on_ci() directory <- "TEMP" @@ -188,7 +212,7 @@ test_that("`atlas_occurrences()` places DOI in `attr()` correctly", { identify("Vulpes vulpes") |> filter(year <= 1900, basisOfRecord == "PRESERVED_SPECIMEN") |> - collect(mint_doi = TRUE) + quiet_collect(mint_doi = TRUE) y <- attr(x, "doi") expect_false(is.null(y)) expect_true(grepl("^https://doi.org/", y)) @@ -198,7 +222,7 @@ test_that("`atlas_occurrences()` places DOI in `attr()` correctly", { identify("Vulpes vulpes") |> filter(year <= 1900, basisOfRecord == "PRESERVED_SPECIMEN") |> - atlas_occurrences(mint_doi = TRUE) + quiet_occurrences(mint_doi = TRUE) y <- attr(x, "doi") expect_false(is.null(y)) expect_true(grepl("^https://doi.org/", y)) @@ -209,7 +233,7 @@ test_that("`atlas_occurrences()` places DOI in `attr()` correctly", { rm(cache_dir) }) -test_that("atlas_occurrences() doesn't return secret information", { +test_that("`atlas_occurrences()`() doesn't return secret information", { skip_if_offline(); skip_on_ci() RUN <- FALSE skip_if((!file.exists("testdata/SECRETS.txt") | !RUN), @@ -229,13 +253,13 @@ test_that("atlas_occurrences() doesn't return secret information", { group = "basic") x_start <- collapse(data_request) # option to add tests here to ensure url is correctly constructed - x <- collect(x_start) + x <- quiet_collect(x_start) # import email address from SECRETS and check again # NOTE: SECRETS.txt has been added to .gitignore - # It should never be placed on GitHub + # **It should never be placed on GitHub** email <- readLines("testdata/SECRETS.txt", warn = FALSE) galah_config(email = email) - y <- collect(data_request) + y <- quiet_collect(data_request) expect_equal(nrow(x), nrow(y)) expect_equal(ncol(x), ncol(y)) expect_equal(colnames(x), colnames(y)) @@ -243,3 +267,5 @@ test_that("atlas_occurrences() doesn't return secret information", { # reset galah_config(email = "ala4r@ala.org.au") }) + +rm(quiet_collapse, quiet_compute, quiet_collect, quiet_occurrences) \ No newline at end of file diff --git a/tests/testthat/test-atlas_species.R b/tests/testthat/test-atlas_species.R index 0e6548c1..bac61d5e 100644 --- a/tests/testthat/test-atlas_species.R +++ b/tests/testthat/test-atlas_species.R @@ -5,19 +5,50 @@ test_that("atlas_species fails nicely if no email is provided", { galah_config(email = "ala4r@ala.org.au") }) -test_that("atlas_species returns a tibble", { +test_that("`atlas_species()` returns a tibble", { skip_if_offline(); skip_on_ci() species <- atlas_species(identify = galah_identify("Osphranter")) expect_s3_class(species, c("tbl_df", "tbl", "data.frame")) expect_gt(nrow(species), 1) }) -test_that("atlas_species returns correct results when piped", { +test_that("`select()` works for type = 'species' with no arguments", { + skip_if_offline(); skip_on_ci() + x <- galah_call(type = "species") |> + identify("Crinia") |> + select() |> + collect() + expect_equal(colnames(x), "taxon_concept_id") + expect_gt(nrow(x), 10) +}) + +test_that("`select()` works for type = 'species' with `counts`", { + skip_if_offline(); skip_on_ci() + x <- galah_call(type = "species") |> + identify("Crinia") |> + select(counts) |> + collect() + expect_equal(colnames(x), c("taxon_concept_id", "count")) + expect_gt(nrow(x), 10) +}) + + +test_that("`select()` works for type = 'species' with group = 'taxonomy'", { + skip_if_offline(); skip_on_ci() + x <- galah_call(type = "species") |> + identify("Crinia") |> + select(counts, lists, group = "taxonomy") |> + collect() + expect_true(all(c("taxon_concept_id", "count", "kingdom", "phylum") %in% colnames(x))) + expect_gt(nrow(x), 10) +}) + +test_that("`atlas_species()` returns correct results when piped", { skip_if_offline(); skip_on_ci() galah_config(run_checks = TRUE) species <- galah_call() |> - galah_identify("perameles") |> - galah_filter(year > 2000) |> + identify("perameles") |> + filter(year > 2000) |> atlas_species() expected_species <- c("Perameles nasuta", "Perameles gunnii", @@ -34,15 +65,15 @@ test_that("atlas_species returns correct results when piped", { expect_s3_class(species, c("tbl_df", "tbl", "data.frame")) }) -test_that("atlas_species returns correct results filtered by galah_geolocate", { +test_that("`atlas_species()` returns correct results filtered by galah_geolocate", { skip_if_offline(); skip_on_ci() galah_config(run_checks = TRUE) wkt <- "POLYGON ((146.5425 -42.63203, 146.8312 -43.13203, 147.4085 -43.13203, 147.6972 -42.63203, 147.4085 -42.13203, 146.8312 -42.13203, 146.5425 -42.63203))" species <- galah_call() |> - galah_identify("perameles") |> - galah_filter(year > 2000) |> - galah_geolocate(wkt) |> + identify("perameles") |> + filter(year > 2000) |> + geolocate(wkt) |> atlas_species() expected_species <- c("Perameles gunnii") expected_cols <- c("taxon_concept_id", "species_name", @@ -55,13 +86,13 @@ test_that("atlas_species returns correct results filtered by galah_geolocate", { expect_s3_class(species, c("tbl_df", "tbl", "data.frame")) }) -test_that("atlas_species works when no species are present", { +test_that("`atlas_species()` works when no species are present", { skip_if_offline(); skip_on_ci() galah_config(email = "ala4r@ala.org.au") galah_config(run_checks = TRUE) result <- galah_call() |> - galah_identify("eolophus") |> - galah_filter(cl1048 == "Kimberley") |> + identify("eolophus") |> + filter(cl1048 == "Kimberley") |> atlas_species() expect_s3_class(result, c("tbl_df", "tbl", "data.frame")) }) @@ -69,10 +100,9 @@ test_that("atlas_species works when no species are present", { test_that("collapse -> compute -> collect workflow is functional", { skip_if_offline(); skip_on_ci() galah_config(email = "ala4r@ala.org.au") - query <- galah_call(method = "data", - type = "species") |> - galah_identify("perameles") |> - galah_filter(year > 2000) + query <- galah_call(type = "species") |> + identify("perameles") |> + filter(year > 2000) species_collapse <- query |> collapse() species_compute <- species_collapse |> compute() species_collect <- species_compute |> collect() @@ -116,7 +146,7 @@ test_that("atlas_species reformats column names when empty tibble is returned", expect_s3_class(species, c("tbl_df", "tbl", "data.frame")) }) -test_that("group_by works on occurrences", { +test_that("`group_by()` works on occurrences", { skip_if_offline(); skip_on_ci() # compare group_by with atlas_species x <- galah_call() |> diff --git a/tests/testthat/test-atlas_taxonomy.R b/tests/testthat/test-atlas_taxonomy.R index e73c5e60..e7dea4f6 100644 --- a/tests/testthat/test-atlas_taxonomy.R +++ b/tests/testthat/test-atlas_taxonomy.R @@ -1,4 +1,4 @@ -test_that("atlas_taxonomy 'identify' must be specified", { +test_that("`atlas_taxonomy()` 'identify' must be specified", { expect_error({ galah_call() |> filter(rank == "kingdom") |> @@ -6,7 +6,7 @@ test_that("atlas_taxonomy 'identify' must be specified", { }) }) -test_that("atlas_taxonomy 'filter' must be specified", { +test_that("`atlas_taxonomy()` 'filter' must be specified", { expect_error({ galah_call() |> identify("Animalia") |> @@ -14,7 +14,7 @@ test_that("atlas_taxonomy 'filter' must be specified", { }) }) -test_that("atlas_taxonomy requires a single taxon", { +test_that("`atlas_taxonomy()` requires a single taxon", { expect_error({ galah_call() |> identify("Animalia", "Plantae") |> @@ -23,18 +23,18 @@ test_that("atlas_taxonomy requires a single taxon", { }) }) -test_that("atlas_taxonomy makes a tree when piped", { +test_that("`atlas_taxonomy()` makes a tree when piped", { skip_if_offline(); skip_on_ci() tree <- galah_call() |> identify("fungi") |> filter(rank >= phylum) |> - atlas_taxonomy() + atlas_taxonomy() expect_s3_class(tree, c("tbl_df", "tbl", "data.frame")) expect_equal(ncol(tree), 4) expect_gte(nrow(tree), 1) }) -test_that("atlas_taxonomy example runs", { +test_that("`atlas_taxonomy()` example runs", { skip_if_offline(); skip_on_ci() df <- galah_call() |> galah_identify("chordata") |> diff --git a/tests/testthat/test-galah_apply_profile.R b/tests/testthat/test-galah_apply_profile.R index e21a66f4..a1babaec 100644 --- a/tests/testthat/test-galah_apply_profile.R +++ b/tests/testthat/test-galah_apply_profile.R @@ -1,4 +1,36 @@ -test_that("galah_apply_profile filters counts", { +test_that("`apply_profile()` amends a `query", { + x <- galah_call() |> + filter (year == 2025) |> + apply_profile("ALA") + expect_equal(x$data_profile, "ALA") +}) + +test_that("`galah_apply_profile()` matches profile", { + skip_if_offline(); skip_on_ci() + profile_1 <- galah_apply_profile("ALA") + profile_2 <- galah_apply_profile(AVH) + expect_equal(profile_1[[1]], "ALA") + expect_equal(profile_2[[1]], "AVH") +}) + +test_that("`apply_profile()` errors at `collapse()`", { + skip_if_offline(); skip_on_ci() + galah_config(run_checks = TRUE) + expect_error(request_data() |> + apply_profile(whatever) |> + count() |> + collapse(), + "Unrecognised profile requested.") + galah_config(run_checks = FALSE) +}) + +test_that("`galah_apply_profile()` allows only one profile at a time", { + skip_if_offline(); skip_on_ci() + expect_error(galah_apply_profile(ALA, CSDM), + "Too many data profiles supplied.") +}) + +test_that("`apply_profile()` filters counts", { skip_if_offline(); skip_on_ci() without_profile <- galah_call() |> count() |> @@ -17,7 +49,7 @@ test_that("galah_apply_profile filters counts", { expect_equal(with_profile, with_profile_2) }) -test_that("galah_apply_profile filters species", { +test_that("`apply_profile()` filters species", { skip_if_offline(); skip_on_ci() galah_config(email = "ala4r@ala.org.au", atlas = "Australia", @@ -36,45 +68,28 @@ test_that("galah_apply_profile filters species", { expect_true(all(with_profile$count <= without_profile$count)) }) -test_that("galah_apply_profile filters occurrences", { +test_that("`apply_profile()` filters occurrences", { skip_if_offline(); skip_on_ci() + quiet_collect <- function(x){ + purrr_collect <- purrr::quietly(collect.data_request) + purrr_collect(x) |> + purrr::pluck("result") + } galah_config(email = "ala4r@ala.org.au", atlas = "Australia", run_checks = FALSE) without_profile <- galah_call() |> - galah_identify("Acanthorhynchus tenuirostris") |> - galah_filter(year == 2012) |> - atlas_occurrences() + identify("Crinia signifera") |> + filter(year <= 1980, + cl22 == "Australian Capital Territory") |> + quiet_collect() with_profile <- galah_call() |> - galah_identify("Acanthorhynchus tenuirostris") |> - galah_filter(year == 2012) |> - galah_apply_profile(ALA) |> - atlas_occurrences() + identify("Crinia signifera") |> + filter(year <= 1980, + cl22 == "Australian Capital Territory") |> + apply_profile(ALA) |> + quiet_collect() expect_gt(nrow(with_profile), 0) expect_lt(nrow(with_profile), nrow(without_profile)) -}) - -test_that("galah_apply_profile matches profile", { - skip_if_offline(); skip_on_ci() - profile_1 <- galah_apply_profile("ALA") - profile_2 <- galah_apply_profile(AVH) - expect_equal(profile_1[[1]], "ALA") - expect_equal(profile_2[[1]], "AVH") -}) - -test_that("`galah_apply_profile()` errors at `collapse()`", { - skip_if_offline(); skip_on_ci() - galah_config(run_checks = TRUE) - expect_error(request_data() |> - apply_profile(whatever) |> - count() |> - collapse(), - "Unrecognised profile requested.") - galah_config(run_checks = FALSE) -}) - -test_that("galah_apply_profile allows only one profile at a time", { - skip_if_offline(); skip_on_ci() - expect_error(galah_apply_profile(ALA, CSDM), - "Too many data profiles supplied.") -}) + rm(quiet_collect) +}) \ No newline at end of file diff --git a/tests/testthat/test-galah_bbox.R b/tests/testthat/test-galah_bbox.R index 565db3a9..94e1e0bf 100644 --- a/tests/testthat/test-galah_bbox.R +++ b/tests/testthat/test-galah_bbox.R @@ -1,47 +1,54 @@ -test_that("galah_bbox returns bbox for sf", { +purrr_bbox <- purrr::quietly(galah_bbox) +quiet_bbox <- function(...){ + purrr_bbox(...) |> + purrr::pluck("result")} + +test_that("`galah_bbox()` returns bbox for sf", { polygon_sfc <- "POLYGON((142.36228 -29.00703,142.74131 -29.00703,142.74131 -29.39064,142.36228 -29.39064,142.36228 -29.00703))" |> sf::st_as_sfc() - polygon_bbox <- galah_bbox(polygon_sfc) + polygon_bbox <- quiet_bbox(polygon_sfc) expected_bbox <- polygon_sfc |> sf::st_bbox() - expect_message(galah_bbox(polygon_sfc), "Data returned for bounding box:") - expect_true(grepl("MULTIPOLYGON", galah_bbox(polygon_sfc))) - expect_equal(attributes(polygon_bbox)$bbox, - expected_bbox) + galah_bbox(polygon_sfc) |> + expect_message("Data returned for bounding box:") + grepl("MULTIPOLYGON", quiet_bbox(polygon_sfc)) |> + expect_true() + attributes(polygon_bbox)$bbox |> + expect_equal(expected_bbox) }) -test_that("galah_bbox returns bbox for shapefile", { +test_that("`galah_bbox()` returns bbox for shapefile", { poly_path <- test_path("testdata", "act_state_polygon_shp", "ACT_STATE_POLYGON_shp.shp") shapefile <- sf::st_read(poly_path, quiet = TRUE) - shapefile_bbox <- galah_bbox(shapefile) + shapefile_bbox <- quiet_bbox(shapefile) expected_bbox <- attributes(shapefile_bbox)$bbox expect_message(galah_bbox(shapefile), "Data returned for bounding box:") - expect_true(grepl("MULTIPOLYGON", galah_bbox(shapefile))) + expect_true(grepl("MULTIPOLYGON", quiet_bbox(shapefile))) expect_equal(attributes(shapefile_bbox)$bbox, expected_bbox) }) -test_that("galah_bbox returns bbox for bbox", { # FIXME: not backwards compatible with bbox coords? +test_that("`galah_bbox()` returns bbox for bbox", { # FIXME: not backwards compatible with bbox coords? bbox <- sf::st_bbox(c(xmin = 143, xmax = 148, ymin = -29, ymax = -28), crs = sf::st_crs("WGS84")) - bbox_galah <- galah_bbox(bbox) + bbox_galah <- quiet_bbox(bbox) expected_polygon <- "MULTIPOLYGON (((143 -29, 148 -29, 148 -28, 143 -28, 143 -29)))" expect_message(galah_bbox(bbox), "Data returned for bounding box:") - expect_equal(galah_bbox(bbox)[1], expected_polygon) + expect_equal(quiet_bbox(bbox)[1], expected_polygon) expect_equal(attributes(bbox_galah)$bbox, bbox) }) -test_that("galah_bbox returns bbox for tibble", { +test_that("`galah_bbox()` returns bbox for tibble", { tibble <- tibble::tibble(xmin = 143, ymin = -29, xmax = 148, ymax = -21) - tibble_bbox <- galah_bbox(tibble) + tibble_bbox <- quiet_bbox(tibble) expected_polygon <- "MULTIPOLYGON (((143 -29, 148 -29, 148 -21, 143 -21, 143 -29)))" expected_bbox <- sf::st_bbox(c(xmin = 143, xmax = 148, ymin = -29, ymax = -21), crs = sf::st_crs("WGS84")) expect_message(galah_bbox(tibble), "Data returned for bounding box:") - expect_equal(galah_bbox(tibble)[1], expected_polygon) + expect_equal(quiet_bbox(tibble)[1], expected_polygon) expect_equal(attributes(tibble_bbox)$bbox, expected_bbox) }) -test_that("galah_bbox does not accept incorrect tibbles", { +test_that("`galah_bbox()` does not accept incorrect tibbles", { tibble_wrong <- tibble::tibble(c1 = c("hi", "hello"), c2 = 1:2) tibble_bad_colnames <- tibble::tibble(top = 148, bottom = -29, ymin = -29, ymax = -29) tibble_invalid_bbox <- tibble::tibble(xmin = 148, ymin = -29, xmax = 143, ymax = -29) @@ -50,16 +57,19 @@ test_that("galah_bbox does not accept incorrect tibbles", { expect_error(galah_bbox(tibble_invalid_bbox)) }) -test_that("galah_bbox uses only first coordinates of tibble with many coordinates", { +test_that("`galah_bbox()` uses only first coordinates of tibble with many coordinates", { tibble_many_coords <- tibble::tibble(xmin = c(148, 145), ymin = c(-29, -42), xmax = c(143, 146), ymax = c(-30, -41)) - expect_warning(galah_bbox(tibble_many_coords), - "More than 1 set of coordinates supplied to") + result <- purrr_bbox(tibble_many_coords) + grepl("More than 1 set of coordinates supplied to", result$warnings) |> + expect_true() + grepl("Data returned for bounding box:", result$messages) |> + expect_true() }) -test_that("galah_bbox checks number of inputs, uses first argument", { # FIXME +test_that("`galah_bbox()` checks number of inputs, uses first argument", { # FIXME wkt_1 <- glue::glue("POLYGON((142.36228 -29.00703,142.74131 -29.00703,142.74131 \\ -29.39064,142.36228 -29.39064,142.36228 -29.00703))") |> sf::st_as_sfc() @@ -69,13 +79,17 @@ test_that("galah_bbox checks number of inputs, uses first argument", { # FIXME sf::st_as_sfc() expected_polygon <- glue::glue("MULTIPOLYGON (((142.3623 -29.39064, 142.7413 -29.39064, \\ 142.7413 -29.00703, 142.3623 -29.00703, 142.3623 -29.39064)))") - bbox_1 <- expect_warning(galah_bbox(wkt_1, wkt_2), "More than 1 spatial area provided") - expect_equal(as.character(bbox_1), - as.character(galah_bbox(wkt_1)[1]), - as.character(expected_polygon)) + result <- purrr_bbox(wkt_1, wkt_2) + grepl("More than 1 spatial area provided", result$warnings) |> + expect_true() + grepl("Data returned for bounding box:", result$messages) |> + expect_true() + expect_identical(as.character(result$result), + as.character(quiet_bbox(wkt_1)[1]), + as.character(expected_polygon)) }) -test_that("galah_bbox checks inputs", { +test_that("`galah_bbox()` checks inputs", { wkt <- "POLYGON((142.36228 -29.00703,142.74131 -29.00703,142.74131 -29.39064,142.36228 -29.39064,142.36228 -29.00703))" number <- 45 c_char <- c("a", "b", "c", "d") @@ -86,7 +100,7 @@ test_that("galah_bbox checks inputs", { expect_error(galah_bbox(c_numbers)) }) -test_that("galah_bbox detects invalid spatial objects", { +test_that("`galah_bbox()` detects invalid spatial objects", { impossible_bbox <- sf::st_bbox(c(xmin = 148000, xmax = -29000, ymin = -29000, @@ -96,12 +110,14 @@ test_that("galah_bbox detects invalid spatial objects", { "Invalid spatial object") }) -test_that("galah_bbox converts to multipolygon", { +test_that("`galah_bbox()` converts to multipolygon", { wkt <- "POLYGON((143.32 -18.78,145.30 -20.52,141.52 -21.50,143.32 -18.78))" sf_wkt <- wkt |> sf::st_as_sfc() - expect_match(galah_bbox(sf_wkt), "MULTIPOLYGON") + expect_match(quiet_bbox(sf_wkt), "MULTIPOLYGON") }) # TODO: test that galah_bbox unnests sf object & shapefiles correctly -# after converting from dots \ No newline at end of file +# after converting from dots + +rm(purrr_bbox, quiet_bbox) \ No newline at end of file diff --git a/tests/testthat/test-galah_call.R b/tests/testthat/test-galah_call.R index 3221c53a..5d019068 100644 --- a/tests/testthat/test-galah_call.R +++ b/tests/testthat/test-galah_call.R @@ -15,8 +15,6 @@ test_that("galah_call accepts method arg", { expect_error(galah_call(method = "nothing")) }) -# prints properly? - test_that("galah_call works with all `galah_` functions", { skip_if_offline(); skip_on_ci() result <- galah_call() |> diff --git a/tests/testthat/test-galah_config.R b/tests/testthat/test-galah_config.R index 25510d27..796e9e0d 100644 --- a/tests/testthat/test-galah_config.R +++ b/tests/testthat/test-galah_config.R @@ -1,3 +1,5 @@ +quiet_config <- purrr::quietly(galah_config) + test_that("galah_config warns that `cache_directory` is deprecated", { unlink("temp", recursive = TRUE) dir.create("temp") @@ -37,7 +39,7 @@ test_that("galah_config checks inputs", { expect_error(galah_config(run_checks = "value")) expect_silent(galah_config(run_checks = TRUE)) # reset defaults - galah_config(run_checks = TRUE, + quiet_config(run_checks = TRUE, verbose = FALSE, download_reason_id = "testing") }) @@ -49,5 +51,7 @@ test_that("galah_config can swap between atlases", { list(organisation = "Global Biodiversity Information Facility", acronym = "GBIF", region = "Global")) - galah_config(atlas = "ALA") + quiet_config(atlas = "ALA") }) + +rm(quiet_config) \ No newline at end of file diff --git a/tests/testthat/test-galah_filter.R b/tests/testthat/test-galah_filter.R index f05f1d58..29c3d415 100644 --- a/tests/testthat/test-galah_filter.R +++ b/tests/testthat/test-galah_filter.R @@ -1,14 +1,17 @@ -test_that("galah_filter works for a single 'equals' argument", { +# set up quiet functions for testing reasons +purrr_collect <- purrr::quietly(collect.data_request) + +test_that("`filter()` works for a single 'equals' argument", { filters <- galah_filter(year == 2010) expect_s3_class(filters, c("tbl_df", "tbl", "data.frame")) expect_equal(nrow(filters), 1) }) -test_that("galah_filter gives an error for single equals sign", { +test_that("`filter()` gives an error for single equals sign", { expect_error(galah_filter(year = 2010)) }) -test_that("galah_filter works with assertions", { +test_that("`filter()` works with assertions", { skip_if_offline(); skip_on_ci() count_all <- atlas_counts() |> dplyr::pull(count) @@ -29,13 +32,13 @@ test_that("galah_filter works with assertions", { count_all) }) -test_that("galah_filter handles multiple assertions", { +test_that("`filter()` handles multiple assertions", { skip_if_offline(); skip_on_ci() # OR statements all_records <- atlas_counts() |> dplyr::pull(count) either_valid <- galah_call() |> - galah_filter(assertions != c("INVALID_SCIENTIFIC_NAME", "COORDINATE_INVALID")) |> + filter(assertions != c("INVALID_SCIENTIFIC_NAME", "COORDINATE_INVALID")) |> count() |> collect() |> dplyr::pull(count) @@ -75,69 +78,73 @@ test_that("galah_filter handles multiple assertions", { }) -test_that("galah_filter handles assertions and taxa", { +test_that("`filter()` handles assertions and taxa", { skip_if_offline(); skip_on_ci() problem_families <- galah_call() |> filter(assertions == "INVALID_SCIENTIFIC_NAME") |> group_by(family) |> slice_head(n = 5) |> count() |> - collect() + purrr_collect() + problem_families |> + purrr::pluck("messages") |> + stringr::str_detect("Limiting to first 5 of ") |> + expect_true() top_family <- galah_call() |> - identify(problem_families$family[1]) |> + identify(problem_families$result$family[1]) |> filter(assertions == "INVALID_SCIENTIFIC_NAME") |> group_by(family) |> count() |> - collect() - expect_equal(problem_families$count[1], top_family$count) + collect() # quiet by default + expect_equal(problem_families$result$count[1], top_family$count) }) -test_that("galah_filter returns empty tibble when no arguments specified", { +test_that("`filter()` returns empty tibble when no arguments specified", { filters <- galah_filter() expect_s3_class(filters, c("tbl_df", "tbl", "data.frame")) expect_equal(nrow(filters), 0) }) -test_that("galah_filter works for two 'equals' arguments", { +test_that("`filter()` works for two 'equals' arguments", { filters <- galah_filter(year == 2010, basisOfRecord == "HUMAN_OBSERVATION") expect_s3_class(filters, c("tbl_df", "tbl", "data.frame")) expect_equal(nrow(filters), 2) expect_equal(filters$variable, c("year", "basisOfRecord")) }) -test_that("galah_filter works for two arguments of the same variable", { +test_that("`filter()` works for two arguments of the same variable", { filters <- galah_filter(year >= 2010, year <= 2015) expect_s3_class(filters, c("tbl_df", "tbl", "data.frame")) expect_equal(nrow(filters), 2) expect_equal(">=", filters$logical[1]) }) -test_that("galah_filter works with urls", { +test_that("`filter()` works with urls", { filters <- galah_filter( taxonConceptID == "https://biodiversity.org.au/afd/taxa/065f1da4-53cd-40b8-a396-80fa5c74dedd") expect_true(nchar(filters$query) > 70) }) -test_that("galah_filter parses '&' correctly", { +test_that("`filter()` parses '&' correctly", { filters <- galah_filter(year >= 2010 & year < 2020) expect_equal(nrow(filters), 1) expect_equal(filters$value, "2010&2020") expect_equal(filters$query, "year:[2010 TO *] AND year:[* TO 2020] AND -(year:\"2020\")") }) -test_that("galah_filter handles numeric queries for text fields", { +test_that("`filter()` handles numeric queries for text fields", { filters <- galah_filter(cl22 >= "Tasmania") expect_equal(filters$query, "cl22:[Tasmania TO *]") }) -test_that("galah_filter handles OR statements", { +test_that("`filter()` handles OR statements", { filters <- galah_filter(year == 2010 | year == 2020) expect_equal(nrow(filters), 1) expect_equal(filters$value, "2010|2020") expect_equal(filters$query, "((year:\"2010\") OR (year:\"2020\"))") }) -test_that("galah_filter handles OR statements", { +test_that("`filter()` handles OR statements", { filters <- galah_filter(raw_scientificName == "Litoria jervisiensis" | raw_scientificName == "Litoria peronii") expect_equal(nrow(filters), 1) @@ -145,7 +152,7 @@ test_that("galah_filter handles OR statements", { "((raw_scientificName:\"Litoria jervisiensis\") OR (raw_scientificName:\"Litoria peronii\"))") }) -test_that("galah_filter works with 3 OR statements", { +test_that("`filter()` works with 3 OR statements", { filters <- galah_filter(basisOfRecord == "HumanObservation" | basisOfRecord == "MachineObservation" | basisOfRecord == "PreservedSpecimen") @@ -155,19 +162,19 @@ test_that("galah_filter works with 3 OR statements", { "((basisOfRecord:\"HumanObservation\") OR (basisOfRecord:\"MachineObservation\") OR (basisOfRecord:\"PreservedSpecimen\"))") }) -test_that("galah_filter handles exclusion", { +test_that("`filter()` handles exclusion", { filters <- galah_filter(year >= 2010, year != 2021) expect_equal(nrow(filters), 2) expect_equal(filters$query, c("year:[2010 TO *]", "-(year:\"2021\")")) }) -test_that("galah_filter handles multiple exclusions", { +test_that("`filter()` handles multiple exclusions", { filters <- galah_filter(!(stateProvince == "Victoria" & year == 2021)) expect_equal(nrow(filters), 1) expect_equal(filters$query, "-(stateProvince:\"Victoria\") AND -(year:\"2021\")") }) -test_that("galah_filter handles three terms at once", { +test_that("`filter()` handles three terms at once", { filters <- galah_filter( basisOfRecord == "HumanObservation", year >= 2010, @@ -176,65 +183,43 @@ test_that("galah_filter handles three terms at once", { expect_equal(filters$query, c("(basisOfRecord:\"HumanObservation\")","year:[2010 TO *]","(stateProvince:\"New South Wales\")")) }) -test_that("galah_filter treats `c()` as an OR for numerics", { +test_that("`filter()` treats `c()` as an OR for numerics", { filters <- galah_filter(year == c(2010, 2021)) expect_equal(nrow(filters), 1) }) -test_that("galah_filter treats `c()` as an OR for strings", { +test_that("`filter()` treats `c()` as an OR for strings", { filters <- galah_filter(multimedia == c("Image", "Sound", "Video")) expect_equal(nrow(filters), 1) expect_true(grepl("multimedia:\"Image\"", filters$query)) }) -# ## NOT SUPPORTED: requires := -## - note this is difficult to support as it parses as a named object -## i.e. gets caught by check_named_input() -# test_that("galah_filter can take an object as a field", { -# field <- "year" -# filters <- galah_filter(field := 2010) -# expect_equal(nrow(filters), 1) -# expect_true(grepl("year", filters$query)) -# }) - -test_that("galah_filter can take an object as a value", { +test_that("`filter()` can take an object as a value", { value <- "2010" filters <- galah_filter(year == value) expect_equal(nrow(filters), 1) expect_match(filters$query, "(year:\"2010\")") }) -test_that("galah_filter returns error when equations are passed as a string", { +test_that("`filter()` returns error when equations are passed as a string", { expect_error(galah_filter("year == 2010")) }) -# # quoting an equation that contains objects - NOT SUPPORTED -# # consider writing a test to specifically exclude this -## or use := as per {dplyr} - note this is difficult to support as it parses as a named object -## i.e. gets caught by check_named_input() -# field <- "year" -# value <- "2010" -# filters <- galah_filter("field == value") -# expect_equal(attr(filters, "call"), "galah_filter") -# expect_equal(nrow(filters), 1) -# expect_true(grepl("2010", filters$query)) - -test_that("galah_filter handles taxonomic queries", { +test_that("`filter()` handles taxonomic queries", { skip_if_offline(); skip_on_ci() filters <- galah_filter(taxonConceptID == search_taxa("Animalia")$taxon_concept_id) expect_equal(nrow(filters), 1) expect_false(grepl("search_taxa", filters$query)) }) -test_that("galah_filter handles taxonomic queries when passed as a string", { - # ensure a taxonomic query to galah_filter works +test_that("`filter()` handles taxonomic queries when passed as a string", { filters <- galah_filter(taxonConceptID == "https://biodiversity.org.au/afd/taxa/012a1234") expect_equal(nrow(filters), 1) expect_false(grepl("search_taxa", filters$query)) expect_true(grepl("taxonConceptID", filters$query)) }) -test_that("galah_filter handles taxonomic exclusions", { +test_that("`filter()` handles taxonomic exclusions", { skip_if_offline(); skip_on_ci() filters <- galah_filter( taxonConceptID == search_taxa("Animalia")$taxon_concept_id, @@ -243,14 +228,14 @@ test_that("galah_filter handles taxonomic exclusions", { expect_false(any(grepl("search_taxa", filters$query))) }) -test_that("galah_filter handles lsid as an input", { +test_that("`filter()` handles lsid as an input", { skip_if_offline(); skip_on_ci() ids <- c("https://biodiversity.org.au/afd/taxa/0df99ece-1982-4605-a2b3-4fcb7660ee2b", "https://id.biodiversity.org.au/node/apni/2910467", "https://id.biodiversity.org.au/node/apni/291047") # wrong id query <- galah_call() |> - galah_filter(year == 2020, - lsid == ids) |> + filter(year == 2020, + lsid == ids) |> count() |> collapse() # number of taxa searches is 3, not 4 @@ -263,29 +248,29 @@ test_that("galah_filter handles lsid as an input", { "slot_name")) }) -test_that("galah_filter handles different fields separated by OR", { +test_that("`filter()` handles different fields separated by OR", { filters <- galah_filter(phylum == "Chordata" | kingdom == "Plantae") expect_equal(filters$query, "((phylum:\"Chordata\") OR (kingdom:\"Plantae\"))") }) -test_that("galah_filter fails when given invalid AND syntax", { +test_that("`filter()` fails when given invalid AND syntax", { expect_error(galah_filter(year >= 2020 & 2021)) }) -test_that("galah_filter fails when given invalid OR syntax", { +test_that("`filter()` fails when given invalid OR syntax", { expect_error(galah_filter(year == 2020 | 2021)) }) -# test that galah_filter handles between() even with multiple filters +# TODOL test that `filter()` handles between() even with multiple filters # NOTE: not implemented yet -test_that("OR works for different fields", { +test_that("`filter()` accepts an OR statement for different fields", { filters <- galah_filter(year == 2010 | basisOfRecord == "PRESERVED_SPECIMEN") expect_true(grepl("year", filters$query) & grepl("basisOfRecord", filters$query)) }) -test_that("galah_filter handles is.na() even with multiple filters", { +test_that("`filter()` handles is.na() even with multiple filters", { filter_single <- galah_filter(is.na(eventDate)) filter_multiple <- galah_filter(is.na(eventDate), year > 2010) expect_equal(nrow(filter_single), 1) @@ -294,7 +279,7 @@ test_that("galah_filter handles is.na() even with multiple filters", { expect_true(grepl("(*:* AND -eventDate:*)", filter_multiple$query[[1]])) }) -test_that("galah_filter handles %in% even with multiple filters", { +test_that("`filter()` handles %in% even with multiple filters", { list_of_years <- 2020:2022 filter_single <- galah_filter(year %in% list_of_years) filter_multiple <- galah_filter(year %in% list_of_years, cl22 == "Tasmania") @@ -304,36 +289,42 @@ test_that("galah_filter handles %in% even with multiple filters", { expect_equal("((year:\"2020\") OR (year:\"2021\") OR (year:\"2022\"))", filter_multiple$query[[1]]) }) -test_that("galah_filter parses fields correctly with is.na()", { +test_that("`filter()` parses fields correctly with is.na()", { skip_if_offline(); skip_on_ci() expect_no_error(galah_call() |> - galah_filter(is.na(decimalLongitude)) |> - atlas_counts() + filter(is.na(decimalLongitude)) |> + count() |> + collect() ) expect_no_error(galah_call() |> - galah_filter(year == 2001, is.na(decimalLongitude)) |> - atlas_counts() + filter(year == 2001, is.na(decimalLongitude)) |> + count() |> + collect() ) expect_no_error(galah_call() |> galah_filter(is.na(coordinateUncertaintyInMeters) | coordinateUncertaintyInMeters <= 1000) |> - atlas_counts() + count() |> + collect() ) # misspelled or wrong fields expect_error(galah_call() |> - galah_filter(is.na(decimalLongitde)) |> - atlas_counts() + filter(is.na(decimalLongitde)) |> + count() |> + collect() ) expect_error(galah_call() |> - galah_filter(year == 2001, is.na(decimalLongitde)) |> - atlas_counts() + filter(year == 2001, is.na(decimalLongitde)) |> + count() |> + collect() ) expect_error(galah_call() |> - galah_filter(is.na(bork) | coordinatencertaintyInMeters <= 1000) |> - atlas_counts() + filter(is.na(bork) | coordinatencertaintyInMeters <= 1000) |> + count() |> + collect() ) }) -test_that("`galah_filter()` handles apostrophes (') correctly", { +test_that("`filter()` handles apostrophes (') correctly", { skip_if_offline(); skip_on_ci() names <- c("Australia's Virtual Herbarium", "iNaturalist observations", @@ -347,7 +338,7 @@ test_that("`galah_filter()` handles apostrophes (') correctly", { expect_match(filter, "\\(datasetName:\\\"Australia's Virtual Herbarium\\\"") }) -test_that("`galah_filter()` handles multiple values with brackets correctly", { +test_that("`filter()` handles multiple values with brackets correctly", { skip_if_offline(); skip_on_ci() filter <- galah_filter( scientificName == c("Aviceda (Aviceda) subcristata", @@ -361,7 +352,7 @@ test_that("`galah_filter()` handles multiple values with brackets correctly", { expect_match(filter, "\\(scientificName:\\\"Aviceda \\(Aviceda\\) subcristata\\\"\\)") }) -test_that("galah_filter builds correct query with `!`, `%in%`, `c()` and `identify()`", { +test_that("`filter()` builds correct query with `!`, `%in%`, `c()` and `identify()`", { ibra_subset <- c("Brigalow Belt North", "Brigalow Belt South", "Central Mackay Coast") query <- request_data(type = "occurrences-count") |> identify("Crinia signifera") |> @@ -369,7 +360,7 @@ test_that("galah_filter builds correct query with `!`, `%in%`, `c()` and `identi expect_equal(query$filter$query, c("-(cl1048:\"Brigalow Belt North\") OR -(cl1048:\"Brigalow Belt South\") OR -(cl1048:\"Central Mackay Coast\")")) }) -test_that("`galah_filter()` accepts {{}} on lhs of formula", { +test_that("`filter()` accepts {{}} on lhs of formula", { skip_if_offline(); skip_on_ci() field <- "species" result <- galah_call() |> @@ -385,44 +376,9 @@ test_that("`galah_filter()` accepts {{}} on lhs of formula", { expect_equal(result, result2) }) -test_that("`group_by()` works when > 1 `filter()`", { - skip_if_offline(); skip_on_ci() - chosen_species <- c("Eolophus roseicapilla", "Platycercus elegans") +test_that("`filter()` handles `method = 'data'` correctly", { x <- request_data() |> - filter(species == chosen_species) |> - group_by(species) |> - count() |> - collect() - expect_s3_class(x, c("tbl_df", "tbl", "data.frame")) - expect_equal(x$species, chosen_species) - expect_equal(colnames(x), c("species", "count")) - expect_equal(nrow(x), 2) - # previously, adding an additional field (`year` below) removed one species from resulting tibble - y <- request_data() |> - filter(species == chosen_species, - year == 2023) |> - group_by(species) |> - count() |> - collect() - expect_s3_class(y, c("tbl_df", "tbl", "data.frame")) - expect_equal(y$species, chosen_species) - expect_equal(colnames(y), c("species", "count")) - expect_equal(nrow(y), 2) - expect_true(all(x$count > y$count)) # extra filter - # compare to different syntax - z <- galah_call() |> - galah_filter(species == c("Eolophus roseicapilla", "Platycercus elegans"), - year == 2023) |> - galah_group_by(species) |> - atlas_counts() - expect_equal(y, z) -}) - -test_that("galah_filter handles `type = 'data'` correctly", { - x <- galah_call(method = "data") |> - galah_filter(year == 2010) - # NOTE: this errors because `galah_filter()` doesn't handle `type = "files"` like `filter()` does - # needs fixing + filter(year == 2010) expect_s3_class(x, "data_request") expect_false(is.null(x$filter)) expect_equal(colnames(x$filter), c("variable", "logical", "value", "query")) @@ -430,9 +386,9 @@ test_that("galah_filter handles `type = 'data'` correctly", { expect_equal(x, y) }) -test_that("galah_filter handles `type = 'metadata'` correctly", { - x <- galah_call(method = "metadata") |> - galah_filter(field == cl22) +test_that("`filter() handles `method = 'metadata'` correctly", { + x <- request_metadata() |> + filter(field == cl22) expect_s3_class(x, "metadata_request") expect_equal(length(x), 2) expect_equal(names(x), c("type", "filter")) @@ -441,12 +397,12 @@ test_that("galah_filter handles `type = 'metadata'` correctly", { expect_equal(x, y) }) -test_that("galah_filter handles `type = 'files'` correctly", { +test_that("`filter() handles `method = 'files'` correctly", { x <- tibble::tibble( id = c(1, 2), images = c("1234", "5678")) - y <- galah_call(method = "files") |> - galah_filter(media == x) + y <- request_files() |> + filter(media == x) expect_s3_class(y, "files_request") expect_equal(length(y), 2) expect_equal(names(y), c("type", "filter")) @@ -454,3 +410,5 @@ test_that("galah_filter handles `type = 'files'` correctly", { z <- request_files() |> filter(media == x) expect_equal(y, z) }) + +rm(purrr_collect) \ No newline at end of file diff --git a/tests/testthat/test-galah_geolocate.R b/tests/testthat/test-galah_geolocate.R index e9917ced..8149c172 100644 --- a/tests/testthat/test-galah_geolocate.R +++ b/tests/testthat/test-galah_geolocate.R @@ -1,3 +1,6 @@ +purrr_geolocate <- purrr::quietly(galah_geolocate) +quiet_geolocate <- function(...){purrr_geolocate(...)$result} + test_that("galah_geolocate defaults to galah_polygon", { wkt <- "POLYGON((142.36228 -29.00703,142.74131 -29.00703,142.74131 -29.39064,142.36228 -29.39064,142.36228 -29.00703))" expect_match(galah_geolocate(wkt), "MULTIPOLYGON") @@ -5,26 +8,34 @@ test_that("galah_geolocate defaults to galah_polygon", { test_that("galah_geolocate works when type is set to polygon", { wkt <- "POLYGON((142.36228 -29.00703,142.74131 -29.00703,142.74131 -29.39064,142.36228 -29.39064,142.36228 -29.00703))" - expect_match(galah_geolocate(wkt, type = "polygon"), "MULTIPOLYGON") + polygon <- quiet_geolocate(wkt, type = "polygon") + expect_match(polygon, "MULTIPOLYGON") }) test_that("galah_geolocate switches to use galah_bbox when `type = bbox`", { wkt <- "POLYGON((142.36228 -29.00703,142.74131 -29.00703,142.74131 -29.39064,142.36228 -29.39064,142.36228 -29.00703))" obj_sf <- wkt |> sf::st_as_sfc() - expect_error(galah_geolocate(wkt, type = "bbox")) - expect_match(galah_geolocate(obj_sf, type = "bbox"), "MULTIPOLYGON") + galah_geolocate(wkt, type = "bbox") |> + expect_error(label = "input must be an sf object, data.frame or tibble") + polygon <- quiet_geolocate(obj_sf, type = "bbox") + expect_match(polygon, "MULTIPOLYGON") }) test_that("galah_geolocate switches to use galah_radius when `type = radius`", { wkt <- "POLYGON((142.36228 -29.00703,142.74131 -29.00703,142.74131 -29.39064,142.36228 -29.39064,142.36228 -29.00703))" obj_sf <- wkt |> sf::st_as_sfc() - expect_error(galah_geolocate(wkt, type = "radius"), "Missing") - expect_error(galah_geolocate(obj_sf, type = "radius"), "Invalid spatial object") - expect_equal(galah_geolocate(lat = -33.66741, - lon = 151.3174, - radius = 2, - type = "radius"), + galah_geolocate(wkt, type = "radius") |> + expect_error("Missing") + galah_geolocate(obj_sf, type = "radius") |> + expect_error("Invalid spatial object") + radius_obj <- quiet_geolocate(lat = -33.66741, + lon = 151.3174, + radius = 2, + type = "radius") + expect_equal(radius_obj, list(lat = -33.66741, lon = 151.3174, radius = 2)) }) + +rm(purrr_geolocate, quiet_geolocate) diff --git a/tests/testthat/test-galah_group_by.R b/tests/testthat/test-galah_group_by.R index aa640fd5..12b1d334 100644 --- a/tests/testthat/test-galah_group_by.R +++ b/tests/testthat/test-galah_group_by.R @@ -1,3 +1,5 @@ +quiet_collect <- purrr::quietly(collect.data_request) + test_that("`group_by` fields are checked during `collapse()`", { galah_config(run_checks = TRUE) # `collapse()` should ping a check when `run_checks = TRUE` @@ -17,7 +19,7 @@ test_that("`group_by` fields are checked during `collapse()`", { galah_config(run_checks = FALSE) }) -test_that("grouped atlas_counts returns expected output", { +test_that("`count()` with `group_by()` returns expected output", { skip_if_offline(); skip_on_ci() counts <- galah_call() |> identify("Mammalia") |> @@ -27,67 +29,73 @@ test_that("grouped atlas_counts returns expected output", { expect_equal(names(counts), c("basisOfRecord", "count")) }) -test_that("grouped atlas_counts returns expected output when limit != NULL", { +test_that("`count()` with `group_by()` returns expected output when limit != NULL", { skip_if_offline(); skip_on_ci() counts <- galah_call() |> identify("Mammalia") |> group_by(basisOfRecord) |> count() |> slice_head(n = 3) |> - collect() + quiet_collect() |> + purrr::pluck("result") expect_s3_class(counts, c("tbl_df", "tbl", "data.frame")) expect_equal(names(counts), c("basisOfRecord", "count")) expect_equal(nrow(counts), 3) }) -test_that("atlas_counts returns all counts if no limit is provided", { +test_that("`count()` with `group_by()` returns all counts if no limit is provided", { skip_if_offline(); skip_on_ci() counts <- galah_call() |> group_by(basisOfRecord) |> # NOTE: basisOfRecord chosen as prone to breaking - atlas_counts() # this code; please do not change it! + count() |> # this code; please do not change it! + quiet_collect() |> + purrr::pluck("result") expect_s3_class(counts, c("tbl_df", "tbl", "data.frame")) expect_gte(nrow(counts), 5) }) -test_that("atlas_counts returns an empty tibble if number of records = 0", { +test_that("`count()` with `group_by()` returns an empty tibble if number of records = 0", { skip_if_offline(); skip_on_ci() counts <- galah_call() |> filter(year < 1900 & year > 2000) |> count() |> group_by(species) |> - collect() + quiet_collect() |> + purrr::pluck("result") expect_s3_class(counts, c("tbl_df", "tbl", "data.frame")) expect_equal(nrow(counts), 0) expect_equal(ncol(counts), 0) }) -test_that("grouped atlas_counts for species returns expected output", { +test_that("`count()` with `group_by()` for species returns expected output", { skip_if_offline(); skip_on_ci() counts <- galah_call() |> identify("Mammalia") |> filter(year == 2020) |> group_by(month) |> count(type = "species") |> - collect() + quiet_collect() |> + purrr::pluck("result") expect_s3_class(counts, c("tbl_df", "tbl", "data.frame")) expect_equal(names(counts), c("month", "count")) }) -test_that("group_by works for three groups", { +test_that("`count()` with `group_by()` works for three groups", { skip_if_offline(); skip_on_ci() counts <- galah_call() |> identify("cacatuidae") |> filter(year >= 2020) |> group_by(year, basisOfRecord, stateProvince) |> count() |> - collect() + quiet_collect() |> + purrr::pluck("result") expect_s3_class(counts, c("tbl_df", "tbl", "data.frame")) expect_gt(nrow(counts), 1) expect_true(all(names(counts) %in% c("basisOfRecord", "year", "stateProvince", "count"))) }) -test_that("group_by returns correct information when ID fields are requested", { +test_that("`count()` with `group_by()` returns correct information when ID fields are requested", { # NOTE: previously these were parsed as the `label` for that field, not the # value itself, hence this test skip_if_offline(); skip_on_ci() @@ -95,8 +103,9 @@ test_that("group_by returns correct information when ID fields are requested", { identify("pardalotus quadragintus") |> filter(year == 2023) |> group_by(species, dataResourceName, dataResourceUid) |> - count() |> - collect() + count() |> + quiet_collect() |> + purrr::pluck("result") expect_equal(colnames(counts), c("species", "dataResourceName", "dataResourceUid", "count")) expect_false(any(counts$dataResourceName == counts$dataResourceUid)) @@ -110,3 +119,5 @@ test_that("group_by fails for four groups", { group_by(year, month, basisOfRecord, stateProvince) |> expect_error() }) + +rm(quiet_collect) \ No newline at end of file diff --git a/tests/testthat/test-galah_identify.R b/tests/testthat/test-galah_identify.R index 58ae42a7..559140fe 100644 --- a/tests/testthat/test-galah_identify.R +++ b/tests/testthat/test-galah_identify.R @@ -1,105 +1,97 @@ +# set up quiet functions for testing reasons +purrr_compute <- purrr::quietly(compute.data_request) + # Warning generated by `galah_identify()` about empty query -test_that("galah_identify returns an empty tibble when no args provided", { +test_that("`galah_identify()` returns an empty tibble when no args provided", { expect_warning({result <- galah_identify()}) expect_equal(nrow(result), 0) expect_warning(galah_identify(), "No query passed") }) -test_that("galah_identify runs a search when given a string", { +test_that("`galah_identify()` runs a search when given a string", { result <- galah_identify("Litoria peronii") expect_equal(nrow(result), 1) }) -test_that("galah_identify runs a search on multiple strings", { +test_that("`galah_identify()` runs a search on multiple strings", { result <- galah_identify("amphibia", "reptilia", "aves", "mammalia") expect_equal(nrow(result), 4) }) -test_that("galah_identify runs a search on multiple strings wrapped by c()", { +test_that("`galah_identify()` runs a search on multiple strings wrapped by c()", { search_terms <- c("amphibia", "reptilia", "aves", "mammalia") result <- galah_identify(search_terms) expect_equal(nrow(result), 4) }) -test_that("galah_identify pipes correctly", { +test_that("`identify()` pipes correctly", { result <- galah_call() |> - galah_identify("Litoria") |> - galah_filter(year == 2020) + identify("Litoria") |> + filter(year == 2020) expect_false(is.null(result$identify)) expect_false(is.null(result$filter)) }) -test_that("galah_identify pipes correctly when taxa are partially returned", { +test_that("`identify()` pipes correctly when taxa are partially returned", { galah_config(verbose = FALSE) result <- galah_call() |> - galah_identify("Litoria", "blarghy") |> - galah_filter(year == 2020) + identify("Litoria", "blarghy") |> + filter(year == 2020) expect_false(is.null(result$identify)) expect_false(is.null(result$filter)) galah_config(verbose = TRUE) }) -test_that("galah_identify warns when taxa are partially returned", { +test_that("`identify()` warns when taxa are partially returned", { skip_if_offline(); skip_on_ci() - expect_message( - galah_call() |> - galah_identify("Litoria", "blarghy") |> - galah_filter(year == 2020) |> - count() |> - compute(), - cli::cli_text("Matched {.bold 1 of 2}") - ) + x <- galah_call() |> + identify("Litoria", "blarghy") |> + filter(year == 2020) |> + count() |> + purrr_compute() + x |> + purrr::pluck("messages") |> + stringr::str_detect("Matched 1 of 2 taxonomic search terms in selected atlas") |> + any() |> + expect_true() }) -test_that("galah_identify warns when identifiers are partially returned", { +test_that("`identify()` warns when identifiers are partially returned", { skip_if_offline(); skip_on_ci() galah_config(run_checks = TRUE) ids <- c("https://biodiversity.org.au/afd/taxa/0df99ece-1982-4605-a2b3-4fcb7660ee2b", "https://id.biodiversity.org.au/node/apni/2910467", "https://id.biodiversity.org.au/node/apni/291047") # wrong id - expect_message( - galah_call() |> - galah_identify(ids) |> - galah_filter(year == 2020) |> - count() |> - compute(), - cli::cli_text("Matched {.bold 2 of 3}") - ) + x <- galah_call() |> + identify(ids) |> + filter(year == 2020) |> + count() |> + purrr_compute() + x |> + purrr::pluck("messages") |> + stringr::str_detect("2 unmatched search terms:") |> + any() |> + expect_true() expect_no_warning( galah_identify(ids) ) }) -test_that("galah_identify truncates unmatched list of taxa at 3 ", { +test_that("`identify()` truncates unmatched list of taxa at 3 ", { skip_if_offline(); skip_on_ci() - expect_message( - galah_call() |> - galah_identify("Litoria", "blarghy", "blorp", "florp", "skorp") |> - galah_filter(year == 2020) |> - count() |> - compute(), - c( - cli::cli_text("Matched {.bold 1 of 5} taxonomic search terms in selected atlas (Australia)."), - "!" = cli::cli_text("{.yellow 4 unmatched search term:}"), - cli::cli_text(rlang::format_error_bullets(c("{.yellow \"blarghy\", \"blorp\", \"florp\" + 1 more}"))) - ) - ) -}) - -test_that("galah_identify errors for deprecated `search = FALSE` argument", { - skip_if_offline(); skip_on_ci() - galah_config(run_checks = TRUE) - ids <- c("https://biodiversity.org.au/afd/taxa/0df99ece-1982-4605-a2b3-4fcb7660ee2b", - "https://id.biodiversity.org.au/node/apni/2910467", - "https://id.biodiversity.org.au/node/apni/291047", - "fred", - "bort") # wrong id - expect_error( - galah_call() |> - galah_identify(ids, search = FALSE) |> - galah_filter(year == 2020) |> - count() |> - collapse()) + x <- galah_call() |> + galah_identify("Litoria", "blarghy", "blorp", "florp", "skorp") |> + galah_filter(year == 2020) |> + count() |> + purrr_compute() + expected_messages <- c( + "Matched 1 of 5 taxonomic search terms in selected atlas (Australia).\n", + "4 unmatched search terms:\n", + "* \"blarghy\", \"blorp\", \"florp\" + 1 more\"\n", + "") + x |> + purrr::pluck("messages") |> + expect_equal(expected_messages) }) ## NOTE: Not certain if this is a necessary test @@ -115,3 +107,5 @@ test_that("galah_identify errors for deprecated `search = FALSE` argument", { # ) # })) # }) + +rm(purrr_compute) \ No newline at end of file diff --git a/tests/testthat/test-galah_polygon.R b/tests/testthat/test-galah_polygon.R index c5e6f574..b2e71b41 100644 --- a/tests/testthat/test-galah_polygon.R +++ b/tests/testthat/test-galah_polygon.R @@ -2,10 +2,15 @@ test_that("galah_polygon uses first argument", { wkt_1 <- "POLYGON((142.36228 -29.00703,142.74131 -29.00703,142.74131 -29.39064,142.36228 -29.39064,142.36228 -29.00703))" wkt_2 <- "POLYGON((145.6765 -42.13203, 145.9652 -42.63203, 146.5425 -42.63203, 146.8312 -42.13203, 146.5425 -41.63203, 145.9652 -41.63203, 145.6765 -42.13203))" expected_polygon <- "MULTIPOLYGON (((142.3623 -29.00703, 142.7413 -29.00703, 142.7413 -29.39064, 142.3623 -29.39064, 142.3623 -29.00703)))" - polygon_1 <- expect_warning(galah_polygon(wkt_1, wkt_2)) - expect_equal(as.character(polygon_1), - galah_polygon(wkt_1)[1], - expected_polygon) + warning_text <- expect_warning(galah_polygon(wkt_1, wkt_2)) + grepl("More than 1 spatial area provided", warning_text) |> + any() |> + expect_true() + quiet_polygons <- purrr::quietly(galah_polygon) + calculated_polygon <- quiet_polygons(wkt_1, wkt_2)$result + expect_identical(calculated_polygon, + galah_polygon(wkt_1)[1], + expected_polygon) }) test_that("galah_polygon checks inputs", { diff --git a/tests/testthat/test-galah_radius.R b/tests/testthat/test-galah_radius.R index 9acfcac9..84b99c49 100644 --- a/tests/testthat/test-galah_radius.R +++ b/tests/testthat/test-galah_radius.R @@ -1,9 +1,13 @@ +purrr_radius <- purrr::quietly(galah_radius) +quiet_radius <- function(...){ + purrr_radius(...) |> + purrr::pluck("result")} test_that("galah_radius returns list from lon/lat/radius arguments", { lon <- 151.3174 lat <- -33.66741 radius = 3 - radius_object <- galah_radius(lon = lon, + radius_object <- quiet_radius(lon = lon, lat = lat, radius = radius) expected_object <- list(lat = -33.66741, @@ -15,12 +19,13 @@ test_that("galah_radius returns list from lon/lat/radius arguments", { test_that("galah_radius assigns default radius when missing argument", { lon <- 151.3174 lat <- -33.66741 - radius_object <- expect_warning(galah_radius(lon = lon, lat = lat), - "No radius value specified.") + radius_object <- purrr_radius(lon = lon, lat = lat) + grepl("No radius value specified.", radius_object$warning) |> + expect_true() expected_object <- list(lat = -33.66741, lon = 151.3174, radius = 10) # default is 10 km - expect_equal(radius_object, expected_object) + expect_equal(radius_object$result, expected_object) }) test_that("galah_radius returns radius for sf_POINT object", { @@ -29,7 +34,8 @@ test_that("galah_radius returns radius for sf_POINT object", { expected_object <- list(lat = -33.66741, lon = 151.3174, radius = 3) - expect_equal(galah_radius(point, radius = 3), expected_object) + quiet_radius(point, radius = 3) |> + expect_equal(expected_object) }) test_that("galah_radius errors when more complex sf objects are passed", { @@ -109,21 +115,16 @@ test_that("galah_radius only uses first arguments supplied to lon/lat/radius", { radius = multiple_radius), "More than 1 radius") - expect_equal( - suppressWarnings( - galah_radius(lon = multiple_lon, lat = -31, radius = radius)$lon), - expected_object$lon - ) - expect_equal( - suppressWarnings( - galah_radius(lon = 151, lat = multiple_lat, radius = radius)$lat), - expected_object$lat - ) - expect_equal( - suppressWarnings( - galah_radius(lon = 151, lat = -31, radius = multiple_radius)$radius), - expected_object$radius - ) + quiet_radius(lon = multiple_lon, lat = -31, radius = radius) |> + purrr::pluck("lon") |> + expect_equal(expected_object$lon) + quiet_radius(lon = 151, lat = multiple_lat, radius = radius) |> + purrr::pluck("lat") |> + expect_equal(expected_object$lat) + quiet_radius(lon = 151, lat = -31, radius = multiple_radius) |> + purrr::pluck("radius") |> + expect_equal(expected_object$radius) }) # TODO: (after implementing) galah_radius uses only first coordinates of tibble with many coordinates +rm(purrr_radius, quiet_radius) \ No newline at end of file diff --git a/tests/testthat/test-galah_select.R b/tests/testthat/test-galah_select.R index 3b625073..94ef7ebe 100644 --- a/tests/testthat/test-galah_select.R +++ b/tests/testthat/test-galah_select.R @@ -1,9 +1,25 @@ +# set up quiet functions for testing reasons +quiet_collect <- function(x){ + purrr_collect <- purrr::quietly(collect.data_request) + purrr_collect(x) |> + purrr::pluck("result") +} +quiet_occurrences <- purrr::quietly(atlas_occurrences) + +test_that("`select.data_request()` adds content to a `data_request` object", { + x <- galah_call() |> + select(group = "basic") + expect_equal(x$select, + list(summary = "group = basic", + group = "basic")) +}) + test_that("`galah_select()` doesn't return error when columns don't exist", { expect_no_error(galah_select(basisOfRecord)) expect_no_error(galah_select(year, basisOfRecord, eventdate)) }) -test_that("`galah_select()` triggers error during `compute()` when columns don't exist", { +test_that("`select()` triggers error during `compute()` when columns don't exist", { skip_if_offline(); skip_on_ci() expect_error( galah_call() |> @@ -19,33 +35,7 @@ test_that("`galah_select()` triggers error during `compute()` when columns don't compute()) }) -test_that("`galah_select()` returns requested columns", { - skip_if_offline(); skip_on_ci() - galah_config(atlas = "Australia", - email = "ala4r@ala.org.au", - run_checks = FALSE) - query <- galah_call() |> - identify("oxyopes dingo") |> - select(year, basisOfRecord) |> - atlas_occurrences() - expect_s3_class(query, c("tbl_df", "tbl", "data.frame" )) - expect_equal(names(query), c("year", "basisOfRecord")) - expect_gte(nrow(query), 10) -}) - -test_that("`galah_select()` returns requested columns when piped", { - skip_if_offline(); skip_on_ci() - galah_config(email = "ala4r@ala.org.au", run_checks = FALSE) - query <- galah_call() |> - identify("oxyopes dingo") |> - select(year, basisOfRecord) |> - collect() - expect_s3_class(query, c("tbl_df", "tbl", "data.frame" )) - expect_equal(names(query), c("year", "basisOfRecord")) - expect_gte(nrow(query), 10) -}) - -test_that("`galah_select()` builds expected columns when group = basic", { +test_that("`select()` builds expected columns when group = basic", { skip_if_offline(); skip_on_ci() x <- galah_call() |> identify("oxyopes dingo") |> @@ -56,7 +46,7 @@ test_that("`galah_select()` builds expected columns when group = basic", { expect_equal(y$qa, "none") }) -test_that("`galah_select()` builds expected columns when group = event", { +test_that("`select()` builds expected columns when group = event", { skip_if_offline(); skip_on_ci() x <- galah_call() |> identify("oxyopes dingo") |> @@ -68,7 +58,7 @@ test_that("`galah_select()` builds expected columns when group = event", { expect_equal(y$qa, "none") }) -test_that("`galah_select()` accepts multiple groups", { +test_that("`select()` accepts multiple groups", { skip_if_offline(); skip_on_ci() x <- galah_call() |> identify("oxyopes dingo") |> @@ -80,7 +70,7 @@ test_that("`galah_select()` accepts multiple groups", { expect_equal(y$qa, "includeall") }) -test_that("galah_select defaults to group = 'basic' when there are no args", { +test_that("`select()` defaults to group = 'basic' when there are no args", { skip_if_offline(); skip_on_ci() x <- galah_call() |> identify("oxyopes dingo") |> @@ -90,7 +80,7 @@ test_that("galah_select defaults to group = 'basic' when there are no args", { expect_equal(y$qa, "none") }) -test_that("galah_select works with group = 'taxonomy'", { +test_that("`select()` works with group = 'taxonomy'", { skip_if_offline(); skip_on_ci() x <- galah_call() |> identify("oxyopes dingo") |> @@ -110,7 +100,7 @@ test_that("galah_select works with group = 'taxonomy'", { "subspecies")) }) -test_that("galah_select returns assertions + recordID when group = assertions", { +test_that("`select()` returns assertions + recordID when group = assertions", { skip_if_offline(); skip_on_ci() x <- galah_call() |> identify("oxyopes dingo") |> @@ -121,18 +111,18 @@ test_that("galah_select returns assertions + recordID when group = assertions", expect_equal(y$qa, "includeall") }) -test_that("galah_select combines requested columns and group columns", { +test_that("`select()` combines requested columns and group columns", { skip_if_offline(); skip_on_ci() x <- galah_call() |> identify("oxyopes dingo") |> - select(year, basisOfRecord, group = "basic") |> + select(year, group = "basic") |> collapse() y <- httr2::url_parse(x$url)$query expect_equal(strsplit(y$fields, ",")[[1]], - c(preset_groups("basic"), "year", "basisOfRecord")) + c(preset_groups("basic"), "year")) }) -test_that("galah_select can use tidyselect::contains", { +test_that("`select()` can use `tidyselect::contains()`", { skip_if_offline(); skip_on_ci() x <- galah_call() |> identify("oxyopes dingo") |> @@ -145,7 +135,9 @@ test_that("galah_select can use tidyselect::contains", { expect_true(all(grepl("el", assertions))) }) -test_that("galah_select can use tidyselect::starts_with", { +# TODO: `galah_select()` fails when `everything()` is called for `type = "occurrences"` + +test_that("`select()` can use `tidyselect::starts_with()`", { skip_if_offline(); skip_on_ci() x <- galah_call() |> identify("oxyopes dingo") |> @@ -158,7 +150,7 @@ test_that("galah_select can use tidyselect::starts_with", { expect_true(all(grepl("^el", assertions))) }) -test_that("galah_select can use tidyselect::last_col", { +test_that("`select()` can use `tidyselect::last_col()`", { skip_if_offline(); skip_on_ci() x <- galah_call() |> identify("oxyopes dingo") |> @@ -169,7 +161,7 @@ test_that("galah_select can use tidyselect::last_col", { expect_equal(y$qa, "ZERO_COORDINATE") }) -test_that("galah_select can use tidyselect::last_col & user-defined queries", { +test_that("`select()` can use `tidyselect::last_col()` & user-defined queries", { skip_if_offline(); skip_on_ci() x <- galah_call() |> identify("oxyopes dingo") |> @@ -180,7 +172,7 @@ test_that("galah_select can use tidyselect::last_col & user-defined queries", { expect_equal(y$qa, "ZERO_COORDINATE") }) -test_that("galah_select can use tidyselect::last_col & group", { +test_that("`select()` can use `tidyselect::last_col()` & group", { skip_if_offline(); skip_on_ci() x <- galah_call() |> identify("oxyopes dingo") |> @@ -192,40 +184,10 @@ test_that("galah_select can use tidyselect::last_col & group", { expect_equal(y$qa, "ZERO_COORDINATE") }) -test_that("galah_select warns for invalid field names when type = 'species'", { +test_that("`select()` warns for invalid field names when type = 'species'", { skip_if_offline(); skip_on_ci() expect_warning({galah_call(type = "species") |> identify("Crinia") |> select(an_unrecognised_field_name) |> collapse()}) -}) - -test_that("galah_select works for type = 'species' with no arguments", { - skip_if_offline(); skip_on_ci() - x <- galah_call(type = "species") |> - identify("Crinia") |> - select() |> - collect() - expect_equal(colnames(x), "taxon_concept_id") - expect_gt(nrow(x), 10) -}) - -test_that("galah_select works for type = 'species'", { - skip_if_offline(); skip_on_ci() - x <- galah_call(type = "species") |> - identify("Crinia") |> - select(counts) |> - collect() - expect_equal(colnames(x), c("taxon_concept_id", "count")) - expect_gt(nrow(x), 10) -}) - -test_that("galah_select works for type = 'species' with group = 'taxonomy'", { - skip_if_offline(); skip_on_ci() - x <- galah_call(type = "species") |> - identify("Crinia") |> - select(counts, lists, group = "taxonomy") |> - collect() - expect_true(all(c("taxon_concept_id", "count", "kingdom", "phylum") %in% colnames(x))) - expect_gt(nrow(x), 10) -}) +}) \ No newline at end of file diff --git a/tests/testthat/test-search_taxa.R b/tests/testthat/test-search_taxa.R index 2fcef28f..59094c0c 100644 --- a/tests/testthat/test-search_taxa.R +++ b/tests/testthat/test-search_taxa.R @@ -1,20 +1,23 @@ -test_that("search_taxa checks inputs", { +purrr_taxa <- purrr::quietly(search_taxa) +purrr_identifiers <- purrr::quietly(search_identifiers) + +test_that("`search_taxa()` checks inputs", { expect_error(search_taxa()) }) -test_that("search_taxa works for simple queries", { +test_that("`search_taxa()` works for simple queries", { skip_if_offline(); skip_on_ci() taxa <- search_taxa("Microseris lanceolata") expect_equal(nrow(taxa), 1) }) -test_that("search_taxa works for multiple queries", { +test_that("`search_taxa()` works for multiple queries", { skip_if_offline(); skip_on_ci() taxa <- search_taxa(c("Eucalyptus", "Banksia", "Acacia")) expect_equal(nrow(taxa), 3) }) -test_that("search_taxa handles data.frame input", { +test_that("`search_taxa()` handles data.frame input", { skip_if_offline(); skip_on_ci() test_df <- data.frame(genus = c("Banksia", "Microseris"), kingdom = "Plantae") @@ -26,22 +29,36 @@ test_that("search_taxa handles data.frame input", { expect_equal(nrow(taxa), 2) }) -test_that("search_taxa handles a mix of valid and invalid queries", { +test_that("`search_taxa()` gives a message for invalid names", { skip_if_offline(); skip_on_ci() galah_config(verbose = TRUE) - expect_message(taxa <- search_taxa(c("Eucalyptus", "Banksia", "Wattle", "wootle"))) - expect_equal(nrow(taxa), 4) + taxa <- purrr_taxa("bad_term") + taxa |> + purrr::pluck("messages") |> + stringr::str_detect("^Matched 0 of 1 taxonomic search terms") |> + any() |> + expect_true() galah_config(verbose = FALSE) }) -test_that("search_taxa gives a message for invalid names", { +test_that("`search_taxa()` handles a mix of valid and invalid queries", { skip_if_offline(); skip_on_ci() galah_config(verbose = TRUE) - expect_message(search_taxa("bad_term")) + taxa <- c("Eucalyptus", "Banksia", "Wattle", "wootle") |> + purrr_taxa() + taxa |> + purrr::pluck("messages") |> + stringr::str_detect("^Matched 3 of 4 taxonomic search terms") |> + any() |> + expect_true() + taxa |> + purrr::pluck("result") |> + nrow() |> + expect_equal(4) galah_config(verbose = FALSE) }) -test_that("search_taxa searches using multiple ranks", { +test_that("`search_taxa()` searches using multiple ranks", { skip_if_offline(); skip_on_ci() taxa <- search_taxa(data.frame(genus = "Acacia", kingdom = "Plantae")) expect_s3_class(taxa, c("tbl_df", "tbl", "data.frame")) @@ -49,19 +66,26 @@ test_that("search_taxa searches using multiple ranks", { expect_equal(nrow(taxa), 1) }) -test_that("`search_identifiers()` works via `search_all()`", { +test_that("`search_taxa()` gives an error when homonyms are returned", { skip_if_offline(); skip_on_ci() - id <- "urn:lsid:biodiversity.org.au:afd.taxon:08b9a1f0-62ae-45ca-9208-e773b00021ed" - search <- search_identifiers(id) - search2 <- search_all(identifiers, id) + expect_warning(search_taxa("ACANTHOCEPHALA")) +}) - expect_equal(attributes(search)$call, "identifiers") - expect_s3_class(search, c("tbl_df", "tbl", "data.frame")) - expect_equal(nrow(search), 1) - expect_equal(search, search2) +test_that("`search_taxa()` handles name issues", { + skip_if_offline(); skip_on_ci() + expect_warning(search_taxa("Microseris")) }) -test_that("search_identifiers searches using identifier", { +test_that("`search_taxa()` give an error when homonym is returned with other issues", { + skip_if_offline(); skip_on_ci() + expect_warning(search_taxa("Gallinago sp.")) +}) + +test_that("`search_taxa()` errors nicely when piped in `galah_call()`", { + expect_error(galah_call() |> search_taxa("perameles"), "Can't pipe `search_taxa()") +}) + +test_that("`search_identifiers()` searches using identifier", { skip_if_offline(); skip_on_ci() # check different types of id identifier <- c("urn:lsid:biodiversity.org.au:afd.taxon:08b9a1f0-62ae-45ca-9208-e773b00021ed", @@ -71,42 +95,50 @@ test_that("search_identifiers searches using identifier", { expect_equal(nrow(taxa), 3) }) -test_that("search_identifiers works when one value is missing", { +test_that("`search_identifiers()` gives a message for invalid ids", { skip_if_offline(); skip_on_ci() - # check different types of id - identifier <- c("urn:lsid:biodiversity.org.au:afd.taxon:08b9a1f0-62ae-45ca-9208-e773b00021ed", - "something") galah_config(verbose = TRUE) - expect_message({taxa <- search_identifiers(identifier)}) - expect_s3_class(taxa, c("tbl_df", "tbl", "data.frame")) - expect_equal(nrow(taxa), 2) + taxa <- purrr_identifiers("1234") + taxa |> + purrr::pluck("messages") |> + stringr::str_detect("^Matched 0 of 1 taxonomic search terms") |> + any() |> + expect_true() galah_config(verbose = FALSE) }) -test_that("search_identifiers gives a message for invalid ids", { +test_that("`search_identifiers()` works when one value is missing", { skip_if_offline(); skip_on_ci() + # check different types of id + identifier <- c("urn:lsid:biodiversity.org.au:afd.taxon:08b9a1f0-62ae-45ca-9208-e773b00021ed", + "something") galah_config(verbose = TRUE) - expect_message(search_identifiers("1234")) + taxa <- purrr_identifiers(identifier) + taxa |> + purrr::pluck("messages") |> + stringr::str_detect("^Matched 1 of 2 taxonomic search terms") |> + any() |> + expect_true() + taxa |> + purrr::pluck("result") |> + expect_s3_class(c("tbl_df", "tbl", "data.frame")) + taxa |> + purrr::pluck("result") |> + nrow() |> + expect_equal(2) galah_config(verbose = FALSE) }) -test_that("search_taxa gives an error when homonyms are returned", { - skip_if_offline(); skip_on_ci() - expect_warning(search_taxa("ACANTHOCEPHALA")) -}) - -test_that("search_taxa handles name issues", { - skip_if_offline(); skip_on_ci() - expect_warning(search_taxa("Microseris")) -}) - -test_that("search_taxa give an error when homonym is returned with other issues", { +test_that("`search_identifiers()` works via `search_all()`", { skip_if_offline(); skip_on_ci() - expect_warning(search_taxa("Gallinago sp.")) -}) - -test_that("search_taxa errors nicely when piped in galah_call", { - expect_error(galah_call() |> search_taxa("perameles"), "Can't pipe `search_taxa()") + id <- "urn:lsid:biodiversity.org.au:afd.taxon:08b9a1f0-62ae-45ca-9208-e773b00021ed" + search <- search_identifiers(id) + search2 <- search_all(identifiers, id) + + expect_equal(attributes(search)$call, "identifiers") + expect_s3_class(search, c("tbl_df", "tbl", "data.frame")) + expect_equal(nrow(search), 1) + expect_equal(search, search2) }) test_that("`request_metadata()` works for `type = 'taxa'`", { @@ -123,3 +155,5 @@ test_that("`request_metadata()` works for `type = 'taxa'`", { expect_s3_class(z, c("tbl_df", "tbl", "data.frame")) expect_equal(nrow(z), 1) }) + +rm(purrr_taxa, purrr_identifiers) \ No newline at end of file diff --git a/tests/testthat/test-show_values.R b/tests/testthat/test-show_values.R index 515d9dd2..7d6786bc 100644 --- a/tests/testthat/test-show_values.R +++ b/tests/testthat/test-show_values.R @@ -1,4 +1,17 @@ -test_that("show_values checks values", { +# quiet `show_values()` +purrr_values <- purrr::quietly(show_values) +quiet_values <- function(x, ...){ + purrr_values(x, ...) |> + purrr::pluck("result") +} +# quiet `search_values()` +purrr_search <- purrr::quietly(search_values) +quiet_search <- function(x, y){ + purrr_search(x, query = y) |> + purrr::pluck("result") +} + +test_that("`show_values()` checks values", { skip_if_offline(); skip_on_ci() df <- tibble::tibble(x = c(1:2), y = c("a", "b")) wrong_type_search <- search_all(reasons, "sci") @@ -7,11 +20,11 @@ test_that("show_values checks values", { expect_error(wrong_type_search |> show_values(), "Can't lookup values for metadata type `reasons`.") }) -test_that("show_values accepts search & show_all inputs from fields", { +test_that("`show_values()` accepts search & show_all inputs from fields", { skip_if_offline(); skip_on_ci() # traditional syntax values_search <- search_all(lists, "EPBC act") |> - show_values() + quiet_values() expect_s3_class(values_search, c("tbl_df", "tbl", "data.frame")) expect_gt(nrow(values_search), 0) # newer syntax (doesn't require `show_all_lists()`) @@ -23,11 +36,11 @@ test_that("show_values accepts search & show_all inputs from fields", { expect_gt(nrow(values_show), 0) }) -test_that("show_values accepts search & show_all inputs from profiles", { +test_that("`show_values()` accepts search & show_all inputs from profiles", { skip_if_offline(); skip_on_ci() # traditional syntax values_search <- search_all(profiles, "ALA") |> - show_values() + quiet_values() expect_s3_class(values_search, c("tbl_df", "tbl", "data.frame")) expect_gt(nrow(values_search), 0) # newer syntax @@ -39,11 +52,11 @@ test_that("show_values accepts search & show_all inputs from profiles", { expect_gt(nrow(values_show), 0) }) -test_that("show_values accepts search & show_all inputs from lists", { +test_that("`show_values()` accepts search & show_all inputs from lists", { skip_if_offline(); skip_on_ci() # old syntax values_search <- search_all(fields, "cl22") |> - show_values() + quiet_values() expect_s3_class(values_search, c("tbl_df", "tbl", "data.frame")) expect_gt(nrow(values_search), 0) # new syntax @@ -55,17 +68,17 @@ test_that("show_values accepts search & show_all inputs from lists", { expect_gt(nrow(values_show), 0) }) -test_that("search_values returns helpful error when missing query", { +test_that("`search_values()` returns helpful error when missing query", { skip_if_offline(); skip_on_ci() expect_error(search_values(), "Missing information for values lookup") - expect_error(search_all(fields, "cl22") |> search_values(), "didn't detect a search") + expect_error(search_all(fields, "cl22") |> purrr_search(), "didn't detect a search") }) -test_that("search_values returns filtered results for fields", { +test_that("`search_values()` returns filtered results for fields", { skip_if_offline(); skip_on_ci() search <- search_all(fields, "cl22") - values_search <- search |> search_values("new") - values_show <- search |> show_values() + values_search <- search |> quiet_search("new") + values_show <- search |> quiet_values() search_result_check <- all(grepl(pattern = "new", paste(values_search[,1]), ignore.case = TRUE)) @@ -75,11 +88,11 @@ test_that("search_values returns filtered results for fields", { expect_true(search_result_check) }) -test_that("search_values returns filtered results for profiles", { +test_that("`search_values()` returns filtered results for profiles", { skip_if_offline(); skip_on_ci() search <- search_all(profiles, "ALA") - values_search <- search |> search_values("kingdom") - values_show <- search |> show_values() + values_search <- search |> quiet_search("kingdom") + values_show <- search |> quiet_values() search_result_check <- all(grepl(pattern = "kingdom", paste(values_search$description), ignore.case = TRUE)) @@ -89,21 +102,21 @@ test_that("search_values returns filtered results for profiles", { expect_true(search_result_check) }) -test_that("search_values returns filtered results for lists", { +test_that("`search_values()` returns filtered results for lists", { skip_if_offline(); skip_on_ci() search <- search_all(lists, "ALA") - values_search <- search |> search_values("frog") - values_show <- search |> show_values() + values_search <- search |> quiet_search("frog") + values_show <- search |> quiet_values() search_result_check <- all(grepl(pattern = "frog", paste(values_search$commonName, values_search$scientificName), ignore.case = TRUE)) expect_s3_class(values_search, c("tbl_df", "tbl", "data.frame")) - expect_equivalent(names(values_search), names(values_show)) + expect_equal(names(values_search), names(values_show)) expect_lt(nrow(values_search), nrow(values_show)) expect_true(search_result_check) }) -test_that("show_values & search_values return number of matched fields", { +test_that("`show_values()` & `search_values()` return number of matched fields", { skip_if_offline(); skip_on_ci() search1 <- search_fields("year") search2 <- search_fields("basisOfRecord") @@ -111,7 +124,7 @@ test_that("show_values & search_values return number of matched fields", { expect_message(search2 |> show_values(), "Showing values for 'basisOfRecord'") }) -test_that("search_values specifies matched field", { +test_that("`search_values()` specifies matched field", { skip_if_offline(); skip_on_ci() search1 <- search_fields("year") search2 <- search_fields("state") @@ -124,16 +137,16 @@ test_that("search_values specifies matched field", { # expect_message(search3 |> show_values(), n_fields3) }) -test_that("show_values returns unformatted names", { +test_that("`show_values()` returns unformatted names", { skip_if_offline(); skip_on_ci() expected <- tibble::tibble(basisOfRecord = c("HUMAN_OBSERVATION", "PRESERVED_SPECIMEN")) search <- search_all(fields, "basisOfRecord") - expect_equal(search |> show_values() |> head(2L), + expect_equal(search |> quiet_values() |> head(2L), expected) }) -test_that("unnest syntax works", { +test_that("`unnest()` syntax works", { skip_if_offline(); skip_on_ci() # fields x <- request_metadata() |> @@ -153,13 +166,12 @@ test_that("unnest syntax works", { expect_gte(nrow(y), 1) }) -test_that("show_values all_fields = TRUE works for lists", { +test_that("`show_values()` all_fields = TRUE works for lists", { skip_if_offline(); skip_on_ci() # simple, fake version for testing `show_values()` df <- tibble::tibble(species_list_uid = "dr650") attr(df, "call") <- "lists" - show_values_query <- show_values(df, all_fields = TRUE) - expect_equal(all_fields_query, show_values_query) + show_values_query <- quiet_values(df, all_fields = TRUE) # NOTE: above is same as following code, but much faster # search <- search_all(lists, "dr650") |> # show_values(all_fields = TRUE) @@ -169,8 +181,12 @@ test_that("show_values all_fields = TRUE works for lists", { expect_true(any(colnames(show_values_query) %in% extra_cols)) expect_gt(ncol(show_values_query), 6) # adds additional columns # doesn't work for fields - expect_warning(search_all(fields, "cl22") |> - show_values(all_fields = TRUE)) + x <- search_all(fields, "cl22") |> + purrr_values(all_fields = TRUE) + expect_equal(x$warnings, + "`all_fields` only applies to type `lists`. Ignoring `all_fields = TRUE`.") + expect_equal(x$messages, + "* Showing values for 'cl22'.") }) test_that("unnest() |> `select(everything()) works as alternative to all_fields",{ @@ -180,10 +196,10 @@ test_that("unnest() |> `select(everything()) works as alternative to all_fields" unnest() |> collect() extra_cols <- c("raw_scientificName", "status", "sourceStatus", "IUCN_equivalent_status") - expect_s3_class(show_values_query, c("tbl_df", "tbl", "data.frame")) - expect_gt(nrow(show_values_query), 0) - expect_true(any(colnames(show_values_query) %in% extra_cols)) - expect_gt(ncol(show_values_query), 6) # adds additional columns + expect_s3_class(x, c("tbl_df", "tbl", "data.frame")) + expect_gt(nrow(x), 0) + expect_true(any(colnames(x) %in% extra_cols)) + expect_gt(ncol(x), 6) # adds additional columns # explicitly errors for other metadata types request_metadata() |> @@ -192,3 +208,5 @@ test_that("unnest() |> `select(everything()) works as alternative to all_fields" unnest() |> expect_error() }) + +rm(purrr_values, quiet_values, purrr_search, quiet_search) \ No newline at end of file From 2ca234c6dbc6c94053b32966188af8be61b83a77 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Fri, 26 Sep 2025 20:33:18 +1000 Subject: [PATCH 30/94] test extending `everything()` to other metadata functions (#266) Proof of concept for type = "assertions" only at this stage --- NAMESPACE | 1 + R/as_query-metadata.R | 7 ++++--- R/as_query.R | 2 +- R/collect_metadata.R | 27 ++++++++++++++----------- R/galah_select.R | 8 +++++--- R/reexports.R | 4 ++++ R/tidyverse.R | 9 --------- R/utilities_caching.R | 2 +- R/utilities_internal.R | 40 ++++++++++++++++++++++++++++++++++++++ man/reexports.Rd | 3 +++ man/tidyverse_functions.Rd | 3 --- 11 files changed, 75 insertions(+), 31 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index adc6cc0b..1db9a221 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -125,3 +125,4 @@ importFrom(lifecycle,badge) importFrom(rlang,.data) importFrom(rlang,caller_env) importFrom(sf,st_crop) +importFrom(tidyselect,everything) diff --git a/R/as_query-metadata.R b/R/as_query-metadata.R index 5981f7f1..cc2672e5 100644 --- a/R/as_query-metadata.R +++ b/R/as_query-metadata.R @@ -49,19 +49,20 @@ as_query_apis <- function(){ #' NOTE: API doesn't accept any arguments - could post-filter for search #' @noRd #' @keywords Internal -as_query_assertions <- function(){ +as_query_assertions <- function(x){ query_type <- "metadata/assertions" if(is_gbif()){ result <- list(type = query_type, data = "galah:::gbif_internal_archived$assertions") }else{ - if(check_if_cache_update_needed("assertions")){ + if(check_if_cache_update_needed("assertions") | + everything_requested(x)){ result <- default_query(query_type) - }else{ result <- default_cache(query_type) } } + result$all_fields <- everything_requested(x) as_query(result) } diff --git a/R/as_query.R b/R/as_query.R index 4ea4c54e..7db0a937 100644 --- a/R/as_query.R +++ b/R/as_query.R @@ -89,7 +89,7 @@ as_query.data_request <- function(x, as_query.metadata_request <- function(x, ...){ switch(x$type, "apis" = as_query_apis(), - "assertions" = as_query_assertions(), + "assertions" = as_query_assertions(x), "atlases" = as_query_atlases(), "collections" = as_query_collections(x), "datasets" = as_query_datasets(x), diff --git a/R/collect_metadata.R b/R/collect_metadata.R index 46e2acc2..5172ab48 100644 --- a/R/collect_metadata.R +++ b/R/collect_metadata.R @@ -22,7 +22,8 @@ retrieve_internal_data <- function(.query){ } .query$data |> parse(text = _) |> - eval() + eval() |> + select_wanted_columns(.query) } #' Internal function to remove `list()` entries inside lists @@ -57,18 +58,22 @@ collect_apis <- function(.query){ #' @keywords Internal collect_assertions <- function(.query){ if(!is.null(.query$data)){ - result <- retrieve_internal_data(.query) + result_df <- retrieve_internal_data(.query) }else{ - result <- purrr::map(query_API(.query), - \(a){a[names(a) != "termsRequiredToTest"]}) |> - dplyr::bind_rows() - names(result) <- rename_columns(names(result), type = "assertions") - result <- result[wanted_columns("assertions")] - result$type <- "assertions" - result <- update_attributes(result, type = "assertions") - update_cache(assertions = result) + # get data from API + result_api <- purrr::map(query_API(.query), + \(a){a[names(a) != "termsRequiredToTest"]}) + # set up a pipe to transform results from the API + result_df <- result_api |> + dplyr::bind_rows() |> + dplyr::rename_with(camel_to_snake_case) |> + dplyr::rename(!!!colname_lookup("assertions")) + select_wanted_columns(.query) |> + dplyr::mutate(type = "assertions") |> # for consistency with `collect_fields()` + update_attributes(type = "assertions") + update_cache(assertions = result_df) } - result + result_df } #' Internal function to `collect()` atlases diff --git a/R/galah_select.R b/R/galah_select.R index 21f79268..2989abf5 100644 --- a/R/galah_select.R +++ b/R/galah_select.R @@ -150,9 +150,11 @@ select.data_request <- function(.data, ..., group){ #' @rdname select.data_request #' @export select.metadata_request <- function(.data, ...){ - if(.data$type != "lists"){ - cli::cli_abort("`select()` is only supported for type `lists`") - } + # if(.data$type != "lists"){ + # cli::cli_abort("`select()` is only supported for type `lists`") + # } + ## TODO: decide whether warnings are needed. + ## Probably inform("Skipping") would be fine select_entries <- rlang::enquos(..., .ignore_empty = "all") |> as.list() |> purrr::map(rlang::as_label) |> diff --git a/R/reexports.R b/R/reexports.R index 69868a4c..49c31e56 100644 --- a/R/reexports.R +++ b/R/reexports.R @@ -42,6 +42,10 @@ dplyr::slice_head #' @export dplyr::count +#' @importFrom tidyselect everything +#' @export +tidyselect::everything + #' @importFrom sf st_crop #' @export sf::st_crop \ No newline at end of file diff --git a/R/tidyverse.R b/R/tidyverse.R index 5080538c..b9e988f6 100644 --- a/R/tidyverse.R +++ b/R/tidyverse.R @@ -59,15 +59,6 @@ desc <- function(...){ parsed_dots <- parse_quosures_basic(dots) tibble::tibble(variable = parsed_dots, direction = "descending") -} - -#' @rdname tidyverse_functions -#' @export -everything <- function(){ - # still need to test this for - # - getting all fields in atlas_species() - # - erroring in atlas_occurrences() - # browser() } #' @rdname tidyverse_functions diff --git a/R/utilities_caching.R b/R/utilities_caching.R index aa565bdd..ff61d518 100644 --- a/R/utilities_caching.R +++ b/R/utilities_caching.R @@ -60,6 +60,6 @@ check_if_cache_update_needed <- function(function_name){ result <- is_local | is_wrong_atlas | is_too_short # if any, update is needed if(length(result) < 1){ # bug catcher result <- TRUE - } + } result } \ No newline at end of file diff --git a/R/utilities_internal.R b/R/utilities_internal.R index 55f75259..2db539ef 100644 --- a/R/utilities_internal.R +++ b/R/utilities_internal.R @@ -2,8 +2,39 @@ ## Output formatting functions -- ##--------------------------------------------------------------- +#' Internal function to check whether a query contains `select(everything())` +#' @noRd +#' @keywords Internal +everything_requested <- function(x){ + result <- FALSE + value <- purrr::pluck(x, "select", "value") + if(!is.null(value)){ + if(any(value == "everything()")){ + result <- TRUE + } + } + result +} + +#' Internal function to apply `select(everything())` when request4ed +#' @noRd +#' @keywords Internal +select_wanted_columns <- function(df, .query){ + if(isFALSE(.query$all_fields)){ + specific_type <- .query |> + purrr::pluck("type") |> + stringr::str_remove("^metadata/") + dplyr::select(df, + tidyselect::any_of(wanted_columns(specific_type))) + }else{ + df + } +} + # Select column names to return # Subsets data returned by webservices to useful columns +#' @noRd +#' @keywords Internal wanted_columns <- function(type) { switch(type, "taxa" = c("search_term", "scientific_name", @@ -43,6 +74,15 @@ wanted_columns <- function(type) { "reasons" = c("id", "name")) } +#' Internal function to rename specific columns, and convert to snake_case +#' @noRd +#' @keywords Internal +colname_lookup <- function(type){ + switch(type, + "assertions" = c("id" = "name") + ) +} + #' Internal function to rename specific columns, and convert to snake_case #' @noRd #' @keywords Internal diff --git a/man/reexports.Rd b/man/reexports.Rd index 778aeede..d361339c 100644 --- a/man/reexports.Rd +++ b/man/reexports.Rd @@ -13,6 +13,7 @@ \alias{group_by} \alias{slice_head} \alias{count} +\alias{everything} \alias{st_crop} \title{Objects exported from other packages} \keyword{internal} @@ -26,5 +27,7 @@ below to see their documentation. \item{graphics}{\code{\link[graphics]{identify}}} \item{sf}{\code{\link[sf]{st_crop}}} + + \item{tidyselect}{\code{\link[tidyselect]{everything}}} }} diff --git a/man/tidyverse_functions.Rd b/man/tidyverse_functions.Rd index 5c3fc98f..23b4a9d9 100644 --- a/man/tidyverse_functions.Rd +++ b/man/tidyverse_functions.Rd @@ -3,14 +3,11 @@ \name{tidyverse_functions} \alias{tidyverse_functions} \alias{desc} -\alias{everything} \alias{unnest} \title{Non-generic tidyverse functions} \usage{ desc(...) -everything() - unnest(.query) } \arguments{ From f9fe109e2132c5545e7dc9d1d17a14ca12cb2f1e Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Wed, 8 Oct 2025 10:01:31 +1100 Subject: [PATCH 31/94] First pass at supporting `select()` for metadata functions - support `select()` for more metadata types, not just `everything()` (#266) - wipe all default caches; always cache full tibble returned by API (#282) - convert `update_data_request()` to `update_request_object()` to extend to metadata queries --- NAMESPACE | 2 - R/as_query-files.R | 63 ++++ R/as_query-media.R | 133 -------- ...ry-unnest.R => as_query-metadata-unnest.R} | 9 +- R/as_query-metadata.R | 158 +++++++--- R/as_query-taxa.R | 49 ++- R/as_query.R | 14 +- R/atlas_media.R | 26 +- R/check.R | 4 +- R/collect-files.R | 39 +++ R/collect_media.R | 65 ---- R/collect_metadata.R | 208 ++++++++----- ...ect_unnest.R => collect_metadata_unnest.R} | 0 R/collect_taxa.R | 44 ++- R/galah_apply_profile.R | 6 +- R/galah_bbox.R | 3 +- R/galah_filter.R | 6 +- R/galah_geolocate.R | 3 +- R/galah_group_by.R | 6 +- R/galah_identify.R | 3 +- R/galah_polygon.R | 3 +- R/galah_radius.R | 3 +- R/galah_select.R | 19 +- R/handle_request_objects.R | 23 +- R/reexports.R | 4 - R/search_all.R | 3 +- R/sysdata.rda | Bin 15318 -> 6455 bytes R/taxonomic-searches.R | 25 +- R/url_lookup.R | 5 +- R/utilities_caching.R | 13 +- R/utilities_internal.R | 204 +++++++------ data-raw/2_internal_data.R | 3 +- man/reexports.Rd | 3 - man/search_all.Rd | 3 +- man/taxonomic_searches.Rd | 25 +- tests/testthat/test-caching.R | 2 +- tests/testthat/test-request_metadata_select.R | 286 ++++++++++++++++++ tests/testthat/test-request_metadata_unnest.R | 14 + tests/testthat/test-url_lookup.R | 30 ++ 39 files changed, 969 insertions(+), 540 deletions(-) create mode 100644 R/as_query-files.R delete mode 100644 R/as_query-media.R rename R/{as_query-unnest.R => as_query-metadata-unnest.R} (94%) create mode 100644 R/collect-files.R rename R/{collect_unnest.R => collect_metadata_unnest.R} (100%) create mode 100644 tests/testthat/test-request_metadata_select.R create mode 100644 tests/testthat/test-url_lookup.R diff --git a/NAMESPACE b/NAMESPACE index 1db9a221..cee61583 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -57,7 +57,6 @@ export(collect_media) export(compute) export(count) export(desc) -export(everything) export(filter) export(galah_apply_profile) export(galah_bbox) @@ -125,4 +124,3 @@ importFrom(lifecycle,badge) importFrom(rlang,.data) importFrom(rlang,caller_env) importFrom(sf,st_crop) -importFrom(tidyselect,everything) diff --git a/R/as_query-files.R b/R/as_query-files.R new file mode 100644 index 00000000..0b4d0ab2 --- /dev/null +++ b/R/as_query-files.R @@ -0,0 +1,63 @@ +#' Internal version of `as_query()` for `request_files(type = "media")` +#' @param .query An object of class `files_request` (from `request_files()`) +#' @noRd +#' @keywords Internal +as_query_media_files <- function(.query, + thumbnail = FALSE, + error_call = rlang::caller_env() + ){ + # handle filters + if(is.null(.query$filter)){ + cli::cli_abort("`collapse()` requires a `filter()` argument to function.", + call = error_call) + } + df <- .query$filter + if(any(colnames(df) == "media_id")){ + identifiers <- df$media_id + }else if(any(colnames(df) == "image_id")){ + identifiers <- df$image_id + }else{ + cli::cli_abort("No valid identifiers found in supplied data.", + call = error_call) + } + path <- build_file_path(ids = identifiers, types = df$mimetype) + if(any(colnames(df) == "image_url")){ + url <- df$image_url + }else{ + url <- url_lookup("files/images", + id = identifiers) + } + # handle thumbnails + if(thumbnail){ + url <- gsub("/original", "/thumbnail", url) + } + + # create result + result <- list( + type = "files/media", + url = tibble::tibble(url = url, path = path), + headers = build_headers()) + + class(result) <- "query" + return(result) +} + +#' build file paths that include 1. path, 2. file name, 3. correct extension +#' @noRd +#' @keywords Internal +build_file_path <- function(ids, types){ + path <- potions::pour("package", "directory", .pkg = "galah") + ext <- dplyr::case_match(types, + "image/jpg" ~ "jpg", + "image/jpeg" ~ "jpg", + "image/png" ~ "png", + "audio/mpeg" ~ "mpg", + "audio/x-wav" ~ "wav", + "audio/mp4" ~ "mp4", + "image/gif" ~ "gif", + "video/3gpp" ~ "3gp", + "video/quicktime" ~ "mov", + "audio/vnd.wave" ~ "wav") + glue::glue("{path}/{ids}.{ext}") |> + as.character() +} \ No newline at end of file diff --git a/R/as_query-media.R b/R/as_query-media.R deleted file mode 100644 index f88dc4bc..00000000 --- a/R/as_query-media.R +++ /dev/null @@ -1,133 +0,0 @@ -#' Internal version of `as_query()` for `request_metadata(type = "media")` -#' @param .query An object of class `metadata_request` (from `request_metadata()`) -#' @noRd -#' @keywords Internal -as_query_media_metadata <- function(.query, - error_call = rlang::caller_env()){ - # NOTE: - # this function currently assumes that the user has passed an occurrence - # tibble verbatim to filter, i.e. - # `request_metadata() |> filter(media = occurrences) |> collapse()` - # It may be useful to support passing of media_ids directly, e.g. - # `request_metadata() |> filter(media = occurrences$images`) |> collapse() - if(is.null(.query$filter)){ - abort("Requests for metadata of type = \"media\" must have information passed via `filter()`") - } - occ <- .query$filter$data - if(any(colnames(occ) %in% c("images", "videos", "sounds"))){ # Australia, Sweden, Spain - media_cols <- which(colnames(occ) %in% c("images", "videos", "sounds")) - media_ids <- do.call(c, occ[, media_cols]) |> - unlist() - media_ids <- media_ids[!is.na(media_ids)] - names(media_ids) <- NULL - }else if(any(colnames(occ) == "all_image_url")){ # Austria, Sweden, UK - media_ids <- dplyr::pull(occ, "all_image_url") - media_ids <- media_ids[!is.na(media_ids)] - names(media_ids) <- NULL - }else{ - cli::cli_abort("Media metadata not found in supplied tibble", - call = error_call) - } - - result <- list( - type = "metadata/media", - url = url_lookup("metadata/media"), - headers = build_headers(), - body = jsonlite::toJSON(list(imageIds = media_ids)), - filter = .query$filter) - class(result) <- "query" - return(result) -} - -#' Internal version of `as_query()` for `request_files(type = "media")` -#' @param .query An object of class `files_request` (from `request_files()`) -#' @noRd -#' @keywords Internal -as_query_media_files <- function(.query, - thumbnail = FALSE, - error_call = rlang::caller_env() - ){ - # handle filters - if(is.null(.query$filter)){ - cli::cli_abort("`collapse()` requires a `filter()` argument to function.", - call = error_call) - } - df <- .query$filter - if(any(colnames(df) == "media_id")){ - identifiers <- df$media_id - }else if(any(colnames(df) == "image_id")){ - identifiers <- df$image_id - }else{ - cli::cli_abort("No valid identifiers found in supplied data.", - call = error_call) - } - path <- build_file_path(ids = identifiers, types = df$mimetype) - if(any(colnames(df) == "image_url")){ - url <- df$image_url - }else{ - url <- url_lookup("files/images", - id = identifiers) - } - # handle thumbnails - if(thumbnail){ - url <- gsub("/original", "/thumbnail", url) - } - - # create result - result <- list( - type = "files/media", - url = tibble::tibble(url = url, path = path), - headers = build_headers()) - - class(result) <- "query" - return(result) -} - -#' Internal function to get media metadata, and create a valid file name -#' @noRd -#' @keywords Internal -build_media_id <- function(df){ - # create a column that includes media identifiers, regardless of which column they are in - ## NOTE: I haven't found good tidyverse syntax for this yet - x <- rep(NA, nrow(df)) - if(any(colnames(df) == "videos")){ - videos <- !is.na(df$videos) - if(any(videos)){x[videos] <- df$videos[videos]} - } - if(any(colnames(df) == "sounds")){ - sounds <- !is.na(df$sounds) - if(any(sounds)){x[sounds] <- df$sounds[sounds]} - } - if(any(colnames(df) == "images")){ - images <- !is.na(df$images) - if(any(images)){x[images] <- df$images[images]} - } - x -} - -#' build file paths that include 1. path, 2. file name, 3. correct extension -#' @noRd -#' @keywords Internal -build_file_path <- function(ids, types){ - path <- potions::pour("package", "directory", .pkg = "galah") - ext <- build_file_extension(types) - glue::glue("{path}/{ids}.{ext}") |> - as.character() -} - -#' get extensions for media files -#' @noRd -#' @keywords Internal -build_file_extension <- function(x){ - dplyr::case_match(x, - "image/jpg" ~ "jpg", - "image/jpeg" ~ "jpg", - "image/png" ~ "png", - "audio/mpeg" ~ "mpg", - "audio/x-wav" ~ "wav", - "audio/mp4" ~ "mp4", - "image/gif" ~ "gif", - "video/3gpp" ~ "3gp", - "video/quicktime" ~ "mov", - "audio/vnd.wave" ~ "wav") -} diff --git a/R/as_query-unnest.R b/R/as_query-metadata-unnest.R similarity index 94% rename from R/as_query-unnest.R rename to R/as_query-metadata-unnest.R index 6dba36a5..885e1b3e 100644 --- a/R/as_query-unnest.R +++ b/R/as_query-metadata-unnest.R @@ -15,11 +15,10 @@ as_query_fields_unnest <- function(.query){ facets = .query$filter$value[1], flimit = 10^4) } - result <- list( - type = "metadata/fields-unnest", - url = httr2::url_build(url)) - class(result) <- "query" - return(result) + list(type = "metadata/fields-unnest", + url = httr2::url_build(url)) |> + enforce_select_query(.query) |> + as_query() } #' Internal function to run `as_query()` for diff --git a/R/as_query-metadata.R b/R/as_query-metadata.R index cc2672e5..a3eaba2a 100644 --- a/R/as_query-metadata.R +++ b/R/as_query-metadata.R @@ -34,14 +34,38 @@ filtered_query <- function(query_type, .query){ headers = build_headers()) } +#' Internal function to enforce `select()` for queries. Basically just supplies +#' defaults. +#' @noRd +#' @keywords Internal +enforce_select_query <- function(new_query, supplied_query){ + if(is.null(supplied_query$select)){ + specific_type <- supplied_query |> + purrr::pluck("type") |> + stringr::str_remove("^metadata/") + chosen_columns <- wanted_columns(specific_type) + if(is.null(chosen_columns)){ + supplied_query <- dplyr::select(supplied_query, + tidyselect::everything()) + }else{ + supplied_query <- dplyr::select(supplied_query, + tidyselect::any_of(wanted_columns(specific_type))) + } + + } + update_request_object(new_query, select = supplied_query$select) +} + + # Actual functions called to build those queries #' Internal function get a tibble of APIs #' @noRd #' @keywords Internal -as_query_apis <- function(){ +as_query_apis <- function(x){ list(type = "metadata/apis", data = "galah:::node_config") |> + enforce_select_query(supplied_query = x) |> as_query() } @@ -55,36 +79,37 @@ as_query_assertions <- function(x){ result <- list(type = query_type, data = "galah:::gbif_internal_archived$assertions") }else{ - if(check_if_cache_update_needed("assertions") | - everything_requested(x)){ + if(check_if_cache_update_needed("assertions")){ result <- default_query(query_type) }else{ result <- default_cache(query_type) } } - result$all_fields <- everything_requested(x) - as_query(result) + result |> + enforce_select_query(supplied_query = x) |> + as_query() } #' Internal function to create an atlases query #' @noRd #' @keywords Internal -as_query_atlases <- function(){ +as_query_atlases <- function(x){ list(type = "metadata/atlases", data = "galah:::node_metadata") |> + enforce_select_query(supplied_query = x) |> as_query() } #' Internal function to create a collections query #' @noRd #' @keywords Internal -as_query_collections <- function(.query){ +as_query_collections <- function(x){ # set `type` query_type <- "metadata/collections" # If `filter()` is supplied, we always need a query - if(is_gbif() & !missing(.query)){ - if(!is.null(.query$filter)){ - result <- filtered_query(query_type, .query) + if(is_gbif() & !missing(x)){ + if(!is.null(x$filter)){ + result <- filtered_query(query_type, x) }else{ result <- default_query(query_type) } @@ -96,7 +121,9 @@ as_query_collections <- function(.query){ result <- default_cache(query_type) } } - as_query(result) + result |> + enforce_select_query(supplied_query = x) |> + as_query() } # NOTE: LA collectory functions do not accept `max` or `offset` # Therefore they cannot be paginated. GBIF collectory funs can. @@ -104,13 +131,13 @@ as_query_collections <- function(.query){ #' Internal function to create a datasets query #' @noRd #' @keywords Internal -as_query_datasets <- function(.query){ +as_query_datasets <- function(x){ # set `type` query_type <- "metadata/datasets" # If `filter()` is supplied, we always need a query - if(is_gbif() & !missing(.query)){ - if(!is.null(.query$filter)){ - result <- filtered_query(query_type, .query) + if(is_gbif() & !missing(x)){ + if(!is.null(x$filter)){ + result <- filtered_query(query_type, x) }else{ result <- default_query(query_type) } @@ -122,7 +149,9 @@ as_query_datasets <- function(.query){ result <- default_cache(query_type) } } - as_query(result) + result |> + enforce_select_query(supplied_query = x) |> + as_query() } #' Internal function to create a fields query @@ -130,41 +159,45 @@ as_query_datasets <- function(.query){ #' from multiple APIs #' @noRd #' @keywords Internal -as_query_fields <- function(){ +as_query_fields <- function(x){ query_type <- "metadata/fields" if(check_if_cache_update_needed("fields")){ result <- default_query(query_type) }else{ result <- default_cache(query_type) } - as_query(result) + result |> + enforce_select_query(supplied_query = x) |> + as_query() } #' Internal function to create a licences query #' @noRd #' @keywords Internal -as_query_licences <- function(){ +as_query_licences <- function(x){ query_type <- "metadata/licences" if(check_if_cache_update_needed("licences")){ result <- default_query(query_type) }else{ result <- default_cache(query_type) } - as_query(result) + result |> + enforce_select_query(supplied_query = x) |> + as_query() } #' Internal function to create a lists query #' @noRd #' @keywords Internal -as_query_lists <- function(.query){ +as_query_lists <- function(x){ query_type <- "metadata/lists" if(check_if_cache_update_needed("lists")){ url <- url_lookup(query_type) |> httr2::url_parse() url$query <- list(max = 10000) - if(!missing(.query)){ - if(!is.null(.query$slice)){ - url$query <- list(max = .query$slice$slice_n) + if(!missing(x)){ + if(!is.null(x$slice)){ + url$query <- list(max = x$slice$slice_n) } } result <- list(type = query_type, @@ -174,32 +207,77 @@ as_query_lists <- function(.query){ }else{ result <- default_cache(query_type) } - as_query(result) + result |> + enforce_select_query(supplied_query = x) |> + as_query() +} + +#' Internal version of `as_query()` for `request_metadata(type = "media")` +#' @param .query An object of class `metadata_request` (from `request_metadata()`) +#' @noRd +#' @keywords Internal +as_query_media_metadata <- function(.query, + error_call = rlang::caller_env()){ + # NOTE: + # this function currently assumes that the user has passed an occurrence + # tibble verbatim to filter, i.e. + # `request_metadata() |> filter(media = occurrences) |> collapse()` + # It may be useful to support passing of media_ids directly, e.g. + # `request_metadata() |> filter(media = occurrences$images`) |> collapse() + if(is.null(.query$filter)){ + cli::cli_abort("Requests for metadata of type = \"media\" must have information passed via `filter()`", + call = error_call) + } + occ <- .query$filter$data + if(any(colnames(occ) %in% c("images", "videos", "sounds"))){ # Australia, Sweden, Spain + media_cols <- which(colnames(occ) %in% c("images", "videos", "sounds")) + media_ids <- do.call(c, occ[, media_cols]) |> + unlist() + media_ids <- media_ids[!is.na(media_ids)] + names(media_ids) <- NULL + }else if(any(colnames(occ) == "all_image_url")){ # Austria, Sweden, UK + media_ids <- dplyr::pull(occ, "all_image_url") + media_ids <- media_ids[!is.na(media_ids)] + names(media_ids) <- NULL + }else{ + cli::cli_abort("Media metadata not found in supplied tibble", + call = error_call) + } + list(type = "metadata/media", + url = url_lookup("metadata/media"), + headers = build_headers(), + body = jsonlite::toJSON(list(imageIds = media_ids)), + filter = .query$filter) |> + enforce_select_query(supplied_query = .query) |> + as_query() + } #' Internal function to create a profiles query #' @noRd #' @keywords Internal -as_query_profiles <- function(){ +as_query_profiles <- function(x){ query_type <- "metadata/profiles" if(check_if_cache_update_needed("profiles")){ result <- default_query(query_type) }else{ result <- default_cache(query_type) } - as_query(result) + result |> + enforce_select_query(supplied_query = x) |> + as_query() } #' Internal function to create a providers query #' @noRd #' @keywords Internal -as_query_providers <- function(.query){ +as_query_providers <- function(x){ # set `type` query_type <- "metadata/providers" # If `filter()` is supplied, we always need a query - if(is_gbif() & !missing(.query)){ - if(!is.null(.query$filter)){ - result <- filtered_query(query_type, .query) + if(is_gbif() & !missing(x)){ + if(!is.null(x$filter)){ + result <- filtered_query(query_type, x) }else{ result <- default_query(query_type) } @@ -211,26 +289,30 @@ as_query_providers <- function(.query){ result <- default_cache(query_type) } } - as_query(result) + result |> + enforce_select_query(supplied_query = x) |> + as_query() } #' Internal function to create a reasons query #' @noRd #' @keywords Internal -as_query_reasons <- function(){ +as_query_reasons <- function(x){ query_type <- "metadata/reasons" if(check_if_cache_update_needed("reasons")){ result <- default_query(query_type) }else{ result <- default_cache(query_type) } - as_query(result) + result |> + enforce_select_query(supplied_query = x) |> + as_query() } #' Internal function to create a ranks query #' @noRd #' @keywords Internal -as_query_ranks <- function(){ +as_query_ranks <- function(x){ if(is_gbif()){ result <- list(type = "metadata/ranks", data = "galah:::gbif_internal_archived$ranks") @@ -238,5 +320,7 @@ as_query_ranks <- function(){ result <- list(type = "metadata/ranks", data = "galah:::galah_internal_archived$ranks") } - as_query(result) -} + result |> + enforce_select_query(supplied_query = x) |> + as_query() +} \ No newline at end of file diff --git a/R/as_query-taxa.R b/R/as_query-taxa.R index cdaaab62..a0b4ea17 100644 --- a/R/as_query-taxa.R +++ b/R/as_query-taxa.R @@ -4,34 +4,29 @@ as_query_taxa <- function(.query){ if(is.null(.query$identify)){ result <- list(type = "metadata/taxa") - class(result) <- "query" - result }else{ if(ncol(.query$identify) > 1 | colnames(.query$identify)[1] != "search_term"){ - as_query_taxa_multiple(.query) + result <- as_query_taxa_multiple(.query) }else{ - as_query_taxa_single(.query) + result <- as_query_taxa_single(.query) } } + result |> + enforce_select_query(supplied_query = .query) |> + as_query() } #' Internal function to `as_query()` for a single taxonomic name #' @noRd #' @keywords Internal as_query_taxa_single <- function(.query){ - urls <- lapply(.query$identify$search_term, - function(a){url_lookup("metadata/taxa-single", - name = a)}) |> - unlist() - search_terms <- .query$identify$search_term - # build object and return - result <- list(type = "metadata/taxa-single", - url = tibble::tibble(url = urls, - search_term = search_terms), - headers = build_headers()) - class(result) <- "query" - return(result) + terms <- .query$identify$search_term + list(type = "metadata/taxa-single", + url = tibble::tibble(url = url_lookup("metadata/taxa-single", + name = terms), + search_term = terms), + headers = build_headers()) } #' Internal function to `collapse()` where multiple taxonomic levels are given @@ -62,12 +57,10 @@ as_query_taxa_multiple <- function(.query){ unlist() # build object and return - result <- list(type = "metadata/taxa-multiple", - url = tibble::tibble(url = urls, - search_term = search_terms), - headers = build_headers()) - class(result) <- "query" - return(result) + list(type = "metadata/taxa-multiple", + url = tibble::tibble(url = urls, + search_term = search_terms), + headers = build_headers()) } #' Internal function to create an identifiers query @@ -99,12 +92,12 @@ as_query_identifiers <- function(.query){ } } # build object and return - result <- list(type = "metadata/identifiers", - url = tibble::tibble(url = urls, - search_term = search_terms), - headers = build_headers()) - class(result) <- "query" - return(result) + list(type = "metadata/identifiers", + url = tibble::tibble(url = urls, + search_term = search_terms), + headers = build_headers()) |> + enforce_select_query(supplied_query = .query) |> + as_query() } #' Internal function to accept only specific taxon ranks for searching diff --git a/R/as_query.R b/R/as_query.R index 7db0a937..9251c385 100644 --- a/R/as_query.R +++ b/R/as_query.R @@ -88,23 +88,23 @@ as_query.data_request <- function(x, #' @export as_query.metadata_request <- function(x, ...){ switch(x$type, - "apis" = as_query_apis(), + "apis" = as_query_apis(x), "assertions" = as_query_assertions(x), - "atlases" = as_query_atlases(), + "atlases" = as_query_atlases(x), "collections" = as_query_collections(x), "datasets" = as_query_datasets(x), "distributions" = as_query_distributions_metadata(x), - "fields" = as_query_fields(), + "fields" = as_query_fields(x), "fields-unnest" = as_query_fields_unnest(x), - "licences" = as_query_licences(), + "licences" = as_query_licences(x), "lists" = as_query_lists(x), "lists-unnest" = as_query_lists_unnest(x), "media" = as_query_media_metadata(x), - "profiles" = as_query_profiles(), + "profiles" = as_query_profiles(x), "profiles-unnest" = as_query_profiles_unnest(x), "providers" = as_query_providers(x), - "ranks" = as_query_ranks(), - "reasons" = as_query_reasons(), + "ranks" = as_query_ranks(x), + "reasons" = as_query_reasons(x), "taxa" = as_query_taxa(x), "taxa-unnest" = as_query_taxa_unnest(x), "identifiers" = as_query_identifiers(x), diff --git a/R/atlas_media.R b/R/atlas_media.R index 4d35530f..785e8233 100644 --- a/R/atlas_media.R +++ b/R/atlas_media.R @@ -25,8 +25,8 @@ atlas_media <- function(request = NULL, # ensure media columns are present in `select` if(is.null(.query$select)){ - .query <- update_data_request(.query, - select = galah_select(group = c("basic", "media"))) + .query <- update_request_object(.query, + select = galah_select(group = c("basic", "media"))) present_fields <- image_fields() present_fields <- present_fields[present_fields != "multimedia"] # check these fields for Spain query_collapse <- collapse(.query) @@ -121,4 +121,26 @@ parse_regional_media_filters <- function(present_fields, cli::cli_abort("`atlas_media` is not supported for atlas = {atlas}", call = error_call) ) +} + +#' Internal function to get media metadata, and create a valid file name +#' @noRd +#' @keywords Internal +build_media_id <- function(df){ + # create a column that includes media identifiers, regardless of which column they are in + ## NOTE: I haven't found good tidyverse syntax for this yet + x <- rep(NA, nrow(df)) + if(any(colnames(df) == "videos")){ + videos <- !is.na(df$videos) + if(any(videos)){x[videos] <- df$videos[videos]} + } + if(any(colnames(df) == "sounds")){ + sounds <- !is.na(df$sounds) + if(any(sounds)){x[sounds] <- df$sounds[sounds]} + } + if(any(colnames(df) == "images")){ + images <- !is.na(df$images) + if(any(images)){x[images] <- df$images[images]} + } + x } \ No newline at end of file diff --git a/R/check.R b/R/check.R index 75b9b1f4..b52b25b6 100644 --- a/R/check.R +++ b/R/check.R @@ -5,10 +5,10 @@ check_atlas_inputs <- function(args){ if(!is.null(args$request)){ check_data_request(args$request) - update_data_request(args$request, args[-1]) + update_request_object(args$request, args[-1]) }else{ galah_call() |> - update_data_request(args[-1]) + update_request_object(args[-1]) } } diff --git a/R/collect-files.R b/R/collect-files.R new file mode 100644 index 00000000..1b4935b2 --- /dev/null +++ b/R/collect-files.R @@ -0,0 +1,39 @@ +#' Internal version of `collect()` for `request_files(type = "media")` +#' @param object of class `files_response`, from `compute()` +#' @noRd +#' @keywords Internal +collect_media_files <- function(.query){ + result <- query_API(.query) + status_values <- purrr::map(result, + \(a){a$status_code}) |> + unlist() + result_summary <- tibble::tibble(status_code = status_values) |> + dplyr::group_by(.data$status_code) |> + dplyr::count() + + # successful downloads + success <- result_summary |> + dplyr::filter(.data$status_code == 200) + n_downloaded <- success[["n"]] + + # failed downloads + fail <- NULL + if(nrow(result_summary) > 1) { + if(any(result_summary$status_code %in% "403")) { + fail <- result_summary |> + dplyr::filter(.data$status_code == 403) + n_failed <- fail[["n"]] + } + } + user_directory <- potions::pour("package", "directory") + bullets <- c( + "v" = "Downloaded {n_downloaded} files successfully (status 200).", + ">" = "Files saved in local directory: \"{user_directory}\"." + ) + if(!is.null(fail)) { + bullets <- c(bullets, + "x" = "Failed {n_failed} downloads due to missing images (status 403)") + } + cli::cli_inform(bullets) + invisible(result_summary) +} \ No newline at end of file diff --git a/R/collect_media.R b/R/collect_media.R index 0ee80c93..214a3831 100644 --- a/R/collect_media.R +++ b/R/collect_media.R @@ -1,68 +1,3 @@ -#' Internal version of `collect()` for `request_data(type = "media")` -#' @param object of class `data_response`, from `compute()` -#' @noRd -#' @keywords Internal -collect_media_metadata <- function(.query){ - result <- query_API(.query) |> - purrr::pluck("results") |> - dplyr::bind_rows() - if(nrow(result) < 1){ # case where no data returned - if(potions::pour("package", "verbose")){ - cli::cli_warn("No data returned from `metadata/media` API") - } - ids <- .query$body |> - jsonlite::fromJSON() |> - unlist() - result <- tibble::tibble(image_id = ids) - }else{ - colnames(result) <- rename_columns(names(result), type = "media") - } - # Select only the rows and columns we want - result |> - dplyr::filter(!is.na(result$image_id)) |> - dplyr::select(dplyr::any_of(wanted_columns("media"))) -} - -#' Internal version of `collect()` for `request_files(type = "media")` -#' @param object of class `files_response`, from `compute()` -#' @noRd -#' @keywords Internal -collect_media_files <- function(.query){ - result <- query_API(.query) - status_values <- purrr::map(result, - \(a){a$status_code}) |> - unlist() - result_summary <- tibble::tibble(status_code = status_values) |> - dplyr::group_by(.data$status_code) |> - dplyr::count() - - # successful downloads - success <- result_summary |> - dplyr::filter(.data$status_code == 200) - n_downloaded <- success[["n"]] - - # failed downloads - fail <- NULL - if(nrow(result_summary) > 1) { - if(any(result_summary$status_code %in% "403")) { - fail <- result_summary |> - dplyr::filter(.data$status_code == 403) - n_failed <- fail[["n"]] - } - } - user_directory <- potions::pour("package", "directory") - bullets <- c( - "v" = "Downloaded {n_downloaded} files successfully (status 200).", - ">" = "Files saved in local directory: \"{user_directory}\"." - ) - if(!is.null(fail)) { - bullets <- c(bullets, - "x" = "Failed {n_failed} downloads due to missing images (status 403)") - } - cli::cli_inform(bullets) - invisible(result_summary) -} - #' Collect media files #' #' @description diff --git a/R/collect_metadata.R b/R/collect_metadata.R index 5172ab48..54b106e6 100644 --- a/R/collect_metadata.R +++ b/R/collect_metadata.R @@ -22,8 +22,7 @@ retrieve_internal_data <- function(.query){ } .query$data |> parse(text = _) |> - eval() |> - select_wanted_columns(.query) + eval() } #' Internal function to remove `list()` entries inside lists @@ -50,7 +49,8 @@ flat_lists_only <- function(x){ #' @keywords Internal collect_apis <- function(.query){ retrieve_internal_data(.query) |> - update_attributes(type = "apis") + update_attributes(type = "apis") |> + parse_select(.query) } #' Internal function to `collect()` assertions @@ -67,21 +67,22 @@ collect_assertions <- function(.query){ result_df <- result_api |> dplyr::bind_rows() |> dplyr::rename_with(camel_to_snake_case) |> - dplyr::rename(!!!colname_lookup("assertions")) - select_wanted_columns(.query) |> + parse_rename(type = "assertions") |> dplyr::mutate(type = "assertions") |> # for consistency with `collect_fields()` update_attributes(type = "assertions") update_cache(assertions = result_df) } - result_df + parse_select(result_df, .query) # always evaluate `select()` last } #' Internal function to `collect()` atlases +#' NOTE: This function does not cache anything because it *always* calls internal data #' @noRd #' @keywords Internal collect_atlases <- function(.query){ retrieve_internal_data(.query) |> - update_attributes(type = "atlases") + update_attributes(type = "atlases") |> + parse_select(.query) } #' Internal function to `collect()` collections @@ -89,32 +90,41 @@ collect_atlases <- function(.query){ #' @keywords Internal collect_collections <- function(.query){ if(!is.null(.query$data)){ - retrieve_internal_data(.query) + result_df <- retrieve_internal_data(.query) }else{ + # Handle GBIF first if(is_gbif()){ result <- query_API(.query) if(any(names(result) == "results")){ # happens when `filter()` not specified # Note: This assumes only one API call; will need more potentially result <- purrr::pluck(result, "results") } - result <- flat_lists_only(result) |> + result_df <- result |> + flat_lists_only() |> dplyr::bind_rows() + # Then France }else if(potions::pour("atlas", "region", .pkg = "galah") == "France"){ - result <- query_API(.query) |> + result <- .query |> + query_API() |> purrr::pluck("_embedded", "producers") |> unlist() - result <- tibble::tibble(name = result) + result_df <- tibble::tibble(name = result) + # Finally, all other Living Atlases }else{ - result <- query_API(.query) |> - dplyr::bind_rows() - result_reordered <- dplyr::relocate(result, "uid") - result <- result_reordered |> + result <- .query |> + query_API() + result_df <- result |> + dplyr::bind_rows() |> + dplyr::relocate("uid") |> dplyr::rename("id" = "uid") } - result <- update_attributes(result, type = "collections") - update_cache(collections = result) - result + result_df <- result_df |> + dplyr::rename_with(camel_to_snake_case) |> + dplyr::arrange(.data$id) |> + update_attributes(type = "collections") + update_cache(collections = result_df) } + parse_select(result_df, .query) } #' Internal function to `collect()` datasets @@ -122,7 +132,7 @@ collect_collections <- function(.query){ #' @keywords Internal collect_datasets <- function(.query){ if(!is.null(.query$data)){ - retrieve_internal_data(.query) + result_df <- retrieve_internal_data(.query) }else{ result <- query_API(.query) if(is_gbif()){ @@ -130,23 +140,26 @@ collect_datasets <- function(.query){ # Note: This assumes only one API call; will need more potentially result <- purrr::pluck(result, "results") } - result <- result |> + result_df <- result |> flat_lists_only() |> dplyr::bind_rows() }else if(potions::pour("atlas", "region", .pkg = "galah") == "France"){ - result <- result |> + result_df <- result |> purrr::pluck("_embedded", "datasets") |> dplyr::bind_rows() }else{ - result <- result |> + result_df <- result |> dplyr::bind_rows() |> dplyr::relocate("uid") |> dplyr::rename("id" = "uid") } - result <- update_attributes(result, type = "datasets") - update_cache(datasets = result) - result + result_df <- result_df |> + dplyr::rename_with(camel_to_snake_case) |> + dplyr::arrange(.data$id) |> + update_attributes(type = "datasets") + update_cache(datasets = result_df) } + parse_select(result_df, .query) } #' Internal function to `collect()` distributions @@ -156,6 +169,7 @@ collect_distributions_metadata <- function(.query){ result <- query_API(.query) result <- result |> dplyr::bind_rows() |> + # NOTE: This syntax should be integrated with `wanted_columns()` et al before shipping dplyr::select( "spcode", "family", @@ -184,37 +198,34 @@ collect_distributions_metadata <- function(.query){ #' @keywords Internal collect_fields <- function(.query){ if(!is.null(.query$data)){ - retrieve_internal_data(.query) + result_df <- retrieve_internal_data(.query) }else{ result <- query_API(.query) |> dplyr::bind_rows() if(is_gbif()){ - result <- result |> + result_df <- result |> dplyr::mutate(id = .data$simpleName, description = .data$qualifiedName, - type = "fields") |> - dplyr::select("id", "description", "type") - + type = "fields") }else{ - # if there is a 'stored' field, use it to filter results if(any(colnames(result) == "stored")){ - result <- result |> + result_df <- result |> dplyr::filter(.data$stored == TRUE) } # now mutate to required format - result <- result |> - dplyr::mutate(id = result$name) |> - dplyr::select(dplyr::all_of(wanted_columns("fields"))) |> - dplyr::mutate(type = "fields") |> + result_df <- result_df |> + dplyr::mutate(id = result_df$name, + type = "fields") |> + dplyr::rename_with(camel_to_snake_case) |> dplyr::bind_rows(galah_internal_archived$media, galah_internal_archived$other) } - result <- update_attributes(result, type = "fields") - update_cache(fields = result) - result + result_df <- update_attributes(result_df, type = "fields") + update_cache(fields = result_df) } + parse_select(result_df, .query) } #' Internal function to `collect()` licences @@ -222,27 +233,27 @@ collect_fields <- function(.query){ #' @keywords Internal collect_licences <- function(.query){ if(!is.null(.query$data)){ - retrieve_internal_data(.query) + result_df <- retrieve_internal_data(.query) }else{ result <- query_API(.query) if(length(result) > 0){ if (any(duplicated(names(result[[1]])))) { # remove duplicate columns (i.e. Spain atlas) result <- purrr::map(result, \(x) x[unique(names(x))]) } - result <- result |> + result_df <- result |> dplyr::bind_rows() |> - dplyr::select(dplyr::all_of(c("id", "name", "acronym", "url"))) |> + dplyr::rename_with(camel_to_snake_case) |> dplyr::arrange(result$id) }else{ - result <- tibble::tibble(id = character(), - name = character(), - acronym = character(), - url = character()) + result_df <- tibble::tibble(id = character(), + name = character(), + acronym = character(), + url = character()) } - result <- update_attributes(result, type = "licences") - update_cache(licences = result) - result + result_df <- update_attributes(result_df, type = "licences") + update_cache(licences = result_df) } + parse_select(result_df, .query) } #' Internal function to `collect()` lists @@ -250,26 +261,57 @@ collect_licences <- function(.query){ #' @keywords Internal collect_lists <- function(.query){ if(!is.null(.query$data)){ - retrieve_internal_data(.query) + result_df <- retrieve_internal_data(.query) }else{ # here we run and parse an API call if(inherits(.query$url, "data.frame")){ - result <- purrr::map(query_API(.query), + result_df <- purrr::map(query_API(.query), \(a){a$lists}) |> dplyr::bind_rows() }else{ - result <- query_API(.query) |> + result_df <- query_API(.query) |> purrr::pluck("lists") |> dplyr::bind_rows() } - if(any(colnames(result) == "dataResourceUid")){ - result <- result |> + if(any(colnames(result_df) == "dataResourceUid")){ + result_df <- result_df |> dplyr::rename("species_list_uid" = "dataResourceUid") } - result <- update_attributes(result, type = "lists") - update_cache(lists = result) - result + # cleaning + result_df <- result_df |> + dplyr::rename_with(camel_to_snake_case) |> + dplyr::arrange(.data$species_list_uid) |> + update_attributes(type = "lists") + update_cache(lists = result_df) + } + # return + parse_select(result_df, .query) +} + +#' Internal version of `collect()` for `request_data(type = "media")` +#' @param object of class `data_response`, from `compute()` +#' @noRd +#' @keywords Internal +collect_media_metadata <- function(.query){ + result <- query_API(.query) |> + purrr::pluck("results") |> + dplyr::bind_rows() + if(nrow(result) < 1){ # case where no data returned + if(potions::pour("package", "verbose")){ + cli::cli_warn("No data returned from `metadata/media` API") + } + ids <- .query$body |> + jsonlite::fromJSON() |> + unlist() + result <- tibble::tibble(image_id = ids) } + # Select only the information we want + # NOTE: this has no caching on purpose + result |> + dplyr::rename_with(camel_to_snake_case) |> + parse_rename(type = "media") |> + dplyr::filter(!is.na(result$image_id)) |> + parse_select(.query) } #' Internal function to `collect()` profiles @@ -277,18 +319,18 @@ collect_lists <- function(.query){ #' @keywords Internal collect_profiles <- function(.query){ if(!is.null(.query$data)){ - retrieve_internal_data(.query) + result_df <- retrieve_internal_data(.query) }else{ result <- query_API(.query) |> dplyr::bind_rows() - result <- result |> + result_df <- result |> dplyr::filter(!duplicated(result$id)) |> - dplyr::arrange("id") |> - dplyr::select(dplyr::all_of(wanted_columns(type = "profile"))) - result <- update_attributes(result, type = "profiles") - update_cache(profiles = result) - result + dplyr::rename_with(camel_to_snake_case) |> + dplyr::arrange(.data$id) |> + update_attributes(type = "profiles") + update_cache(profiles = result_df) } + parse_select(result_df, .query) } #' Internal function to `collect()` providers @@ -296,7 +338,7 @@ collect_profiles <- function(.query){ #' @keywords Internal collect_providers <- function(.query){ if(!is.null(.query$data)){ - retrieve_internal_data(.query) + result_df <- retrieve_internal_data(.query) }else{ result <- query_API(.query) if(is_gbif()){ @@ -304,27 +346,30 @@ collect_providers <- function(.query){ # Note: This assumes only one API call; will need more potentially result <- purrr::pluck(result, "results") } - result <- result |> + result_df <- result |> flat_lists_only() |> dplyr::bind_rows() }else if(potions::pour("atlas", "region", .pkg = "galah") == "France"){ - result <- tibble::tibble(name = { + result_df <- tibble::tibble(name = { purrr::pluck(result, "_embedded", "providers") |> unlist() }) }else{ - result <- result |> + result_df <- result |> dplyr::bind_rows() - if(nrow(result) > 0){ # exception added because this API isn't always populated (e.g. France) - result <- result |> + if(nrow(result_df) > 0){ # exception added because this API isn't always populated (e.g. France) + result_df <- result_df |> dplyr::relocate("uid") |> - dplyr::rename("id" = "uid") + dplyr::rename("id" = "uid") } } - result <- update_attributes(result, type = "providers") - update_cache(providers = result) - result + result_df <- result_df |> + dplyr::rename_with(camel_to_snake_case) |> + dplyr::arrange(.data$id) |> + update_attributes(type = "providers") + update_cache(providers = result_df) } + parse_select(result_df, .query) } #' Internal function to `collect()` APIs @@ -332,7 +377,8 @@ collect_providers <- function(.query){ #' @keywords Internal collect_ranks <- function(.query){ retrieve_internal_data(.query) |> - update_attributes(type = "ranks") + update_attributes(type = "ranks") |> + parse_select(.query) } #' Internal function to `collect()` reasons @@ -340,16 +386,16 @@ collect_ranks <- function(.query){ #' @keywords Internal collect_reasons <- function(.query){ if(!is.null(.query$data)){ - retrieve_internal_data(.query) + result_df <- retrieve_internal_data(.query) }else{ result <- query_API(.query) |> dplyr::bind_rows() - result <- result |> + result_df <- result |> dplyr::filter(!result$deprecated) |> - dplyr::select(dplyr::all_of(wanted_columns("reasons"))) |> - arrange("id") |> + dplyr::arrange(.data$id) |> + dplyr::relocate("id", "name") |> update_attributes(type = "reasons") - update_cache(reasons = result) - result + update_cache(reasons = result_df) } + parse_select(result_df, .query) } \ No newline at end of file diff --git a/R/collect_unnest.R b/R/collect_metadata_unnest.R similarity index 100% rename from R/collect_unnest.R rename to R/collect_metadata_unnest.R diff --git a/R/collect_taxa.R b/R/collect_taxa.R index 6dc75b2a..409ac5d7 100644 --- a/R/collect_taxa.R +++ b/R/collect_taxa.R @@ -2,7 +2,7 @@ #' @noRd #' @keywords Internal collect_taxa <- function(.query){ - if(grepl("namematching|name-matching", .query$url$url[1])){ + if(stringr::str_detect(.query$url$url[1], "namematching|name-matching")){ collect_taxa_namematching(.query) # Australia, Spain, Sweden }else{ if(is_gbif()){ @@ -28,7 +28,6 @@ collect_taxa_namematching <- function(.query, # Might be worth returning to if this functionality is needed # result <- filter(result, !duplicated(taxonConceptID)) # } - # handle one or more returned issues values issues_vec <- purrr::map(result$issues, \(a){ @@ -40,25 +39,22 @@ collect_taxa_namematching <- function(.query, } }) |> unlist() - # add issues to result result <- result |> dplyr::select(-"issues") |> dplyr::mutate("search_term" = search_terms, .before = "success", issues = issues_vec) - # Check for homonyms - check_homonyms(result, - error_call = error_call) - + check_homonyms(result, error_call = error_call) # Check for invalid search terms if (galah_config()$package$verbose) { check_search_terms(result) } - - names(result) <- rename_columns(names(result), type = "taxa") # old code - result |> dplyr::select(dplyr::any_of(wanted_columns("taxa"))) + result |> + dplyr::rename_with(camel_to_snake_case) |> + parse_rename(type = "taxa") |> + parse_select(.query) } #' Internal function to `collect()` taxa for other living atlases @@ -78,9 +74,10 @@ collect_taxa_la <- function(.query){ dplyr::filter(!duplicated({{name}})) |> dplyr::mutate("search_term" = search_terms) } - names(result) <- rename_columns(names(result), - type = "taxa") # old code - result |> dplyr::select(dplyr::any_of(wanted_columns("taxa"))) + result |> + dplyr::rename_with(camel_to_snake_case) |> + parse_rename(type = "taxa") |> + parse_select(.query) } #' Internal function to `collect()` taxa for GBIF @@ -88,14 +85,14 @@ collect_taxa_la <- function(.query){ #' @keywords Internal collect_taxa_gbif <- function(.query){ search_terms <- .query$url$search_term - result <- query_API(.query) |> + query_API(.query) |> clean_gbif_taxa() |> dplyr::bind_rows() |> dplyr::mutate("search_term" = search_terms, - .before = 1) - names(result) <- rename_columns(names(result), type = "taxa") # old code - result |> - dplyr::select(dplyr::any_of(wanted_columns("taxa"))) + .before = 1) |> + dplyr::rename_with(camel_to_snake_case) |> + parse_rename(type = "taxa") |> + parse_select(.query) } #' Internal function to do cleaning for GBIF @@ -208,13 +205,10 @@ collect_identifiers <- function(.query){ check_search_terms(result) } - names(result) <- rename_columns(names(result), - type = "taxa") # old code - result <- result |> - dplyr::select(dplyr::any_of(wanted_columns("taxa"))) - attr(result, "call") <- "identifiers" - attr(result, "region") <- potions::pour("atlas", "region") - result + result |> + dplyr::rename_with(camel_to_snake_case) |> + parse_rename(type = "taxa") |> + parse_select(.query) } #' Internal function to check search terms provided to `search_taxa()` diff --git a/R/galah_apply_profile.R b/R/galah_apply_profile.R index ede8c116..867b6568 100644 --- a/R/galah_apply_profile.R +++ b/R/galah_apply_profile.R @@ -35,7 +35,8 @@ apply_profile <- function(.data, ...){ result <- parse_quosures_basic(dots) |> purrr::pluck(!!!list(1)) |> parse_profile() - update_data_request(.data, data_profile = result) + update_request_object(.data, + data_profile = result) } #' @rdname apply_profile @@ -47,7 +48,8 @@ galah_apply_profile <- function(...){ "data_request" = { result <- parse_quosures_basic(dots[-1]) |> parse_profile() - update_data_request(dots[[1]], data_profile = result) + update_request_object(dots[[1]], + data_profile = result) }, { parse_quosures_basic(dots) |> diff --git a/R/galah_bbox.R b/R/galah_bbox.R index c549aea8..1b19db72 100644 --- a/R/galah_bbox.R +++ b/R/galah_bbox.R @@ -111,7 +111,8 @@ galah_bbox <- function(...) { if(is.null(dr)){ out_query }else{ - update_data_request(dr, geolocate = out_query) + update_request_object(dr, + geolocate = out_query) } } diff --git a/R/galah_filter.R b/R/galah_filter.R index f387be1a..d07c9075 100644 --- a/R/galah_filter.R +++ b/R/galah_filter.R @@ -114,7 +114,8 @@ filter.data_request <- function(.data, ...){ }else{ filters <- parse_quosures_data(dots) # `handle_quosures.R` } - update_data_request(.data, filter = filters) + update_request_object(.data, + filter = filters) } # usually filters as previously for ALA, but some exceptions: # doi == "x" in `atlas_occurrences()` @@ -188,7 +189,8 @@ galah_filter <- function(..., profile = NULL){ }else{ filters <- parse_quosures_data(dots[-1]) # `handle_quosures.R` } - update_data_request(dots[[1]], filter = filters) + update_request_object(dots[[1]], + filter = filters) }, "metadata_request" = { parse_quosures_metadata(dots[[1]], dots[-1]) diff --git a/R/galah_geolocate.R b/R/galah_geolocate.R index bad9c5f2..05a88a59 100644 --- a/R/galah_geolocate.R +++ b/R/galah_geolocate.R @@ -155,5 +155,6 @@ galah_geolocate <- geolocate #' @param y A valid Well-Known Text string (wkt), a `POLYGON` or a `MULTIPOLYGON` #' @export st_crop.data_request <- function(x, y, ...){ - update_data_request(x, geolocate = parse_polygon(y)) + update_request_object(x, + geolocate = parse_polygon(y)) } \ No newline at end of file diff --git a/R/galah_group_by.R b/R/galah_group_by.R index 086f4e3e..16719a5d 100644 --- a/R/galah_group_by.R +++ b/R/galah_group_by.R @@ -35,7 +35,8 @@ group_by.data_request <- function(.data, ...){ parsed_dots <- rlang::enquos(..., .ignore_empty = "all") |> parse_quosures_basic() df <- parse_group_by(parsed_dots) -update_data_request(.data, group_by = df) +update_request_object(.data, + group_by = df) } #' @rdname group_by.data_request @@ -47,7 +48,8 @@ galah_group_by <- function(...){ "data_request" = { df <- parse_quosures_basic(dots[-1]) |> parse_group_by() - update_data_request(dots[[1]], group_by = df) + update_request_object(dots[[1]], + group_by = df) }, { parse_quosures_basic(dots) |> diff --git a/R/galah_identify.R b/R/galah_identify.R index db5e8dc2..6578c6b6 100644 --- a/R/galah_identify.R +++ b/R/galah_identify.R @@ -47,7 +47,8 @@ identify.data_request <- function(x, ...){ result <- tibble::tibble("search_term" = unlist(dots_initial)) } } - update_data_request(x, identify = result) + update_request_object(x, + identify = result) } #' @rdname identify.data_request diff --git a/R/galah_polygon.R b/R/galah_polygon.R index 2ba7e635..3e9ebc3f 100644 --- a/R/galah_polygon.R +++ b/R/galah_polygon.R @@ -16,7 +16,8 @@ galah_polygon <- function(...){ out_query <- parse_polygon(query) # if a data request was supplied, return one if(!is.null(dr)){ - update_data_request(dr, geolocate = out_query) + update_request_object(dr, + geolocate = out_query) }else{ out_query } diff --git a/R/galah_radius.R b/R/galah_radius.R index d9baf56c..f105f125 100644 --- a/R/galah_radius.R +++ b/R/galah_radius.R @@ -16,7 +16,8 @@ galah_radius <- function(...){ out_query <- parse_point_radius(query) # if a data request was supplied, return one if(!is.null(dr)){ - update_data_request(dr, geolocate = out_query) + update_request_object(dr, + geolocate = out_query) }else{ out_query } diff --git a/R/galah_select.R b/R/galah_select.R index 2989abf5..063ef1c1 100644 --- a/R/galah_select.R +++ b/R/galah_select.R @@ -139,11 +139,11 @@ select.data_request <- function(.data, ..., group){ cli::cli_inform("`select()` is not supported for GBIF: skipping") .data }else{ - dots <- rlang::enquos(..., .ignore_empty = "all") |> + rlang::enquos(..., .ignore_empty = "all") |> as.list() |> add_summary() |> - add_group(group) - update_data_request(.data, select = dots) + add_group(group) |> + update_request_object(.data, select = _) } } @@ -155,14 +155,10 @@ select.metadata_request <- function(.data, ...){ # } ## TODO: decide whether warnings are needed. ## Probably inform("Skipping") would be fine - select_entries <- rlang::enquos(..., .ignore_empty = "all") |> + rlang::enquos(..., .ignore_empty = "all") |> as.list() |> - purrr::map(rlang::as_label) |> - unlist() - names(select_entries) <- NULL - .data$select <- list(value = select_entries, - summary = select_entries) - .data + add_summary() |> + update_request_object(.data, select = _) } #' @rdname select.data_request @@ -183,7 +179,8 @@ galah_select <- function(..., group){ add_summary() |> add_group(group) if(inherits(dots[[1]], "data_request")){ - update_data_request(dots[[1]], select = dots[-1]) + update_request_object(dots[[1]], + select = dots[-1]) }else{ dots } diff --git a/R/handle_request_objects.R b/R/handle_request_objects.R index 1a12e181..99eb4707 100644 --- a/R/handle_request_objects.R +++ b/R/handle_request_objects.R @@ -31,7 +31,8 @@ detect_request_object <- function(dots){ #' Internal function to update a `data_request` #' @noRd #' @keywords Internal -update_data_request <- function(data_request, ...){ +update_request_object <- function(x, ...){ + class_tr <- class(x) dots <- list(...) if(length(dots)[[1]] == 1){ if(inherits(dots[[1]], "list") & is.null(names(dots))){ @@ -39,24 +40,24 @@ update_data_request <- function(data_request, ...){ } } result <- purrr::map( - names(data_request), # i.e. for all slots in object of class `data_request` + names(x), # i.e. for all slots in object of class `data_request` or `metadata_request` function(a){ - if(any(names(dots) == a)){ # object is present in `data_request` - if(is.null(data_request[[a]])){ # slot in `data_request` is empty + if(any(names(dots) == a)){ # object is present in `x` + if(is.null(x[[a]])){ # slot in `x` is empty dots[[a]] }else{ # slot is filled if(is.null(dots[[a]])){ # if nothing has been supplied, retain source - data_request[[a]] + x[[a]] }else{ # both supplied and source contain data switch(a, "identify" = { - bind_unique_rows(data_request[[a]], dots[[a]], "search_term") + bind_unique_rows(x[[a]], dots[[a]], "search_term") }, "filter" = { - bind_unique_rows(data_request[[a]], dots[[a]], "query") + bind_unique_rows(x[[a]], dots[[a]], "query") }, "select" = { - update_select(data_request[[a]], dots[[a]]) + update_select(x[[a]], dots[[a]]) }, # for below, we assume that in all other circumstances we # simply pass the most recent result (i.e. overwrite) @@ -65,17 +66,17 @@ update_data_request <- function(data_request, ...){ } } }else{ # if supplied object is not named in `data_request` - data_request[[a]] + x[[a]] } }) - names(result) <- names(data_request) + names(result) <- names(x) # check if any names in `dots` have been missed from `results` missing_names <- !(names(dots) %in% names(result)) if(any(missing_names)){ result <- append(result, dots[missing_names]) } - class(result) <- "data_request" + class(result) <- class_tr result } diff --git a/R/reexports.R b/R/reexports.R index 49c31e56..69868a4c 100644 --- a/R/reexports.R +++ b/R/reexports.R @@ -42,10 +42,6 @@ dplyr::slice_head #' @export dplyr::count -#' @importFrom tidyselect everything -#' @export -tidyselect::everything - #' @importFrom sf st_crop #' @export sf::st_crop \ No newline at end of file diff --git a/R/search_all.R b/R/search_all.R index fb7538b4..d208f49a 100644 --- a/R/search_all.R +++ b/R/search_all.R @@ -10,7 +10,8 @@ #' search term within the valid options for the information specified by the #' suffix. #' -#' **For more information about taxonomic searches using `search_taxa()`, see `?taxonomic_searches`**. +#' **For more information about taxonomic searches using `search_taxa()`, see** +#' \code{\link[=taxonomic_searches]{?taxonomic_searches}}. #' #' `r lifecycle::badge("stable")` #' `search_all()` is a helper function that can do searches for multiple diff --git a/R/sysdata.rda b/R/sysdata.rda index 41de923d6c380d7300a4f6c15effad514231315d..fe6d4cc949baa2a5782da71af378123a7b08bd29 100644 GIT binary patch literal 6455 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 RUa9}Z+>uTcBtJj*eSk*uVub(z literal 15318 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 diff --git a/R/taxonomic-searches.R b/R/taxonomic-searches.R index ca40f019..6981a33b 100644 --- a/R/taxonomic-searches.R +++ b/R/taxonomic-searches.R @@ -3,7 +3,7 @@ #' @title Look up taxon information #' #' @description -#' `search_taxa()` allows users to look up taxonomic names, and ensure they are +#' [search_taxa()] allows users to look up taxonomic names, and ensure they are #' being matched correctly, before downloading data from the specified #' organisation. #' @@ -16,7 +16,7 @@ #' A more common use-case is to distinguish between homonyms by listing higher #' taxonomic units, by supplying columns like `kingdom`, `phylum` or `class`. #' -#' `search_identifiers()` allows users to look up matching taxonomic names using +#' [search_identifiers()] allows users to look up matching taxonomic names using #' their unique `taxonConceptID`. In the ALA, all records are associated with #' an identifier that uniquely identifies the taxon to which that record belongs. #' Once those identifiers are known, this function allows you to use them to @@ -25,7 +25,7 @@ #' identifiers. #' #' Note that when taxonomic look-up is required within a pipe, the equivalent -#' to `search_taxa()` is \code{\link[=identify.data_request]{identify()}} (or +#' to [search_taxa()] is \code{\link[=identify.data_request]{identify()}} (or #' [galah_identify()]). The equivalent to `search_identifiers()` is to use #' \code{\link[=filter.data_request]{filter()}} to filter by `taxonConceptId`. #' @@ -78,7 +78,26 @@ #' family = c("pardalotidae", "maluridae"), #' scientificName = c("Pardalotus striatus striatus", "malurus cyaneus"))) #' +#' # Use OOP for the same effect +#' # `identify()` tells the code that we want to search for _taxonomic_ metadata. +#' request_metadata() |> +#' identify("crinia") |> +#' collect() +#' +#' # This approach has the advantage that we can call `select()` +#' request_metadata() |> +#' identify("crinia") |> +#' select(everything()) |> +#' collect() +#' #' # Look up a unique taxon identifier #' search_identifiers(query = "https://id.biodiversity.org.au/node/apni/2914510") +#' +#' # OOP process for identifiers uses `filter()`, not `identify()` +#' # In these cases the `field` argument is used to specify `type` +#' request_metadata() |> +#' filter(identifier = "https://id.biodiversity.org.au/node/apni/2914510") |> +#' select(everything()) |> +#' collect() #' } NULL \ No newline at end of file diff --git a/R/url_lookup.R b/R/url_lookup.R index dfa9406c..1ef0c127 100644 --- a/R/url_lookup.R +++ b/R/url_lookup.R @@ -32,7 +32,8 @@ url_lookup <- function(type, as.character() |> utils::URLencode() }else{ - url_string |> utils::URLencode() + url_string |> + utils::URLencode() } }else{ if(quiet){ @@ -40,7 +41,7 @@ url_lookup <- function(type, }else{ c( glue::glue("No API is available for type `{type}`"), - i = glue("Selected atlas: {current_atlas}"), + i = glue::glue("Selected atlas: {current_atlas}"), i = "Use `show_all_apis()` to list valid API calls") |> cli::cli_abort(call = error_call) } diff --git a/R/utilities_caching.R b/R/utilities_caching.R index ff61d518..3bc97043 100644 --- a/R/utilities_caching.R +++ b/R/utilities_caching.R @@ -53,13 +53,18 @@ retrieve_cache <- function(slot_name){ #' @noRd #' @keywords Internal check_if_cache_update_needed <- function(function_name){ + # get data for checking df <- retrieve_cache(function_name) + current_atlas <- potions::pour("atlas", "region", .pkg = "galah") + # build some checks is_local <- !is.null(attr(df, "ARCHIVED")) - is_wrong_atlas <- attr(df, "region") != potions::pour("atlas", "region") - is_too_short <- nrow(df) < 10 + is_wrong_atlas <- attr(df, "region") != current_atlas + is_too_short <- nrow(df) <= 1 # somewhat arbitrary, but catches empty tibbles + # evaluate those checks result <- is_local | is_wrong_atlas | is_too_short # if any, update is needed if(length(result) < 1){ # bug catcher - result <- TRUE + TRUE + }else{ + result } - result } \ No newline at end of file diff --git a/R/utilities_internal.R b/R/utilities_internal.R index 2db539ef..bb991f6f 100644 --- a/R/utilities_internal.R +++ b/R/utilities_internal.R @@ -2,120 +2,130 @@ ## Output formatting functions -- ##--------------------------------------------------------------- -#' Internal function to check whether a query contains `select(everything())` -#' @noRd -#' @keywords Internal -everything_requested <- function(x){ - result <- FALSE - value <- purrr::pluck(x, "select", "value") - if(!is.null(value)){ - if(any(value == "everything()")){ - result <- TRUE - } - } - result -} - -#' Internal function to apply `select(everything())` when request4ed -#' @noRd -#' @keywords Internal -select_wanted_columns <- function(df, .query){ - if(isFALSE(.query$all_fields)){ - specific_type <- .query |> - purrr::pluck("type") |> - stringr::str_remove("^metadata/") - dplyr::select(df, - tidyselect::any_of(wanted_columns(specific_type))) - }else{ - df - } -} - -# Select column names to return -# Subsets data returned by webservices to useful columns +#' Choose column names to pass to `select()`. +#' NOTE: this isn't especially subtle wrt different atlases +#' NOTE: this assumes `dplyr::rename_with(camel_to_snake_case)` has been run #' @noRd #' @keywords Internal wanted_columns <- function(type) { switch(type, - "taxa" = c("search_term", "scientific_name", - "scientific_name_authorship", - "taxon_concept_id", # ALA - "taxon_concept_lsid", # Austria, Guatemala - "authority", # OpenObs - "usage_key", # GBIF - "guid", # species search - "canonical_name", "status", - "rank", - "match_type", "confidence", "time_taken", - "kingdom", "phylum", "class", "order", - "family", "genus", "species", "vernacular_name", - "issues","subkingdom", "superclass", "infraclass", - "subclass", "subinfraclass", "suborder", "superorder", - "infraorder", "infrafamily", "superfamily", "subfamily", - "subtribe", "subgenus", "subspecies"), - "extended_taxa" = c("subkingdom", "superclass", "infraclass", - "subclass", "subinfraclass", "suborder", - "superorder", "infraorder", "infrafamily", - "superfamily", "subfamily", "subtribe", - "subgenus"), - "profile" = c("id", "shortName", "name", "description"), + "assertions" = c("id", + "description", + "category", + "type"), + "fields" = c("id", + "description", + "type"), + "identifiers" = wanted_columns_taxa(), + "licences" = c("id", + "name", + "acronym", + "url"), + "lists" = c("species_list_uid", + "list_name", + "description", + "list_type", + "item_count"), "media" = c("image_id", - "creator", "license", + "creator", + "license", "data_resource_uid", - "date_taken", "date_uploaded", - "mime_type", "mimetype", - "width", "height", "size_in_bytes", - "image_url" - ), - "layer" = c("id", "description", "source_link"), - "fields" = c("id", "description"), - "assertions" = c("id", "description", "category"), - "quality_filter" = c("description", "filter"), - "reasons" = c("id", "name")) + "date_taken", + "date_uploaded", + "mime_type", + "mimetype", + "width", + "height", + "size_in_bytes", + "image_url"), + "profiles" = c("id", + "short_name", + "name", + "description"), + "reasons" = c("id", + "name"), + "taxa" = wanted_columns_taxa(), + NULL # When no defaults are set, sending NULL tells the code to call `everything()` + ) } -#' Internal function to rename specific columns, and convert to snake_case +#' `wanted_columns()` but for taxa *and* identifier queries #' @noRd #' @keywords Internal -colname_lookup <- function(type){ - switch(type, - "assertions" = c("id" = "name") - ) +wanted_columns_taxa <- function(){ + c("search_term", + "scientific_name", + "scientific_name_authorship", + "taxon_concept_id", # ALA + "taxon_concept_lsid", # Austria, Guatemala + "authority", # OpenObs + "usage_key", # GBIF + "guid", # species search + "canonical_name", "status", + "rank", + "match_type", + "confidence", + "time_taken", + "vernacular_name", + "issues", + {show_all_ranks() |> dplyr::pull("name")}) } -#' Internal function to rename specific columns, and convert to snake_case +#' Internal function to run `eval_tidy()` on captured `select()` requests +#' @noRd +#' @keywords Internal +parse_select <- function(df, .query){ + select_list <- .query |> + purrr::pluck("select") |> + purrr::map(rlang::is_quosure) |> + unlist() |> + which() + select_query <- .query |> + purrr::pluck("select", !!!select_list) + pos <- tidyselect::eval_select(expr = select_query, + data = df) + rlang::set_names(df[pos], names(pos)) # note: this line taken from + # `tidyselect` documentation; it could be argued that `df[pos]` is sufficient +} + +#' Internal function to rename specific columns +#' In-progress, tidyverse-compliant replacement for `rename_columns()` +#' Note that actual renaming is now handled in-pipe by `dplyr::rename()` #' @noRd #' @keywords Internal -rename_columns <- function(varnames, type) { - varnames <- camel_to_snake_case(varnames) - switch(type, - "media" = { - varnames[varnames %in% c("image_identifier")] <- "image_id" - varnames[varnames == "mime_type"] <- "mimetype" - }, - "taxa" = { - varnames[varnames == "classs"] <- "class" - varnames[varnames %in% c("usage_key", "usageKey", "guid", "reference_id", "referenceId", "key")] <- "taxon_concept_id" - varnames[varnames %in% c("genus_name", "genusName")] <- "genus" - varnames[varnames %in% c("family_name", "familyName")] <- "family" - varnames[varnames %in% c("order_name", "orderName")] <- "order" - varnames[varnames %in% c("class_name", "className")] <- "class" - varnames[varnames %in% c("phylum_name", "phylumName")] <- "phylum" - varnames[varnames %in% c("kingdom_name", "kingdomName")] <- "kingdom" - varnames[varnames %in% c("rank_name", "rankName")] <- "rank" - varnames[varnames %in% c("french_vernacular_name", "frenchVernacularName")] <- "vernacular_name" - }, - "assertions" = { - varnames[varnames == "name"] <- "id" - }, - "checklist" = { - varnames[1] <- "taxon_concept_id" - varnames[varnames %in% c("counts", "number_of_records")] <- "count" +parse_rename <- function(df, type){ + if(type == "taxa"){ + taxa_vec <- c("class" = "classs", + "taxon_concept_id" = "usage_key", + "taxon_concept_id" = "guid", + "taxon_concept_id" = "reference_id", + "taxon_concept_id" = "key", + "genus" = "genus_name", + "family" = "family_name", + "order" = "order_name", + "phylum" = "phylum_name", + "kingdom" = "kingdom_name", + "rank" = "rank_name", + "vernacular_name" = "french_vernacular_name") + cols <- colnames(df) + col_lookup <- taxa_vec %in% cols + rename_cols <- as.list(taxa_vec[col_lookup]) + if(any(col_lookup)){ + dplyr::rename(df, !!!rename_cols) + }else{ + df } - ) - varnames + }else if(type == "assertions"){ + dplyr::rename(df, !!!c("id" = "name")) + }else if(type == "media"){ + dplyr::rename(df, !!!c("image_id" = "image_identifier", + "mimetype" = "mime_type")) + }else{ + df + } } + ##--------------------------------------------------------------- ## Cases -- ##--------------------------------------------------------------- diff --git a/data-raw/2_internal_data.R b/data-raw/2_internal_data.R index 1c1e62b2..344356d5 100644 --- a/data-raw/2_internal_data.R +++ b/data-raw/2_internal_data.R @@ -103,7 +103,8 @@ stored_types <- c("assertions", "fields", "profiles", "reasons") galah_internal_cached <- purrr::map( stored_types, function(a){ - result <- request_metadata(type = a) |> collect() + result <- tibble::tibble() # i.e. ship blank tibbles to save space + # request_metadata(type = a) |> collect() # old code to actually populate the cache attr(result, "ARCHIVED") <- TRUE attr(result, "region") <- "Australia" result diff --git a/man/reexports.Rd b/man/reexports.Rd index d361339c..778aeede 100644 --- a/man/reexports.Rd +++ b/man/reexports.Rd @@ -13,7 +13,6 @@ \alias{group_by} \alias{slice_head} \alias{count} -\alias{everything} \alias{st_crop} \title{Objects exported from other packages} \keyword{internal} @@ -27,7 +26,5 @@ below to see their documentation. \item{graphics}{\code{\link[graphics]{identify}}} \item{sf}{\code{\link[sf]{st_crop}}} - - \item{tidyselect}{\code{\link[tidyselect]{everything}}} }} diff --git a/man/search_all.Rd b/man/search_all.Rd index f468a2a3..ee34c402 100644 --- a/man/search_all.Rd +++ b/man/search_all.Rd @@ -70,7 +70,8 @@ Functions prefixed with \code{search_} do this, displaying any matches to a search term within the valid options for the information specified by the suffix. -\strong{For more information about taxonomic searches using \code{search_taxa()}, see \code{?taxonomic_searches}}. +\strong{For more information about taxonomic searches using \code{search_taxa()}, see} +\code{\link[=taxonomic_searches]{?taxonomic_searches}}. \ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#stable}{\figure{lifecycle-stable.svg}{options: alt='[Stable]'}}}{\strong{[Stable]}} \code{search_all()} is a helper function that can do searches for multiple diff --git a/man/taxonomic_searches.Rd b/man/taxonomic_searches.Rd index 000975d5..db25cb59 100644 --- a/man/taxonomic_searches.Rd +++ b/man/taxonomic_searches.Rd @@ -4,7 +4,7 @@ \alias{taxonomic_searches} \title{Look up taxon information} \description{ -\code{search_taxa()} allows users to look up taxonomic names, and ensure they are +\code{\link[=search_taxa]{search_taxa()}} allows users to look up taxonomic names, and ensure they are being matched correctly, before downloading data from the specified organisation. @@ -17,7 +17,7 @@ to list these separately under \code{genus} and \code{specificEpithet} (respecti A more common use-case is to distinguish between homonyms by listing higher taxonomic units, by supplying columns like \code{kingdom}, \code{phylum} or \code{class}. -\code{search_identifiers()} allows users to look up matching taxonomic names using +\code{\link[=search_identifiers]{search_identifiers()}} allows users to look up matching taxonomic names using their unique \code{taxonConceptID}. In the ALA, all records are associated with an identifier that uniquely identifies the taxon to which that record belongs. Once those identifiers are known, this function allows you to use them to @@ -26,7 +26,7 @@ inverse function to \code{\link[=search_taxa]{search_taxa()}}, which takes names identifiers. Note that when taxonomic look-up is required within a pipe, the equivalent -to \code{search_taxa()} is \code{\link[=identify.data_request]{identify()}} (or +to \code{\link[=search_taxa]{search_taxa()}} is \code{\link[=identify.data_request]{identify()}} (or \code{\link[=galah_identify]{galah_identify()}}). The equivalent to \code{search_identifiers()} is to use \code{\link[=filter.data_request]{filter()}} to filter by \code{taxonConceptId}. } @@ -76,8 +76,27 @@ search_taxa(tibble::tibble( family = c("pardalotidae", "maluridae"), scientificName = c("Pardalotus striatus striatus", "malurus cyaneus"))) +# Use OOP for the same effect +# `identify()` tells the code that we want to search for _taxonomic_ metadata. +request_metadata() |> + identify("crinia") |> + collect() + +# This approach has the advantage that we can call `select()` +request_metadata() |> + identify("crinia") |> + select(everything()) |> + collect() + # Look up a unique taxon identifier search_identifiers(query = "https://id.biodiversity.org.au/node/apni/2914510") + +# OOP process for identifiers uses `filter()`, not `identify()` +# In these cases the `field` argument is used to specify `type` +request_metadata() |> + filter(identifier = "https://id.biodiversity.org.au/node/apni/2914510") |> + select(everything()) |> + collect() } } \seealso{ diff --git a/tests/testthat/test-caching.R b/tests/testthat/test-caching.R index 2a70288e..ed58433b 100644 --- a/tests/testthat/test-caching.R +++ b/tests/testthat/test-caching.R @@ -10,7 +10,7 @@ test_that("`retrieve_cache()` works without any arugments", { test_that("`retrieve_cache()` works with a valid arugment", { x <- retrieve_cache("fields") expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) - expect_gt(nrow(x), 10) + expect_equal(nrow(x), 0) }) test_that("`retrieve_cache()` returns `NULL` with an invalid arugment", { diff --git a/tests/testthat/test-request_metadata_select.R b/tests/testthat/test-request_metadata_select.R new file mode 100644 index 00000000..2609e6fe --- /dev/null +++ b/tests/testthat/test-request_metadata_select.R @@ -0,0 +1,286 @@ +# script to test whether `everything()` is respected (present or absent) +# in metadata requests. Caching is an important consideration here. + +purrr_collect <- purrr::quietly(collect) +quiet_collect <- function(...){ + purrr_collect(...) |> + purrr::pluck("result") +} + +test_that("`request_metadata()` works with `select()` for local APIs", { + type_list <- c("atlases", + "apis", + "ranks") + x <- purrr::map(type_list, \(a){ + + # build a query + query <- request_metadata(type = a) |> + collapse() + + # check this requests data + query |> + purrr::pluck("data") |> + is.null() |> + expect_false() + # check `select` exists, and contains a quosure and a summary + purrr::pluck(query, "select") |> + is.null() |> + expect_false() + purrr::pluck(query, !!!list("select", 1)) |> + rlang::is_quosure() |> + expect_true() + purrr::pluck(query, "select", "summary") |> + is.null() |> + expect_false() + + # collect result + result <- collect(query) + result |> + inherits(c("tbl_df", "tbl", "data.frame")) |> + expect_true() + result |> + nrow() |> + expect_gt(0) + + # `select()` first two columns only + first_2_cols <- colnames(result)[1:2] + result2 <- request_metadata(type = a) |> + select(tidyselect::any_of(first_2_cols)) |> + collect() + + # check has worked + expect_equal(colnames(result2), + first_2_cols) + }) +}) + +test_that("`request_metadata()` works with `select()` for remote APIs *without* default columns", { + skip_if_offline() + type_list <- c("collections", + "datasets", + "providers") + reset_cache() + + x <- purrr::map(type_list, \(a){ + + # build a query + query <- request_metadata(type = a) |> + collapse() + + # check this requests data + query |> + purrr::pluck("url") |> + is.null() |> + expect_false() + # check `select` exists, and contains a quosure and a summary + purrr::pluck(query, "select") |> + is.null() |> + expect_false() + purrr::pluck(query, !!!list("select", 1)) |> + rlang::is_quosure() |> + expect_true() + purrr::pluck(query, "select", "summary") |> + is.null() |> + expect_false() + + # collect result + result <- collect(query) + result |> + inherits(c("tbl_df", "tbl", "data.frame")) |> + expect_true() + result |> + nrow() |> + expect_gt(0) + + # `select()` first two columns only + first_2_cols <- colnames(result)[1:2] + result2 <- request_metadata(type = a) |> + select(tidyselect::any_of(first_2_cols)) |> + collect() + + # check has worked + expect_equal(colnames(result2), + first_2_cols) + }) +}) + +test_that("`request_metadata()` works with `select()` for remote APIs *with* default columns", { + skip_if_offline() + type_list <- c("assertions", + "fields", + "licences", + "lists", + "profiles", + "reasons") + reset_cache() + + # run in a loop to check for all types + # note `x` is captured to silence output, not because we need the results + x <- purrr::map(type_list, \(a){ + + # setup + expected_columns <- wanted_columns(a) + expected_n <- length(expected_columns) + + # set up a query _without_ `everything()` + query <- request_metadata(type = a) |> + collapse() + # check this requests a url + query |> + purrr::pluck("url") |> + is.null() |> + expect_false() + # check `select` exists, and contains a quosure and a summary + purrr::pluck(query, "select") |> + is.null() |> + expect_false() + purrr::pluck(query, !!!list("select", 1)) |> + rlang::is_quosure() |> + expect_true() + purrr::pluck(query, "select", "summary") |> + is.null() |> + expect_false() + + # collect that query, and check for expected columns + result <- quiet_collect(query) + result |> + ncol() |> + expect_equal(expected_n) + result |> + colnames() |> + expect_equal(expected_columns) + + # ensure results from the API are cached + result_cached <- retrieve_cache(a) + # because `everything()` was not called, cached tibble will have more cols + # than returned tibble + expect_gt(ncol(result_cached), ncol(result)) + + # now construct a query _with_ `everything()` + query_everything <- request_metadata(type = a) |> + select(everything()) |> + collapse() + # because the full query is always cached, this should not include an API call + query_everything |> + purrr::pluck("url") |> + is.null() |> + expect_true() + result_everything <- quiet_collect(query_everything) + result_everything |> + ncol() |> + expect_gt(expected_n) + result_everything |> + colnames() |> + expect_contains(expected_columns) + result_cached <- retrieve_cache(a) + expect_equal(colnames(result_everything), + colnames(result_cached)) + expect_equal(nrow(result_everything), + nrow(result_cached)) + + # clean up + reset_cache() + return(a) # probably pointless, but neater than returning nothing + }) +}) + +test_that("`request_metdata()` works with `select()` for `type = 'taxa'`", { + query <- request_metadata() |> + identify("Crinia") |> + select(everything()) |> + collapse() + # check this requests a url + query |> + purrr::pluck("url") |> + is.null() |> + expect_false() + # check `select` exists, and contains a quosure and a summary + purrr::pluck(query, "select") |> + is.null() |> + expect_false() + purrr::pluck(query, !!!list("select", 1)) |> + rlang::is_quosure() |> + expect_true() + purrr::pluck(query, "select", "summary") |> + is.null() |> + expect_false() + # now run the query + basic_search <- search_taxa("Crinia") + everything_search <- quiet_collect(query) + expect_gt(ncol(everything_search), + ncol(basic_search)) + expect_equal(nrow(everything_search), 1) + # look for some 'unusual' columns in the result + expect_contains(colnames(everything_search), + c("lft", "rgt", "species_group", "genus_id")) +}) + +test_that("`request_metdata()` works with `select()` for complex taxa", { + crinia_tibble <- tibble::tibble(kingdom = "Animalia", + phylum = "Chordata", + genus = "Crinia") + query <- request_metadata() |> + identify(crinia_tibble) |> + select(everything()) |> + collapse() + # check this requests a url + query |> + purrr::pluck("url") |> + is.null() |> + expect_false() + # check `select` exists, and contains a quosure and a summary + purrr::pluck(query, "select") |> + is.null() |> + expect_false() + purrr::pluck(query, !!!list("select", 1)) |> + rlang::is_quosure() |> + expect_true() + purrr::pluck(query, "select", "summary") |> + is.null() |> + expect_false() + # now run the query + basic_search <- search_taxa(crinia_tibble) + everything_search <- quiet_collect(query) + expect_gt(ncol(everything_search), + ncol(basic_search)) + expect_equal(nrow(everything_search), 1) + # look for some 'unusual' columns in the result + expect_contains(colnames(everything_search), + c("lft", "rgt", "species_group", "genus_id")) +}) + +test_that("`request_metdata()` works with `select()` for `type = 'identifiers'`", { + tcid <- search_taxa("Chordata") |> + dplyr::pull("taxon_concept_id") + query <- request_metadata() |> + filter(identifier == tcid) |> + select(everything()) |> + collapse() + # check this requests a url + query |> + purrr::pluck("url") |> + dplyr::pull("url") |> + stringr::str_detect("namematching\\/api\\/getByTaxonID") |> + expect_true() + # check `select` exists, and contains a quosure and a summary + purrr::pluck(query, "select") |> + is.null() |> + expect_false() + purrr::pluck(query, !!!list("select", 1)) |> + rlang::is_quosure() |> + expect_true() + purrr::pluck(query, "select", "summary") |> + is.null() |> + expect_false() + # now run the query + basic_search <- search_identifiers(tcid) + everything_search <- quiet_collect(query) + expect_gt(ncol(everything_search), + ncol(basic_search)) + expect_equal(nrow(everything_search), 1) + # look for some 'unusual' columns in the result + expect_contains(colnames(everything_search), + c("success", "lft", "rgt", "kingdom_id")) +}) + +rm(purrr_collect, quiet_collect) \ No newline at end of file diff --git a/tests/testthat/test-request_metadata_unnest.R b/tests/testthat/test-request_metadata_unnest.R index 798feb7c..e1a08f14 100644 --- a/tests/testthat/test-request_metadata_unnest.R +++ b/tests/testthat/test-request_metadata_unnest.R @@ -26,6 +26,20 @@ test_that("request_metadata() |> unnest() works for type = 'fields'", { expect_true(any(x[[1]] == "HUMAN_OBSERVATION")) }) +test_that("request_metadata() |> select() |> unnest() works for type = 'fields'", { + skip_if_offline(); skip_on_ci() + x <- request_metadata() |> + filter(field == basisOfRecord) |> + unnest() |> + select(everything()) |> + collect() + expect_s3_class(x, c("tbl_df", "tbl", "data.frame")) + expect_gte(nrow(x), 4) + expect_equal(ncol(x), 1) + expect_equal(colnames(x), "basisOfRecord") + expect_true(any(x[[1]] == "HUMAN_OBSERVATION")) +}) + test_that("request_metadata() |> unnest() works for type = 'lists'", { skip_if_offline(); skip_on_ci() x <- request_metadata() |> diff --git a/tests/testthat/test-url_lookup.R b/tests/testthat/test-url_lookup.R new file mode 100644 index 00000000..36e8c277 --- /dev/null +++ b/tests/testthat/test-url_lookup.R @@ -0,0 +1,30 @@ +test_that("`url_lookup()` errors for nonsense strings", { + url_lookup("something") |> + expect_error(label = "No API is available") +}) + +test_that("`url_lookup()` returns a URL for a valid input", { + url_lookup("metadata/assertions") |> + stringr::str_detect("^https://api.ala.org.au") |> + expect_true() +}) + +test_that("`url_lookup()` parses named inputs correctly", { + url_lookup("metadata/taxa-single", + name = "Crinia") |> + stringr::str_detect("search\\?q=Crinia$") |> + expect_true() +}) + +test_that("`url_lookup()` errors for incorrect named inputs", { + url_lookup("metadata/taxa-single", + something = "Crinia") |> + expect_error() +}) + +test_that("`url_lookup()` parses multiple named inputs correctly", { + url_lookup("metadata/taxa-single", + name = c("Crinia", "Limnodynastes")) |> + length() |> + expect_equal(2) +}) From 62a3bf671a41930c7109591dfb79c634e048797c Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Thu, 16 Oct 2025 12:21:23 +1100 Subject: [PATCH 32/94] Ensure `select()` works for all metadata functions (#266) Mainly extends previous commit to `unnest` functions (aka `show_values()`). tests updated. --- R/as_query-metadata-unnest.R | 57 ++--- R/as_query-metadata.R | 71 +++---- R/check.R | 8 +- R/coalesce.R | 6 +- R/collapse_checks.R | 41 ++-- R/collapse_lists.R | 59 ------ R/collapse_metadata.R | 136 ++++++++++++ R/collapse_profile_values.R | 71 ------- R/collapse_query.R | 6 +- R/collect_metadata.R | 75 +++++-- R/collect_metadata_unnest.R | 46 ++-- R/collect_taxa.R | 8 +- R/galah_select.R | 51 ++--- R/print.R | 9 +- R/show_values.R | 2 +- R/taxonomic-searches.R | 14 +- R/utilities_internal.R | 199 +++++++++++++----- man/coalesce.Rd | 4 +- man/select.data_request.Rd | 15 +- man/taxonomic_searches.Rd | 15 +- tests/testthat/test-request_metadata_select.R | 14 +- tests/testthat/test-request_metadata_unnest.R | 119 +++++++++-- tests/testthat/test-search_all.R | 8 +- tests/testthat/test-show_all.R | 5 +- tests/testthat/test-show_values.R | 81 +------ 25 files changed, 634 insertions(+), 486 deletions(-) delete mode 100644 R/collapse_lists.R create mode 100644 R/collapse_metadata.R delete mode 100644 R/collapse_profile_values.R diff --git a/R/as_query-metadata-unnest.R b/R/as_query-metadata-unnest.R index 885e1b3e..b5319040 100644 --- a/R/as_query-metadata-unnest.R +++ b/R/as_query-metadata-unnest.R @@ -6,14 +6,12 @@ as_query_fields_unnest <- function(.query){ url <- url_lookup("metadata/fields-unnest") |> httr2::url_parse() if(is_gbif()){ - url$query <- list( - limit = 0, - facet = .query$filter$value[1], # note: facet (singular), not facets (plural) - facetLimit = 10^4) + url$query <- list(limit = 0, + facet = .query$filter$value[1], # note: facet (singular), not facets (plural) + facetLimit = 10^4) }else{ - url$query <- list( - facets = .query$filter$value[1], - flimit = 10^4) + url$query <- list(facets = .query$filter$value[1], + flimit = 10^4) } list(type = "metadata/fields-unnest", url = httr2::url_build(url)) |> @@ -30,26 +28,14 @@ as_query_lists_unnest <- function(.query){ url <- url_lookup("metadata/lists-unnest", list_id = .query$filter$value[1]) |> httr2::url_parse() - # set a default query - query <- list(max = -1) # remove max limit - # Request additional raw fields if `select(everything())` - if(!is.null(.query$select)){ - if(any(.query$select == "everything()")){ - query <- list( - max = -1, # remove max limit - includeKVP = TRUE # add name & status columns - ) - } - } - url$query <- query - + url$query <- list(max = -1, # remove max limit + includeKVP = TRUE) # add name & status columns # create object - result <- list( - type = "metadata/lists-unnest", - url = httr2::url_build(url)) - class(result) <- "query" - return(result) + list(type = "metadata/lists-unnest", + url = httr2::url_build(url)) |> + enforce_select_query(.query) |> + as_query() } #' Internal function to run `as_query()` for @@ -57,12 +43,11 @@ as_query_lists_unnest <- function(.query){ #' @noRd #' @keywords Internal as_query_profiles_unnest <- function(.query){ - result <- list( - type = "metadata/profiles-unnest", - url = url_lookup("metadata/profiles-unnest", - profile = .query$filter$value[1])) - class(result) <- "query" - return(result) + list(type = "metadata/profiles-unnest", + url = url_lookup("metadata/profiles-unnest", + profile = .query$filter$value[1])) |> + enforce_select_query(.query) |> + as_query() } #' Internal function to `as_query()` for @@ -75,9 +60,9 @@ as_query_taxa_unnest <- function(.query){ }else if(!is.null(.query$identify)){ id <- "`TAXON_PLACEHOLDER`" } - result <- list(type = "metadata/taxa-unnest", - url = url_lookup("metadata/taxa-unnest", id = id), - headers = build_headers()) - class(result) <- "query" - return(result) + list(type = "metadata/taxa-unnest", + url = url_lookup("metadata/taxa-unnest", id = id), + headers = build_headers()) |> + enforce_select_query(.query) |> + as_query() } diff --git a/R/as_query-metadata.R b/R/as_query-metadata.R index a3eaba2a..185691fe 100644 --- a/R/as_query-metadata.R +++ b/R/as_query-metadata.R @@ -34,29 +34,6 @@ filtered_query <- function(query_type, .query){ headers = build_headers()) } -#' Internal function to enforce `select()` for queries. Basically just supplies -#' defaults. -#' @noRd -#' @keywords Internal -enforce_select_query <- function(new_query, supplied_query){ - if(is.null(supplied_query$select)){ - specific_type <- supplied_query |> - purrr::pluck("type") |> - stringr::str_remove("^metadata/") - chosen_columns <- wanted_columns(specific_type) - if(is.null(chosen_columns)){ - supplied_query <- dplyr::select(supplied_query, - tidyselect::everything()) - }else{ - supplied_query <- dplyr::select(supplied_query, - tidyselect::any_of(wanted_columns(specific_type))) - } - - } - update_request_object(new_query, select = supplied_query$select) -} - - # Actual functions called to build those queries #' Internal function get a tibble of APIs @@ -189,23 +166,43 @@ as_query_licences <- function(x){ #' Internal function to create a lists query #' @noRd #' @keywords Internal -as_query_lists <- function(x){ +as_query_lists <- function(x, + error_call = rlang::caller_env()){ query_type <- "metadata/lists" - if(check_if_cache_update_needed("lists")){ - url <- url_lookup(query_type) |> - httr2::url_parse() - url$query <- list(max = 10000) - if(!missing(x)){ - if(!is.null(x$slice)){ - url$query <- list(max = x$slice$slice_n) - } + # if filter is supplied, lookup a specified list by dr number + if(!is.null(x$filter)){ + dr_lookup <- stringr::str_detect(x$filter$value, "^dr") + if(any(dr_lookup)){ + dr_values <- x$filter$value[dr_lookup] + base_url <- url_lookup(query_type) + url <- glue::glue("{base_url}/{dr_values}") + result <- list(type = query_type, + url = tibble::tibble(url = url), # note: tibbles are used to skip pagination in `collapse()` + headers = build_headers(), + slot_name = "lists") + }else{ + cli::cli_abort(c("`filter()` arguments to `lists` only accept a data resource number", + i = "e.g. request_metadata() |> filter(lists == 'dr656')"), + call = error_call) } - result <- list(type = query_type, - url = httr2::url_build(url), - headers = build_headers(), - slot_name = "lists") + # if filter isn't supplied, check cache etc }else{ - result <- default_cache(query_type) + if(check_if_cache_update_needed("lists")){ + url <- url_lookup(query_type) |> + httr2::url_parse() + url$query <- list(max = 10000) + if(!missing(x)){ + if(!is.null(x$slice)){ + url$query <- list(max = x$slice$slice_n) + } + } + result <- list(type = query_type, + url = httr2::url_build(url), + headers = build_headers(), + slot_name = "lists") + }else{ + result <- default_cache(query_type) + } } result |> enforce_select_query(supplied_query = x) |> diff --git a/R/check.R b/R/check.R index b52b25b6..15a4366e 100644 --- a/R/check.R +++ b/R/check.R @@ -152,7 +152,7 @@ check_fields <- function(.query, # error message if(any(!is.na(check_result))) { returned_invalid <- tibble::tibble( - function_name = c("`galah_filter()`", "`galah_group_by()`"), + function_name = c("`filter()`", "`group_by()`"), fields = check_result) |> tidyr::drop_na() @@ -162,9 +162,9 @@ check_fields <- function(.query, bullets <- c( "Can't use fields that don't exist.", i = "Use `search_all(fields)` to find a valid field ID.", - x = glue("Can't find field(s) in"), + x = glue::glue("Can't find field(s) in"), glue::glue(" ", - format_error_bullets(invalid_fields_message), + rlang::format_error_bullets(invalid_fields_message), call = error_call) ) cli::cli_abort(bullets) @@ -686,7 +686,7 @@ check_reason <- function(.query, check_select <- function(.query, error_call = rlang::caller_env()){ if(any(names(.query) == "select")){ - if(is_gbif()){ + if(is_gbif() & stringr::str_detect(.query$type, "^data")){ cli::cli_inform(c("skipping `select()`:", i = "This function is not supported by the GBIF API v1")) }else{ diff --git a/R/coalesce.R b/R/coalesce.R index f7e11bbd..f8dd99da 100644 --- a/R/coalesce.R +++ b/R/coalesce.R @@ -16,7 +16,9 @@ #' @param ... Other arguments passed to [as_query()]. #' @order 1 #' @return An object of class `query_set`, which is simply a list of all `query` -#' objects required to properly evaluate the specified request. +#' objects required to properly evaluate the specified request. Objects are +#' listed in the order in which they will be evaluated, meaning the query +#' that the user has actually requested will be placed last. #' @seealso To open a piped query, see [galah_call()]. For alternative #' operations on `_request` objects, see [as_query()], #' \code{\link[=collapse.data_request]{collapse()}}, @@ -63,7 +65,7 @@ coalesce.metadata_request <- function(x, ...){ } }else if(is.null(x$filter)){ current_type <- x$type - cli::cli_abort("Requests of type `{current_type}` containing `unnest` must supply `filter()`.") + cli::cli_abort("Requests of type `{current_type}` must supply `filter()`.") } } if(x$type == "lists-unnest"){ diff --git a/R/collapse_checks.R b/R/collapse_checks.R index aed3a994..a434bbca 100644 --- a/R/collapse_checks.R +++ b/R/collapse_checks.R @@ -42,9 +42,7 @@ collapse_build_checks <- function(.query){ #' @keywords Internal collapse_run_checks <- function(.query){ # "data/" functions require pre-processing of metadata, - # as do `unnest()`/`show_values()` functions - if(grepl("^data/", .query$type) | - grepl("-unnest$", .query$type)){ + if(stringr::str_detect(.query$type, "^data/")){ # some checks should happen regardless of `run_checks` .query <- .query |> check_login() |> @@ -56,17 +54,21 @@ collapse_run_checks <- function(.query){ check_fields() |> check_profiles() } - # special cases: - # distributions - if(.query$type == "data/distributions" & - !is.null(.query[["metadata/distributions"]])){ - .query$url <- tibble(url = glue(utils::URLdecode(.query$url), - id = .query[["metadata/distributions"]]$id)) - } - # clean up - .query <- collapse_remove_metadata(.query) + # as do `unnest()`/`show_values()` functions + }else if(stringr::str_detect(.query$type, "-unnest$")){ + # FIXME: decide which checks should be subject to `if(potions::pour("package", "run_checks"))` + .query <- .query |> + check_identifiers() |> + check_fields() } - .query + collapse_remove_metadata(.query) + # special cases: + # distributions + # if(.query$type == "data/distributions" & + # !is.null(.query[["metadata/distributions"]])){ + # .query$url <- tibble(url = glue(utils::URLdecode(.query$url), + # id = .query[["metadata/distributions"]]$id)) + # } } #' Internal function to collapse metadata @@ -90,9 +92,8 @@ collapse_run_metadata <- function(names_vec, .query){ #' @noRd #' @keywords Internal collapse_add_metadata <- function(query, meta){ - result <- c(query, meta) - class(result) <- "query" - return(result) + c(query, meta) |> + as_query() } #' Internal function to reduce size of internally computed objects @@ -100,12 +101,10 @@ collapse_add_metadata <- function(query, meta){ #' @noRd #' @keywords Internal collapse_remove_metadata <- function(.query){ - names_lookup <- grepl("^metadata/", names(.query)) + names_lookup <- stringr::str_detect( names(.query), "^metadata/") if(any(names_lookup)){ - x <- .query[!names_lookup] + as_query(.query[!names_lookup]) }else{ - x <- .query + as_query(.query) } - class(x) <- "query" - x } \ No newline at end of file diff --git a/R/collapse_lists.R b/R/collapse_lists.R deleted file mode 100644 index 0c9cb1bb..00000000 --- a/R/collapse_lists.R +++ /dev/null @@ -1,59 +0,0 @@ -#' Internal function to `compute()` lists -#' Required for pagination -#' Should run a query with `max = 0` to get total n -#' Then use `max` and `offset` to paginate up to `n` -#' @noRd -#' @keywords Internal -collapse_lists <- function(.query){ - if(is.null(.query$url)){ - .query - }else{ - url <- httr2::url_parse(.query$url) - n_requested <- as.integer(url$query$max) - # make decisions about how much pagination is needed - if(n_requested <= 500){ # we haven't hit pagination limit - .query - }else{ # more lists are requested - n <- get_max_n(.query) - n_pages <- ceiling(n$max_requested / n$paginate) - offsets <- (seq_len(n_pages) - 1) * n$paginate - result <- tibble::tibble( - offset = offsets, - max = c( - rep(n$paginate, n_pages - 1), - n$max_requested - offsets[n_pages])) - result$url <- purrr::map( - split(result, seq_len(nrow(result))), - function(a){ - url$query <- list(offset = a$offset, max = a$max) - httr2::url_build(url) - }) |> - unlist() - .query$url <- dplyr::select(result, "url") - } - .query - } -} - -#' Internal function to retrieve max number of entries for an API -#' @noRd -#' @keywords Internal -get_max_n <- function(.query){ - url <- httr2::url_parse(.query$url) - if(is_gbif()){ - count_field <- "count" - }else{ - count_field <- "listCount" - } - n <- list(requested = as.integer(url$query$max), - paginate = 500, - max_available = { - url$query <- list(max = 0) - list(url = httr2::url_build(url), - headers = .query$headers) |> - query_API() |> - purrr::pluck(count_field) # NOTE: only tested for ALA - }) - n$max_requested <- min(c(n$requested, n$max_available)) - return(n) -} diff --git a/R/collapse_metadata.R b/R/collapse_metadata.R new file mode 100644 index 00000000..09fc6439 --- /dev/null +++ b/R/collapse_metadata.R @@ -0,0 +1,136 @@ +#' Internal function to `collapse()` lists +#' Required for pagination +#' Should run a query with `max = 0` to get total n +#' Then use `max` and `offset` to paginate up to `n` +#' @noRd +#' @keywords Internal +collapse_lists <- function(.query){ + if(is.null(.query$url)){ + .query + }else if(inherits(.query$url, "tbl_df")){ + .query + }else{ + url <- httr2::url_parse(.query$url) + n_requested <- as.integer(url$query$max) + # make decisions about how much pagination is needed + if(n_requested <= 500){ # we haven't hit pagination limit + .query + }else{ # more lists are requested + n <- get_max_n(.query) + n_pages <- ceiling(n$max_requested / n$paginate) + offsets <- (seq_len(n_pages) - 1) * n$paginate + result <- tibble::tibble( + offset = offsets, + max = c( + rep(n$paginate, n_pages - 1), + n$max_requested - offsets[n_pages])) + result$url <- purrr::map( + split(result, seq_len(nrow(result))), + function(a){ + url$query <- list(offset = a$offset, max = a$max) + httr2::url_build(url) + }) |> + unlist() + .query$url <- dplyr::select(result, "url") + } + .query + } +} + +#' Internal function to retrieve max number of entries for an API +#' @noRd +#' @keywords Internal +get_max_n <- function(.query){ + url <- httr2::url_parse(.query$url) + if(is_gbif()){ + count_field <- "count" + }else{ + count_field <- "listCount" + } + n <- list(requested = as.integer(url$query$max), + paginate = 500, + max_available = { + url$query <- list(max = 0) + list(url = httr2::url_build(url), + headers = .query$headers) |> + query_API() |> + purrr::pluck(count_field) # NOTE: only tested for ALA + }) + n$max_requested <- min(c(n$requested, n$max_available)) + return(n) +} + + +#' Internal function to call `collapse` for `request_metadata(type = "profiles-unnest")` +#' @noRd +#' @keywords Internal +collapse_profile_values <- function(.query, + error_call){ + url <- .query |> + purrr::pluck("url") |> + httr2::url_parse() + profile_name <- extract_profile_name(url) + short_name <- profile_short_name(profile_name, + error_call = error_call) + if (!potions::pour("atlas", "region") == "Spain") { + path_name <- url |> + purrr::pluck("path") |> + dirname() + url$path <- glue::glue("{path_name}/{short_name}") + } + list(type = .query$type, + url = httr2::url_build(url)) |> + enforce_select_query(.query) |> + as_query() +} +# this doesn't print for some reason + +#' Internal function to convert between long and short names +#' for data profiles. Only used by `collapse_profile_values()` +#' @noRd +#' @keywords Internal +profile_short_name <- function(profile, + error_call) { + valid_profiles <- show_all_profiles() + short_name <- NA + if (suppressWarnings(!is.na(as.numeric(profile)))) { + # assume a profile id has been provided + short_name <- valid_profiles[match(as.numeric(profile), + valid_profiles$id),]$short_name + } else { + # try to match a short name or a long name + if (profile %in% valid_profiles$name) { + short_name <- valid_profiles[match(profile, + valid_profiles$name), ]$short_name + } else { + if (profile %in% valid_profiles$short_name) { + short_name <- profile + } + } + } + if (is.na(short_name)) { + c( + "Unknown profile detected.", + i = "See a listing of valid data quality profiles with `show_all_profiles()`.") |> + cli::cli_abort(call = error_call) + }else{ + short_name + } +} + +#' Internal function to extract profile name from url +#' for data profiles. Only used by `compute_profile_values()` +#' @noRd +#' @keywords Internal +extract_profile_name <- function(url) { + atlas <- potions::pour("atlas", "region") + if (atlas == "Spain") { + profile_name <- url |> + purrr::pluck("query", "profileName") + } else { + profile_name <- url |> + purrr::pluck("path") |> + basename() + } + return(profile_name) +} diff --git a/R/collapse_profile_values.R b/R/collapse_profile_values.R deleted file mode 100644 index 30161598..00000000 --- a/R/collapse_profile_values.R +++ /dev/null @@ -1,71 +0,0 @@ -#' Internal function to call `collapse` for `request_metadata(type = "profiles-unnest")` -#' @noRd -#' @keywords Internal -collapse_profile_values <- function(.query){ - url <- .query |> - purrr::pluck("url") |> - httr2::url_parse() - profile_name <- extract_profile_name(url) - short_name <- profile_short_name(profile_name) - if (!potions::pour("atlas", "region") == "Spain") { - path_name <- url |> - purrr::pluck("path") |> - dirname() - url$path <- glue::glue("{path_name}/{short_name}") - } - result <- list(type = .query$type, - url = httr2::url_build(url)) - class(result) <- "query" - return(result) -} -# this doesn't print for some reason - -#' Internal function to convert between long and short names -#' for data profiles. Only used by `collapse_profile_values()` -#' @noRd -#' @keywords Internal -profile_short_name <- function(profile, - error_call = rlang::caller_env()) { - valid_profiles <- show_all_profiles() - short_name <- NA - if (suppressWarnings(!is.na(as.numeric(profile)))) { - # assume a profile id has been provided - short_name <- valid_profiles[match(as.numeric(profile), - valid_profiles$id),]$shortName - } else { - # try to match a short name or a long name - if (profile %in% valid_profiles$name) { - short_name <- valid_profiles[match(profile, - valid_profiles$name), ]$shortName - } else { - if (profile %in% valid_profiles$shortName) { - short_name <- profile - } - } - } - if (is.na(short_name)) { - c( - "Unknown profile detected.", - i = "See a listing of valid data quality profiles with `show_all_profiles()`.") |> - cli::cli_abort(call = error_call) - }else{ - short_name - } -} - -#' Internal function to extract profile name from url -#' for data profiles. Only used by `compute_profile_values()` -#' @noRd -#' @keywords Internal -extract_profile_name <- function(url) { - atlas <- potions::pour("atlas", "region") - if (atlas == "Spain") { - profile_name <- url |> - purrr::pluck("query", "profileName") - } else { - profile_name <- url |> - purrr::pluck("path") |> - basename() - } - return(profile_name) -} diff --git a/R/collapse_query.R b/R/collapse_query.R index 684cd4ca..59d1b367 100644 --- a/R/collapse_query.R +++ b/R/collapse_query.R @@ -2,7 +2,8 @@ #' @param x a `query_set` #' @noRd #' @keywords Internal -collapse_query <- function(x){ +collapse_query <- function(x, + error_call = rlang::caller_env()){ switch(x$type, "data/occurrences" = collapse_occurrences(x), "data/occurrences-count" = { @@ -33,7 +34,8 @@ collapse_query <- function(x){ "data/species" = collapse_occurrences(x), # optimised for GBIF "data/species-count" = collapse_species_count(x), # "-unnest" functions require some checks - "metadata/profiles-unnest" = collapse_profile_values(x), # check this + "metadata/profiles-unnest" = collapse_profile_values(x, + error_call = error_call), # some "metadata/" functions require pagination under some circumstances "metadata/lists" = collapse_lists(x), # always paginates x # remaining "metadata/" functions are passed as-is diff --git a/R/collect_metadata.R b/R/collect_metadata.R index 54b106e6..23632740 100644 --- a/R/collect_metadata.R +++ b/R/collect_metadata.R @@ -42,6 +42,21 @@ flat_lists_only <- function(x){ }) } +#' Internal function to ensure rows can be converted to tibble +#' This is important because some lists contain `NULL` +#' The function fixes it by converting to `NA` +#' @param x a single list, to be converted into a tibble row. use map() for multiple rows +#' @noRd +#' @keywords Internal +make_nulls_safe <- function(x){ + x_null <- purrr::map(x, is.null) |> + unlist() + if(any(x_null)){ + x[x_null] <- purrr::map(x[x_null], \(a){c(NA)}) + } + x +} + # collect functions #' Internal function to `collect()` APIs @@ -67,7 +82,7 @@ collect_assertions <- function(.query){ result_df <- result_api |> dplyr::bind_rows() |> dplyr::rename_with(camel_to_snake_case) |> - parse_rename(type = "assertions") |> + parse_rename(.query) |> dplyr::mutate(type = "assertions") |> # for consistency with `collect_fields()` update_attributes(type = "assertions") update_cache(assertions = result_df) @@ -120,7 +135,7 @@ collect_collections <- function(.query){ } result_df <- result_df |> dplyr::rename_with(camel_to_snake_case) |> - dplyr::arrange(.data$id) |> + parse_arrange() |> update_attributes(type = "collections") update_cache(collections = result_df) } @@ -155,7 +170,7 @@ collect_datasets <- function(.query){ } result_df <- result_df |> dplyr::rename_with(camel_to_snake_case) |> - dplyr::arrange(.data$id) |> + parse_arrange() |> update_attributes(type = "datasets") update_cache(datasets = result_df) } @@ -169,7 +184,7 @@ collect_distributions_metadata <- function(.query){ result <- query_API(.query) result <- result |> dplyr::bind_rows() |> - # NOTE: This syntax should be integrated with `wanted_columns()` et al before shipping + # NOTE: This syntax should be integrated with `lookup_select_columns()` et al before shipping dplyr::select( "spcode", "family", @@ -243,7 +258,7 @@ collect_licences <- function(.query){ result_df <- result |> dplyr::bind_rows() |> dplyr::rename_with(camel_to_snake_case) |> - dplyr::arrange(result$id) + parse_arrange() }else{ result_df <- tibble::tibble(id = character(), name = character(), @@ -260,29 +275,47 @@ collect_licences <- function(.query){ #' @noRd #' @keywords Internal collect_lists <- function(.query){ + # NOTE: this function has some quite versatile behaviour, so we need to + # explicitly control when caching does (and does not) happen. + should_update_cache <- FALSE + + # requests for cached data use the `data` slot; check this first if(!is.null(.query$data)){ result_df <- retrieve_internal_data(.query) }else{ # here we run and parse an API call - if(inherits(.query$url, "data.frame")){ + result <- query_API(.query) # this when `url` is a single value or a tibble + # pagination returns long lists + if(length(result) > 1){ result_df <- purrr::map(query_API(.query), - \(a){a$lists}) |> - dplyr::bind_rows() - }else{ - result_df <- query_API(.query) |> - purrr::pluck("lists") |> + \(a){a$lists}) |> dplyr::bind_rows() + should_update_cache <- TRUE + }else{ + lists_slot <- purrr::pluck(result, "lists") + # single list queries that use `filter()` don't have a `lists` slot + if(is.null(lists_slot)){ + result_df <- result[[1]] |> + make_nulls_safe() |> + tibble::as_tibble() + # but some queries do + }else{ + result_df <- lists_slot |> + dplyr::bind_rows() + should_update_cache <- TRUE + } } - if(any(colnames(result_df) == "dataResourceUid")){ - result_df <- result_df |> - dplyr::rename("species_list_uid" = "dataResourceUid") - } + # cleaning result_df <- result_df |> dplyr::rename_with(camel_to_snake_case) |> - dplyr::arrange(.data$species_list_uid) |> + parse_rename(.query) |> + parse_arrange() |> update_attributes(type = "lists") - update_cache(lists = result_df) + + if(should_update_cache){ + update_cache(lists = result_df) + } } # return parse_select(result_df, .query) @@ -309,7 +342,7 @@ collect_media_metadata <- function(.query){ # NOTE: this has no caching on purpose result |> dplyr::rename_with(camel_to_snake_case) |> - parse_rename(type = "media") |> + parse_rename(.query) |> dplyr::filter(!is.na(result$image_id)) |> parse_select(.query) } @@ -326,7 +359,7 @@ collect_profiles <- function(.query){ result_df <- result |> dplyr::filter(!duplicated(result$id)) |> dplyr::rename_with(camel_to_snake_case) |> - dplyr::arrange(.data$id) |> + parse_arrange() |> update_attributes(type = "profiles") update_cache(profiles = result_df) } @@ -365,7 +398,7 @@ collect_providers <- function(.query){ } result_df <- result_df |> dplyr::rename_with(camel_to_snake_case) |> - dplyr::arrange(.data$id) |> + parse_arrange() |> update_attributes(type = "providers") update_cache(providers = result_df) } @@ -392,7 +425,7 @@ collect_reasons <- function(.query){ dplyr::bind_rows() result_df <- result |> dplyr::filter(!result$deprecated) |> - dplyr::arrange(.data$id) |> + parse_arrange() |> dplyr::relocate("id", "name") |> update_attributes(type = "reasons") update_cache(reasons = result_df) diff --git a/R/collect_metadata_unnest.R b/R/collect_metadata_unnest.R index 9846bfa0..2e670272 100644 --- a/R/collect_metadata_unnest.R +++ b/R/collect_metadata_unnest.R @@ -9,14 +9,17 @@ collect_fields_unnest <- function(.query, httr2::url_parse() if(is_gbif()){ + # get name of facet in question facet <- purrr::pluck(facet, "query", "facet") # NOTE: "facet" (singular) check_missing_fields(facet, call = error_call) - result <- .query |> + # get result from API + .query |> query_API() |> purrr::pluck(!!!list("facets", 1, "counts")) |> - dplyr::bind_rows() - colnames(result)[which(colnames(result) == "name")[1]] <- facet - dplyr::select(result, {{facet}}) + dplyr::bind_rows() |> + dplyr::rename_with(camel_to_snake_case) |> + dplyr::rename({{facet}} := "name") |> + parse_select(.query) }else{ facet <- purrr::pluck(facet, "query", "facets") # NOTE: "facets" (plural) @@ -28,12 +31,13 @@ collect_fields_unnest <- function(.query, # extract unformatted facet values if(nrow(result) > 0){ - result <- result |> + result |> dplyr::mutate( - field_value = stringr::str_extract(result$i18nCode, - "(?<=\\.).*")) # everything after . - colnames(result)[which(colnames(result) == "field_value")[1]] <- facet - dplyr::select(result, {{facet}}) + field_name := stringr::str_extract(result$i18nCode, "(?<=\\.).*"), + .before = 1) |> + dplyr::rename_with(camel_to_snake_case) |> + dplyr::rename({{facet}} := "field_name") |> + parse_select(.query) }else{ # i.e. catch empty results result } @@ -57,7 +61,6 @@ check_missing_fields <- function(x, call){ collect_lists_unnest <- function(.query){ result <- query_API(.query) |> dplyr::bind_rows() - # extract additional raw fields columns if (any(colnames(result) %in% "kvpValues")) { result <- result |> @@ -65,9 +68,10 @@ collect_lists_unnest <- function(.query){ tidyr::pivot_wider(names_from = "key", values_from = "value") } - - return(result) - + result |> + dplyr::rename_with(camel_to_snake_case) |> + parse_rename(.query) |> + parse_select(.query) } #' Internal function to run `compute()` for @@ -75,13 +79,14 @@ collect_lists_unnest <- function(.query){ #' @noRd #' @keywords Internal collect_profiles_unnest <- function(.query){ - result <- query_API(.query) |> + result <- query_API(.query) + result |> purrr::pluck("categories") |> - dplyr::bind_rows() - result <- result |> + dplyr::bind_rows() |> dplyr::pull("qualityFilters") |> - dplyr::bind_rows() - result + dplyr::bind_rows() |> + dplyr::rename_with(camel_to_snake_case) |> + parse_select(.query) } #' Internal function to run `compute()` for @@ -90,5 +95,8 @@ collect_profiles_unnest <- function(.query){ #' @keywords Internal collect_taxa_unnest <- function(.query){ query_API(.query) |> - dplyr::bind_rows() + dplyr::bind_rows() |> + dplyr::rename_with(camel_to_snake_case) |> + parse_rename(.query) |> + parse_select(.query) } diff --git a/R/collect_taxa.R b/R/collect_taxa.R index 409ac5d7..68285508 100644 --- a/R/collect_taxa.R +++ b/R/collect_taxa.R @@ -53,7 +53,7 @@ collect_taxa_namematching <- function(.query, } result |> dplyr::rename_with(camel_to_snake_case) |> - parse_rename(type = "taxa") |> + parse_rename(.query) |> parse_select(.query) } @@ -76,7 +76,7 @@ collect_taxa_la <- function(.query){ } result |> dplyr::rename_with(camel_to_snake_case) |> - parse_rename(type = "taxa") |> + parse_rename(.query) |> parse_select(.query) } @@ -91,7 +91,7 @@ collect_taxa_gbif <- function(.query){ dplyr::mutate("search_term" = search_terms, .before = 1) |> dplyr::rename_with(camel_to_snake_case) |> - parse_rename(type = "taxa") |> + parse_rename(.query) |> parse_select(.query) } @@ -207,7 +207,7 @@ collect_identifiers <- function(.query){ result |> dplyr::rename_with(camel_to_snake_case) |> - parse_rename(type = "taxa") |> + parse_rename(.query) |> parse_select(.query) } diff --git a/R/galah_select.R b/R/galah_select.R index 063ef1c1..c3d5275b 100644 --- a/R/galah_select.R +++ b/R/galah_select.R @@ -96,16 +96,10 @@ #' * `synonyms` to include any synonymous names. #' * `lists` to include authoritative lists that each species is included on. #' -#' The [everything()] function is recoded in galah to support three changed -#' behaviours: -#' -#' * When called with [unnest()] for type `"lists"`, it adds user-provided -#' columns, for example on conservation status or species traits. -#' * For occurrence downloads with type `"species`, it adds `counts`, -#' `synonyms` and `lists` to the download. -#' * For 'normal' occurrence downloads, it returns an error. Returning all -#' fields is computationally expensive and probably not what you want -#' anyway. +#' For metadata queries - as generated using [request_metadata()] or +#' [galah_call()] - `select()` can now be used to return only the requested +#' columns. Unlike data queries, this works by capturing the user's query +#' and applying it user-side, rather than amending the query. #' #' @seealso \code{\link[=filter.data_request]{filter()}}, #' \code{\link[=st_crop.data_request]{st_crop()}} and @@ -139,9 +133,9 @@ select.data_request <- function(.data, ..., group){ cli::cli_inform("`select()` is not supported for GBIF: skipping") .data }else{ - rlang::enquos(..., .ignore_empty = "all") |> - as.list() |> - add_summary() |> + dots <- rlang::enquos(..., .ignore_empty = "all") + list(quosure = dots, + summary = generate_summary(dots)) |> add_group(group) |> update_request_object(.data, select = _) } @@ -150,14 +144,10 @@ select.data_request <- function(.data, ..., group){ #' @rdname select.data_request #' @export select.metadata_request <- function(.data, ...){ - # if(.data$type != "lists"){ - # cli::cli_abort("`select()` is only supported for type `lists`") - # } - ## TODO: decide whether warnings are needed. - ## Probably inform("Skipping") would be fine - rlang::enquos(..., .ignore_empty = "all") |> - as.list() |> - add_summary() |> + dots <- rlang::enquos(..., + .ignore_empty = "all") + list(quosure = dots, + summary = generate_summary(dots)) |> update_request_object(.data, select = _) } @@ -175,9 +165,9 @@ galah_select <- function(..., group){ NULL } }else{ - dots <- dots |> - add_summary() |> - add_group(group) + # dots <- dots |> + # add_summary() |> # FIXME: this *will* break rn + # add_group(group) if(inherits(dots[[1]], "data_request")){ update_request_object(dots[[1]], select = dots[-1]) @@ -190,14 +180,11 @@ galah_select <- function(..., group){ #' internal function to summarise select function (to support `print()`) #' @noRd #' @keywords Internal -add_summary <- function(dots){ - labels <- purrr::map(dots, rlang::as_label) |> - unlist() - labels <- labels[labels != ""] - last_entry <- length(dots) + 1 - dots[[last_entry]] <- glue::glue_collapse(labels, sep = " | ") - names(dots)[last_entry] <- "summary" - dots +generate_summary <- function(dots){ + labels <- purrr::map(dots, rlang::expr_text) |> + unlist() |> + glue::glue_collapse(sep = " | ") + labels[labels != ""] } #' internal function to add `group` arg to the end of a list diff --git a/R/print.R b/R/print.R index 95b28091..d4adf148 100644 --- a/R/print.R +++ b/R/print.R @@ -170,12 +170,19 @@ print.query <- function(x, ...){ }else{ subtext <- "" } + if(!is.null(x$select)){ + select <- galah_grey(glue::glue("\n + select: {x$select$summary}")) + }else{ + select <- NULL + } cat(c( crayon::silver("Object of class"), galah_pink("query"), crayon::silver("with type"), galah_green(x$type), subtext, # note: need code for url tibbles + select, arrange, slice)) } @@ -243,7 +250,7 @@ print.query_set <- function(x, ...){ n_queries <- length(x) message(c(crayon::silver("Object of class "), galah_pink("`query_set` "), - crayon::silver(glue("containing ")), + crayon::silver(glue::glue("containing ")), ifelse(n_queries > 1, crayon::silver(glue::glue("{n_queries} queries:")), crayon::silver("1 query:")))) diff --git a/R/show_values.R b/R/show_values.R index 9c2d677c..f30ee6c8 100644 --- a/R/show_values.R +++ b/R/show_values.R @@ -68,7 +68,7 @@ show_values <- function(df, match_column <- switch(type, "fields" = "id", "lists" = "species_list_uid", - "profiles" = "shortName", + "profiles" = "short_name", "taxa" = "taxon_concept_id", "uid" # last option selected if above are exhausted ) diff --git a/R/taxonomic-searches.R b/R/taxonomic-searches.R index 6981a33b..fc0c9509 100644 --- a/R/taxonomic-searches.R +++ b/R/taxonomic-searches.R @@ -26,12 +26,13 @@ #' #' Note that when taxonomic look-up is required within a pipe, the equivalent #' to [search_taxa()] is \code{\link[=identify.data_request]{identify()}} (or -#' [galah_identify()]). The equivalent to `search_identifiers()` is to use +#' [galah_identify()]). The equivalent to [search_identifiers()] is to use #' \code{\link[=filter.data_request]{filter()}} to filter by `taxonConceptId`. #' #' @details #' `search_taxa()` returns the taxonomic match of a supplied text string, along #' with the following information: +#' #' * `search_term`: The search term used by the user. When multiple search #' terms are provided in a tibble, these are displayed in this column, #' concatenated using `_`. @@ -48,6 +49,17 @@ #' * `taxonomic names` (e.g. `kingdom`, `phylum`, `class`, `order`, #' `family`, `genus`) #' +#' When querying using [request_metadata()], you have the option to pass +#' \code{\link[=select.metadata_request]{select()}} within the query. The +#' easiest way to do this is `select(everything())`, but for completeness, the +#' following additional fields are available: +#' +#' * `success`: Logical indicating success or failure of the search +#' * `scientific_name_authorship`: Author and year for the name in question +#' * `name_type`: Usually `"SCIENTIFIC"` +#' * `lft` and `rgt`: Numeric indices for taxonomic lookups +#' * `species_group` and `species_subgroup`: List-columns giving group names +#' * `_id` fields for `rank` and any taxonomic rank fields (`kingdom_id`, `phylum_id` etc.) #' #' @seealso [search_all()] for how to get names if taxonomic identifiers #' are already known. \code{\link[=filter.data_request]{filter()}}, diff --git a/R/utilities_internal.R b/R/utilities_internal.R index bb991f6f..dd4abbd3 100644 --- a/R/utilities_internal.R +++ b/R/utilities_internal.R @@ -2,12 +2,103 @@ ## Output formatting functions -- ##--------------------------------------------------------------- +#' Internal function to enforce `select()` for metadata queries. Basically just +#' supplies defaults. This is the *setup* phase as is usually called by +#' `as_query()` +#' @noRd +#' @keywords Internal +enforce_select_query <- function(new_query, supplied_query){ + # if `select()` is given, we simply pass it on + # if missing, we have to apply some logic + if(is.null(supplied_query$select)){ + specific_type <- supplied_query |> + purrr::pluck("type") |> + stringr::str_remove("^metadata/") + # see whether `lookup_select_columns()` returns anything + chosen_columns <- lookup_select_columns(specific_type) + # some `unnest` queries internally rename the lead column to the name of the supplied field + if(is.null(chosen_columns) & + stringr::str_detect(specific_type, "-unnest$")){ + chosen_columns <- supplied_query$filter |> + dplyr::pull(value) + } + # if we have, after 2 attempts, found some chosen_columns, use them + if(!is.null(chosen_columns)){ + supplied_query <- dplyr::select(supplied_query, + tidyselect::any_of({{chosen_columns}})) + # if *still* null, choose `everything()` + }else{ + supplied_query <- dplyr::select(supplied_query, + tidyselect::everything()) + } + } + update_request_object(new_query, + select = supplied_query$select) +} + +#' Internal function to run `eval_tidy()` on captured `select()` requests. +#' This is the *enactment* phase and is usually called by `collect()`. +#' Critically, this function is *NOT* called by `select()`. This matters because +#' we have to eval `unnest()` before `select()` for it to work, and this can +#' only happen at the end of a pipe. +#' @noRd +#' @keywords Internal +parse_select <- function(df, .query){ + # get quosures captured by `select()` + quo_list <- purrr::pluck(.query, "select", "quosure") + # map() over list of quosures + # honestly I don't know why `!!quo_list` fails here, but it does, so used this instead + pos <- purrr::map(quo_list, \(a){ + tidyselect::eval_select(expr = a, data = df) + }) |> + unlist() + # apply tidy selection to `df` + # note: this code taken from `tidyselect` documentation; it could be argued that `df[pos]` is sufficient + rlang::set_names(df[pos], names(pos)) +} + +#' Internal function to rename specific columns. Note this is safer than calling +#' `dplyr::rename()` directly, because it only seeks to rename columns that +#' are actually present, and so won't fail. +#' @noRd +#' @keywords Internal +parse_rename <- function(df, .query){ + cols <- colnames(df) + rename_vec <- .query$type |> + stringr::str_remove("^metadata/") |> + lookup_rename_columns() + # check whether renaming information is given + if(!is.null(rename_vec)){ + # check whether these are actually present in the supplied `tibble` + col_lookup <- rename_vec %in% cols + # if they are, rename + if(any(col_lookup)){ + rename_cols <- as.list(rename_vec[col_lookup]) + dplyr::rename(df, !!!rename_cols) + # otherwise, return source `tibble` + }else{ + df + } + # if no lookup information supplied, return source `tibble` + }else{ + df + } +} + +#' Simple internal function to `arrange()` by first column +#' @noRd +#' @keywords Internal +parse_arrange <- function(df){ + col <- colnames(df)[1] + dplyr::arrange(df, !!!col) +} + #' Choose column names to pass to `select()`. #' NOTE: this isn't especially subtle wrt different atlases #' NOTE: this assumes `dplyr::rename_with(camel_to_snake_case)` has been run #' @noRd #' @keywords Internal -wanted_columns <- function(type) { +lookup_select_columns <- function(type) { switch(type, "assertions" = c("id", "description", @@ -16,7 +107,7 @@ wanted_columns <- function(type) { "fields" = c("id", "description", "type"), - "identifiers" = wanted_columns_taxa(), + "identifiers" = lookup_select_columns_taxa(), "licences" = c("id", "name", "acronym", @@ -26,6 +117,9 @@ wanted_columns <- function(type) { "description", "list_type", "item_count"), + "lists-unnest" = c("scientific_name", + "vernacular_name", + "taxon_concept_id"), "media" = c("image_id", "creator", "license", @@ -42,17 +136,25 @@ wanted_columns <- function(type) { "short_name", "name", "description"), + "profiles-unnest" = c("id", + "description", + "filter", + "enabled"), "reasons" = c("id", "name"), - "taxa" = wanted_columns_taxa(), + "taxa" = lookup_select_columns_taxa(), + "taxa-unnest" = c("name", + "taxon_concept_id", + "parent_taxon_concept_id", + "rank"), NULL # When no defaults are set, sending NULL tells the code to call `everything()` ) } -#' `wanted_columns()` but for taxa *and* identifier queries +#' `lookup_select_columns()` but for taxa and identifier queries #' @noRd #' @keywords Internal -wanted_columns_taxa <- function(){ +lookup_select_columns_taxa <- function(){ c("search_term", "scientific_name", "scientific_name_authorship", @@ -68,63 +170,46 @@ wanted_columns_taxa <- function(){ "time_taken", "vernacular_name", "issues", - {show_all_ranks() |> dplyr::pull("name")}) + # taxonomic ranks (basic only) + "kingdom", + "phylum", + "class", + "order", + "family", + "genus", + "species" + # if all are needed, use this instead + # {show_all_ranks() |> dplyr::pull("name")} + ) } -#' Internal function to run `eval_tidy()` on captured `select()` requests +#' Choose which columns to rename #' @noRd #' @keywords Internal -parse_select <- function(df, .query){ - select_list <- .query |> - purrr::pluck("select") |> - purrr::map(rlang::is_quosure) |> - unlist() |> - which() - select_query <- .query |> - purrr::pluck("select", !!!select_list) - pos <- tidyselect::eval_select(expr = select_query, - data = df) - rlang::set_names(df[pos], names(pos)) # note: this line taken from - # `tidyselect` documentation; it could be argued that `df[pos]` is sufficient +lookup_rename_columns <- function(type){ + switch(type, + "assertions" = c("id" = "name"), + "lists" = c("species_list_uid" = "data_resource_uid"), + "lists-unnest" = c("taxon_concept_id" = "lsid"), + "media" = c("image_id" = "image_identifier", + "mimetype" = "mime_type"), + "taxa" = c("class" = "classs", + "taxon_concept_id" = "usage_key", + "taxon_concept_id" = "guid", + "taxon_concept_id" = "reference_id", + "taxon_concept_id" = "key", + "genus" = "genus_name", + "family" = "family_name", + "order" = "order_name", + "phylum" = "phylum_name", + "kingdom" = "kingdom_name", + "rank" = "rank_name", + "vernacular_name" = "french_vernacular_name"), + "taxa-unnest" = c("taxon_concept_id" = "guid", + "parent_taxon_concept_id" = "parent_guid"), + NULL + ) } - -#' Internal function to rename specific columns -#' In-progress, tidyverse-compliant replacement for `rename_columns()` -#' Note that actual renaming is now handled in-pipe by `dplyr::rename()` -#' @noRd -#' @keywords Internal -parse_rename <- function(df, type){ - if(type == "taxa"){ - taxa_vec <- c("class" = "classs", - "taxon_concept_id" = "usage_key", - "taxon_concept_id" = "guid", - "taxon_concept_id" = "reference_id", - "taxon_concept_id" = "key", - "genus" = "genus_name", - "family" = "family_name", - "order" = "order_name", - "phylum" = "phylum_name", - "kingdom" = "kingdom_name", - "rank" = "rank_name", - "vernacular_name" = "french_vernacular_name") - cols <- colnames(df) - col_lookup <- taxa_vec %in% cols - rename_cols <- as.list(taxa_vec[col_lookup]) - if(any(col_lookup)){ - dplyr::rename(df, !!!rename_cols) - }else{ - df - } - }else if(type == "assertions"){ - dplyr::rename(df, !!!c("id" = "name")) - }else if(type == "media"){ - dplyr::rename(df, !!!c("image_id" = "image_identifier", - "mimetype" = "mime_type")) - }else{ - df - } -} - ##--------------------------------------------------------------- ## Cases -- diff --git a/man/coalesce.Rd b/man/coalesce.Rd index d75ef2f9..e9830f22 100644 --- a/man/coalesce.Rd +++ b/man/coalesce.Rd @@ -26,7 +26,9 @@ applies to \code{type = "occurrences"} when atlas chosen is "ALA".} } \value{ An object of class \code{query_set}, which is simply a list of all \code{query} -objects required to properly evaluate the specified request. +objects required to properly evaluate the specified request. Objects are +listed in the order in which they will be evaluated, meaning the query +that the user has actually requested will be placed last. } \description{ \code{\link[=coalesce]{coalesce()}} is an S3 generic function intended to be called before diff --git a/man/select.data_request.Rd b/man/select.data_request.Rd index fcffdbe7..047320ed 100644 --- a/man/select.data_request.Rd +++ b/man/select.data_request.Rd @@ -123,17 +123,10 @@ it should be one or more of: \item \code{lists} to include authoritative lists that each species is included on. } -The \code{\link[=everything]{everything()}} function is recoded in galah to support three changed -behaviours: -\itemize{ -\item When called with \code{\link[=unnest]{unnest()}} for type \code{"lists"}, it adds user-provided -columns, for example on conservation status or species traits. -\item For occurrence downloads with type \verb{"species}, it adds \code{counts}, -\code{synonyms} and \code{lists} to the download. -\item For 'normal' occurrence downloads, it returns an error. Returning all -fields is computationally expensive and probably not what you want -anyway. -} +For metadata queries - as generated using \code{\link[=request_metadata]{request_metadata()}} or +\code{\link[=galah_call]{galah_call()}} - \code{select()} can now be used to return only the requested +columns. Unlike data queries, this works by capturing the user's query +and applying it user-side, rather than amending the query. } \examples{ \dontrun{ diff --git a/man/taxonomic_searches.Rd b/man/taxonomic_searches.Rd index db25cb59..7767ec67 100644 --- a/man/taxonomic_searches.Rd +++ b/man/taxonomic_searches.Rd @@ -27,7 +27,7 @@ identifiers. Note that when taxonomic look-up is required within a pipe, the equivalent to \code{\link[=search_taxa]{search_taxa()}} is \code{\link[=identify.data_request]{identify()}} (or -\code{\link[=galah_identify]{galah_identify()}}). The equivalent to \code{search_identifiers()} is to use +\code{\link[=galah_identify]{galah_identify()}}). The equivalent to \code{\link[=search_identifiers]{search_identifiers()}} is to use \code{\link[=filter.data_request]{filter()}} to filter by \code{taxonConceptId}. } \details{ @@ -50,6 +50,19 @@ on the \href{https://github.com/AtlasOfLivingAustralia/ala-name-matching?tab=rea \item \verb{taxonomic names} (e.g. \code{kingdom}, \code{phylum}, \code{class}, \code{order}, \code{family}, \code{genus}) } + +When querying using \code{\link[=request_metadata]{request_metadata()}}, you have the option to pass +\code{\link[=select.metadata_request]{select()}} within the query. The +easiest way to do this is \code{select(everything())}, but for completeness, the +following additional fields are available: +\itemize{ +\item \code{success}: Logical indicating success or failure of the search +\item \code{scientific_name_authorship}: Author and year for the name in question +\item \code{name_type}: Usually \code{"SCIENTIFIC"} +\item \code{lft} and \code{rgt}: Numeric indices for taxonomic lookups +\item \code{species_group} and \code{species_subgroup}: List-columns giving group names +\item \verb{_id} fields for \code{rank} and any taxonomic rank fields (\code{kingdom_id}, \code{phylum_id} etc.) +} } \examples{ \dontrun{ diff --git a/tests/testthat/test-request_metadata_select.R b/tests/testthat/test-request_metadata_select.R index 2609e6fe..71411caa 100644 --- a/tests/testthat/test-request_metadata_select.R +++ b/tests/testthat/test-request_metadata_select.R @@ -26,7 +26,7 @@ test_that("`request_metadata()` works with `select()` for local APIs", { purrr::pluck(query, "select") |> is.null() |> expect_false() - purrr::pluck(query, !!!list("select", 1)) |> + purrr::pluck(query, !!!list("select", "quosure", 1)) |> rlang::is_quosure() |> expect_true() purrr::pluck(query, "select", "summary") |> @@ -76,7 +76,7 @@ test_that("`request_metadata()` works with `select()` for remote APIs *without* purrr::pluck(query, "select") |> is.null() |> expect_false() - purrr::pluck(query, !!!list("select", 1)) |> + purrr::pluck(query, !!!list("select", "quosure", 1)) |> rlang::is_quosure() |> expect_true() purrr::pluck(query, "select", "summary") |> @@ -119,7 +119,7 @@ test_that("`request_metadata()` works with `select()` for remote APIs *with* def x <- purrr::map(type_list, \(a){ # setup - expected_columns <- wanted_columns(a) + expected_columns <- lookup_select_columns(a) expected_n <- length(expected_columns) # set up a query _without_ `everything()` @@ -134,7 +134,7 @@ test_that("`request_metadata()` works with `select()` for remote APIs *with* def purrr::pluck(query, "select") |> is.null() |> expect_false() - purrr::pluck(query, !!!list("select", 1)) |> + purrr::pluck(query, !!!list("select", "quosure", 1)) |> rlang::is_quosure() |> expect_true() purrr::pluck(query, "select", "summary") |> @@ -198,7 +198,7 @@ test_that("`request_metdata()` works with `select()` for `type = 'taxa'`", { purrr::pluck(query, "select") |> is.null() |> expect_false() - purrr::pluck(query, !!!list("select", 1)) |> + purrr::pluck(query, !!!list("select", "quosure", 1)) |> rlang::is_quosure() |> expect_true() purrr::pluck(query, "select", "summary") |> @@ -232,7 +232,7 @@ test_that("`request_metdata()` works with `select()` for complex taxa", { purrr::pluck(query, "select") |> is.null() |> expect_false() - purrr::pluck(query, !!!list("select", 1)) |> + purrr::pluck(query, !!!list("select", "quosure", 1)) |> rlang::is_quosure() |> expect_true() purrr::pluck(query, "select", "summary") |> @@ -266,7 +266,7 @@ test_that("`request_metdata()` works with `select()` for `type = 'identifiers'`" purrr::pluck(query, "select") |> is.null() |> expect_false() - purrr::pluck(query, !!!list("select", 1)) |> + purrr::pluck(query, !!!list("select", "quosure", 1)) |> rlang::is_quosure() |> expect_true() purrr::pluck(query, "select", "summary") |> diff --git a/tests/testthat/test-request_metadata_unnest.R b/tests/testthat/test-request_metadata_unnest.R index e1a08f14..ca1b3ddd 100644 --- a/tests/testthat/test-request_metadata_unnest.R +++ b/tests/testthat/test-request_metadata_unnest.R @@ -3,17 +3,20 @@ test_that("request_metadata() |> unnest() works for type = 'fields'", { # no filter provided causes an error expect_error({request_metadata() |> unnest() |> - collapse()}) + collapse()}, + label = "Requests of type `fields-unnest` must supply `filter()`") # an incorrect filter argument errors at `collapse()` expect_error({request_metadata() |> filter(something == 10) |> unnest() |> - collapse()}) + collapse()}, + label = "Invalid `type` supplied to `unnest()`") # passing a correct `type` but not `value` errors at `collapse()`... expect_error(request_metadata() |> unnest() |> filter(field == unknown) |> - collapse()) + collapse(), + label = "Can't use fields that don't exist.") # whole thing works when... x <- request_metadata() |> unnest() |> @@ -28,16 +31,25 @@ test_that("request_metadata() |> unnest() works for type = 'fields'", { test_that("request_metadata() |> select() |> unnest() works for type = 'fields'", { skip_if_offline(); skip_on_ci() - x <- request_metadata() |> + base_query <- request_metadata() |> filter(field == basisOfRecord) |> - unnest() |> - select(everything()) |> + unnest() + x <- base_query |> collect() expect_s3_class(x, c("tbl_df", "tbl", "data.frame")) expect_gte(nrow(x), 4) expect_equal(ncol(x), 1) expect_equal(colnames(x), "basisOfRecord") expect_true(any(x[[1]] == "HUMAN_OBSERVATION")) + y <- base_query |> + select(everything()) |> + collect() + expect_gt(ncol(y), ncol(x)) + z <- base_query |> + select(label, count) |> + collect() + expect_gt(ncol(z), ncol(x)) + expect_lt(ncol(z), ncol(y)) }) test_that("request_metadata() |> unnest() works for type = 'lists'", { @@ -48,28 +60,68 @@ test_that("request_metadata() |> unnest() works for type = 'lists'", { collapse() expect_s3_class(x, "query") expect_equal(x$type, "metadata/lists-unnest") - expect_equal(names(x), c("type", "url")) + expect_equal(names(x), + c("type", "url", "select")) y <- compute(x) expect_s3_class(y, "computed_query") z <- collect(y) expect_s3_class(z, c("tbl_df", "tbl", "data.frame")) expect_gte(nrow(z), 10) expect_gte(ncol(z), 3) + # now check `everything()` + xx <- request_metadata() |> + filter(list == dr947) |> + unnest() |> + select(everything()) |> + collect() + expect_gt(ncol(xx), ncol(z)) }) -test_that("request_metadata() |> unnest() works for type = 'profiles'", { +test_that("`request_metadata() |> unnest()` fails for invalid profiles", { skip_if_offline(); skip_on_ci() x <- request_metadata() |> unnest() |> filter(profile == "something") - expect_error(collapse(x)) - y <- request_metadata() |> + expect_error(collapse(x), + label = "Unknown profile detected") +}) + +test_that("`request_metadata() |> unnest() |> collapse()` works for type = profiles", { + skip_if_offline(); skip_on_ci() + x <- request_metadata() |> + filter(profile == "ALA") |> + unnest() |> + collapse() + expect_s3_class(x, "query") + expect_equal(x$type, "metadata/profiles-unnest") + expect_equal(names(x), + c("type", "url", "select")) +}) + +test_that("request_metadata() |> unnest() works for type = 'profiles'", { + skip_if_offline(); skip_on_ci() + x <- request_metadata() |> filter(profile == "ALA") |> unnest() |> collect() - expect_s3_class(y, c("tbl_df", "tbl", "data.frame")) - expect_gte(ncol(y), 3) - expect_gte(nrow(y), 10) + expect_s3_class(x, c("tbl_df", "tbl", "data.frame")) + expect_gte(ncol(x), 3) + expect_gte(nrow(x), 10) + # check `select()` works + y <- request_metadata() |> + filter(profile == "ALA") |> + unnest() |> + select(filter, enabled) |> + collect() + expect_equal(nrow(x), nrow(y)) + expect_equal(ncol(y), 2) + # now check `everything()` + z <- request_metadata() |> + filter(profile == "ALA") |> + unnest() |> + select(everything()) |> + collect() + expect_gt(ncol(z), ncol(x)) }) test_that("request_metadata() |> unnest() works for type = 'taxa' using `identify()`", { @@ -79,14 +131,14 @@ test_that("request_metadata() |> unnest() works for type = 'taxa' using `identif unnest() |> collapse() expect_s3_class(x, "query") - expect_equal(length(x), 3) - expect_equal(names(x), c("type", "url", "headers")) + expect_equal(length(x), 4) + expect_equal(names(x), c("type", "url", "headers", "select")) expect_equal(x$type, "metadata/taxa-unnest") y <- compute(x) expect_s3_class(y, "computed_query") z <- collect(y) expect_s3_class(z, c("tbl_df", "tbl", "data.frame")) - expect_gte(ncol(z), 3) + expect_equal(ncol(z), 4) expect_gte(nrow(z), 10) }) @@ -98,13 +150,42 @@ test_that("request_metadata() |> unnest() works for type = 'taxa' using `filter( unnest() |> collapse() expect_s3_class(x, "query") - expect_equal(length(x), 3) - expect_equal(names(x), c("type", "url", "headers")) + expect_equal(length(x), 4) + expect_equal(names(x), c("type", "url", "headers", "select")) expect_equal(x$type, "metadata/taxa-unnest") y <- compute(x) expect_s3_class(y, "computed_query") z <- collect(y) expect_s3_class(z, c("tbl_df", "tbl", "data.frame")) - expect_gte(ncol(z), 3) + expect_equal(ncol(z), 4) expect_gte(nrow(z), 10) }) + +test_that("`request_metadata() |> unnest() |> select()` works for type = 'taxa'", { + skip_if_offline(); skip_on_ci() + lookup <- search_taxa("Crinia")$taxon_concept_id + # request one column only + x <- request_metadata() |> + filter(taxa == lookup) |> + unnest() |> + select(name) |> + collect() + # request defaults (4 columns) + y <- request_metadata() |> + filter(taxa == lookup) |> + unnest() |> + collect() + # request all columns + z <- request_metadata() |> + filter(taxa == lookup) |> + unnest() |> + select(everything()) |> + collect() + # should all have same number of rows + expect_equal(nrow(x), nrow(y)) + expect_equal(nrow(x), nrow(z)) + # but increasing numbers of columns + expect_gt(ncol(y), ncol(x)) + expect_gt(ncol(z), ncol(x)) + # +}) diff --git a/tests/testthat/test-search_all.R b/tests/testthat/test-search_all.R index 343807a0..e8370c33 100644 --- a/tests/testthat/test-search_all.R +++ b/tests/testthat/test-search_all.R @@ -1,9 +1,9 @@ -test_that("search_all checks inputs, returns helpful error", { +test_that("`search_all()` checks inputs, returns helpful error", { skip_if_offline(); skip_on_ci() expect_error(search_all(attributes, ""), "Unrecognised metadata requested") }) -test_that("search_all returns correct output for type", { +test_that("`search_all()` returns correct output for type", { skip_if_offline(); skip_on_ci() fields <- search_all(fields, "year") reasons <- search_all(reasons, "genus") @@ -23,13 +23,13 @@ test_that("search_all returns correct output for type", { expect_equal(ncol(profiles), 4) }) -test_that("search_all returns error when missing query", { +test_that("`search_all()` returns error when missing query", { skip_if_offline(); skip_on_ci() expect_error(search_all(profiles), "We didn't detect a search") expect_error(search_all(fields, blah)) }) -test_that("search_assertions returns a filtered result", { +test_that("`search_assertions()` returns a filtered result", { skip_if_offline(); skip_on_ci() all <- show_all_assertions() search <- search_assertions("INVALID") diff --git a/tests/testthat/test-show_all.R b/tests/testthat/test-show_all.R index d5b89132..15e78bca 100644 --- a/tests/testthat/test-show_all.R +++ b/tests/testthat/test-show_all.R @@ -34,15 +34,12 @@ test_that("all show_all() functions return correctly with all syntax", { syntax2 <- paste0("show_all(", a, ")") |> parse(text = _) |> eval() # e.g. show_all(fields) - syntax3 <- request_metadata(type = a) |> - collect() limit_test <- paste0("show_all_", a) |> do.call(args = list(limit = 3)) expect_s3_class(syntax1, c("tbl_df", "tbl", "data.frame")) expect_equal(attributes(syntax1)$call, a) expect_equal(attributes(syntax1)$region, "Australia") expect_equal(syntax1, syntax2) - expect_equal(syntax1, syntax3) expect_equal(nrow(limit_test), 3) })) -}) +}) \ No newline at end of file diff --git a/tests/testthat/test-show_values.R b/tests/testthat/test-show_values.R index 7d6786bc..0dbcaa88 100644 --- a/tests/testthat/test-show_values.R +++ b/tests/testthat/test-show_values.R @@ -22,56 +22,32 @@ test_that("`show_values()` checks values", { test_that("`show_values()` accepts search & show_all inputs from fields", { skip_if_offline(); skip_on_ci() - # traditional syntax - values_search <- search_all(lists, "EPBC act") |> + values_search <- search_all(fields, "basisOfRecord") |> quiet_values() expect_s3_class(values_search, c("tbl_df", "tbl", "data.frame")) expect_gt(nrow(values_search), 0) - # newer syntax (doesn't require `show_all_lists()`) - values_show <- request_metadata() |> - filter(lists == "dr656") |> - unnest() |> - collect() - expect_s3_class(values_show, c("tbl_df", "tbl", "data.frame")) - expect_gt(nrow(values_show), 0) }) test_that("`show_values()` accepts search & show_all inputs from profiles", { skip_if_offline(); skip_on_ci() - # traditional syntax values_search <- search_all(profiles, "ALA") |> quiet_values() expect_s3_class(values_search, c("tbl_df", "tbl", "data.frame")) expect_gt(nrow(values_search), 0) - # newer syntax - values_show <- request_metadata() |> - filter(profiles == "ALA") |> - unnest() |> - collect() - expect_s3_class(values_show, c("tbl_df", "tbl", "data.frame")) - expect_gt(nrow(values_show), 0) }) test_that("`show_values()` accepts search & show_all inputs from lists", { skip_if_offline(); skip_on_ci() - # old syntax - values_search <- search_all(fields, "cl22") |> + values_search <- search_all(lists, "dr650") |> quiet_values() expect_s3_class(values_search, c("tbl_df", "tbl", "data.frame")) expect_gt(nrow(values_search), 0) - # new syntax - values_show <- request_metadata() |> - filter(fields == "basisOfRecord") |> - unnest() |> - collect() - expect_s3_class(values_show, c("tbl_df", "tbl", "data.frame")) - expect_gt(nrow(values_show), 0) }) test_that("`search_values()` returns helpful error when missing query", { skip_if_offline(); skip_on_ci() expect_error(search_values(), "Missing information for values lookup") - expect_error(search_all(fields, "cl22") |> purrr_search(), "didn't detect a search") + expect_error(search_all(fields, "cl22") |> search_values(), "didn't detect a search") }) test_that("`search_values()` returns filtered results for fields", { @@ -104,11 +80,14 @@ test_that("`search_values()` returns filtered results for profiles", { test_that("`search_values()` returns filtered results for lists", { skip_if_offline(); skip_on_ci() - search <- search_all(lists, "ALA") - values_search <- search |> quiet_search("frog") - values_show <- search |> quiet_values() + # use more efficient syntax + base_df <- request_metadata() |> + filter(lists == "dr650") |> + collect() + values_search <- base_df |> quiet_search("frog") + values_show <- base_df |> quiet_values() search_result_check <- all(grepl(pattern = "frog", - paste(values_search$commonName, values_search$scientificName), + paste(values_search$vernacular_name, values_search$scientific_name), ignore.case = TRUE)) expect_s3_class(values_search, c("tbl_df", "tbl", "data.frame")) expect_equal(names(values_search), names(values_show)) @@ -146,26 +125,6 @@ test_that("`show_values()` returns unformatted names", { expected) }) -test_that("`unnest()` syntax works", { - skip_if_offline(); skip_on_ci() - # fields - x <- request_metadata() |> - filter(field == "cl22") |> - unnest() |> - collect() - expect_s3_class(x, c("tbl_df", "tbl", "data.frame")) - expect_equal(colnames(x), "cl22") - expect_gte(nrow(x), 1) - # profiles - y <- request_metadata() |> - filter(profile == "ALA") |> - unnest() |> - collect() - expect_s3_class(y, c("tbl_df", "tbl", "data.frame")) - expect_gte(ncol(y), 4) - expect_gte(nrow(y), 1) -}) - test_that("`show_values()` all_fields = TRUE works for lists", { skip_if_offline(); skip_on_ci() # simple, fake version for testing `show_values()` @@ -189,24 +148,4 @@ test_that("`show_values()` all_fields = TRUE works for lists", { "* Showing values for 'cl22'.") }) -test_that("unnest() |> `select(everything()) works as alternative to all_fields",{ - x <- request_metadata() |> - filter(list == "dr650") |> - select(everything()) |> - unnest() |> - collect() - extra_cols <- c("raw_scientificName", "status", "sourceStatus", "IUCN_equivalent_status") - expect_s3_class(x, c("tbl_df", "tbl", "data.frame")) - expect_gt(nrow(x), 0) - expect_true(any(colnames(x) %in% extra_cols)) - expect_gt(ncol(x), 6) # adds additional columns - - # explicitly errors for other metadata types - request_metadata() |> - filter(field == "basisOfRecord") |> - select(everything()) |> - unnest() |> - expect_error() -}) - rm(purrr_values, quiet_values, purrr_search, quiet_search) \ No newline at end of file From 6478a71b8b549cd9b70b7226e5b1cec0033cf22b Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Mon, 20 Oct 2025 11:49:18 +1100 Subject: [PATCH 33/94] Extend `all_fields` argument to all metadata types (#266) --- NAMESPACE | 1 + R/collapse_occurrences_count_atlas.R | 3 +- R/collect_metadata.R | 77 ++++++++------- R/collect_taxa.R | 2 +- R/search_all.R | 142 ++++++++++++++++++++------- R/show_all.R | 113 +++++++++++++++------ R/show_values.R | 29 +++--- man/search_all.Rd | 41 ++++---- man/show_all.Rd | 35 ++++--- man/show_values.Rd | 19 ++-- 10 files changed, 304 insertions(+), 158 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index cee61583..2a54471d 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -86,6 +86,7 @@ export(search_fields) export(search_identifiers) export(search_licences) export(search_lists) +export(search_media) export(search_profiles) export(search_providers) export(search_ranks) diff --git a/R/collapse_occurrences_count_atlas.R b/R/collapse_occurrences_count_atlas.R index 3927fd17..038368fb 100644 --- a/R/collapse_occurrences_count_atlas.R +++ b/R/collapse_occurrences_count_atlas.R @@ -95,8 +95,7 @@ collapse_occurrences_count_atlas_groupby_crossed <- function(.query, x <- result[[a]][c(1, 4)] names(x)[1] <- a x}) - # browser() - + # convert to all combinations of levels if(length(result_list) > 1){ levels_list <- purrr::map(result_list, \(a){a[[1]]}) diff --git a/R/collect_metadata.R b/R/collect_metadata.R index 23632740..6acb491e 100644 --- a/R/collect_metadata.R +++ b/R/collect_metadata.R @@ -25,21 +25,33 @@ retrieve_internal_data <- function(.query){ eval() } -#' Internal function to remove `list()` entries inside lists -#' This supports passing to `bind_rows()`, but loses data +#' Function to convert list entries in tibbles, supporting list-columns +#' for nested data +#' @param x a list-entry - i.e. we need to call +#' purrr::map(list, tidy_list_columns) for this to work #' @noRd #' @keywords Internal -flat_lists_only <- function(x){ - purrr::map(x, - \(a){ - purrr::map(a, \(b){ - if(is.list(b)){ - NULL - }else{ - b - } - }) - }) +tidy_list_columns <- function(x){ + + # tibble-ify list columns + list_check <- purrr::map(x, is.list) |> + unlist() + if(any(list_check)){ + list_names <- names(x)[list_check] + list_tibbles <- purrr::map(list_names, + \(a){ + tibble::tibble({{a}} := list(x[[a]])) + }) + }else{ + list_tibbles <- NULL + } + + # bind with non-list columns + x[!list_check] |> + make_nulls_safe() |> + tibble::as_tibble() |> + dplyr::bind_cols(list_tibbles) |> + dplyr::select(!!!names(x)) # reorder columns to same as `x` } #' Internal function to ensure rows can be converted to tibble @@ -115,7 +127,7 @@ collect_collections <- function(.query){ result <- purrr::pluck(result, "results") } result_df <- result |> - flat_lists_only() |> + tidy_list_columns() |> dplyr::bind_rows() # Then France }else if(potions::pour("atlas", "region", .pkg = "galah") == "France"){ @@ -156,7 +168,7 @@ collect_datasets <- function(.query){ result <- purrr::pluck(result, "results") } result_df <- result |> - flat_lists_only() |> + tidy_list_columns() |> dplyr::bind_rows() }else if(potions::pour("atlas", "region", .pkg = "galah") == "France"){ result_df <- result |> @@ -321,29 +333,24 @@ collect_lists <- function(.query){ parse_select(result_df, .query) } -#' Internal version of `collect()` for `request_data(type = "media")` +#' Internal version of `collect()` for `request_metadata(type = "media")` #' @param object of class `data_response`, from `compute()` #' @noRd #' @keywords Internal -collect_media_metadata <- function(.query){ - result <- query_API(.query) |> - purrr::pluck("results") |> - dplyr::bind_rows() - if(nrow(result) < 1){ # case where no data returned - if(potions::pour("package", "verbose")){ - cli::cli_warn("No data returned from `metadata/media` API") - } - ids <- .query$body |> - jsonlite::fromJSON() |> - unlist() - result <- tibble::tibble(image_id = ids) - } +collect_media_metadata <- function(.query, + error_call = rlang::caller_env()){ + result <- query_API(.query) + + # ensure list-columns are imported correctly + result_df <- purrr::map(result, tidy_list_columns) |> + dplyr::bind_rows() + # Select only the information we want - # NOTE: this has no caching on purpose - result |> + # NOTE: this has no caching *on purpose* + result_df |> dplyr::rename_with(camel_to_snake_case) |> parse_rename(.query) |> - dplyr::filter(!is.na(result$image_id)) |> + dplyr::filter(!is.na(.data$image_id)) |> parse_select(.query) } @@ -357,7 +364,7 @@ collect_profiles <- function(.query){ result <- query_API(.query) |> dplyr::bind_rows() result_df <- result |> - dplyr::filter(!duplicated(result$id)) |> + dplyr::filter(!duplicated(.data$id)) |> dplyr::rename_with(camel_to_snake_case) |> parse_arrange() |> update_attributes(type = "profiles") @@ -380,7 +387,7 @@ collect_providers <- function(.query){ result <- purrr::pluck(result, "results") } result_df <- result |> - flat_lists_only() |> + tidy_list_columns() |> dplyr::bind_rows() }else if(potions::pour("atlas", "region", .pkg = "galah") == "France"){ result_df <- tibble::tibble(name = { @@ -424,7 +431,7 @@ collect_reasons <- function(.query){ result <- query_API(.query) |> dplyr::bind_rows() result_df <- result |> - dplyr::filter(!result$deprecated) |> + dplyr::filter(!.data$deprecated) |> parse_arrange() |> dplyr::relocate("id", "name") |> update_attributes(type = "reasons") diff --git a/R/collect_taxa.R b/R/collect_taxa.R index 68285508..29ff8264 100644 --- a/R/collect_taxa.R +++ b/R/collect_taxa.R @@ -181,7 +181,7 @@ clean_la_taxa <- function(result, search_terms){ collect_identifiers <- function(.query){ search_terms <- .query$url$search_term result <- query_API(.query) |> - flat_lists_only() |> + tidy_list_columns() |> dplyr::bind_rows() if(any(colnames(result) == "taxonConceptID")){ diff --git a/R/search_all.R b/R/search_all.R index d208f49a..c70d199a 100644 --- a/R/search_all.R +++ b/R/search_all.R @@ -21,9 +21,10 @@ #' @param type A string to specify what type of parameters should be searched. #' @param query A string specifying a search term. Searches are not #' case-sensitive. -#' @param ... A set of strings or a tibble to be queried; see -#' Details. -#' @details There are five categories of information, each with their own +#' @param all_fields `r lifecycle::badge("experimental")` If `TRUE`, +#' `show_values()` also returns all columns available from the API, rather +#' than the 'default' columns traditionally provided via galah. +#' @details There are six categories of information, each with their own #' specific sub-functions to look-up each type of information. #' The available types of information for `search_all()` are: #' @@ -44,6 +45,7 @@ #' |data providers|`providers`| Search for which institutions have provided data | `search_providers()`| #' | |`collections`|Search for the specific collections within those institutions| `search_collections()`| #' | |`datasets`|Search for the data groupings within those collections| `search_datasets()`| +#' |media|`media`|Search for images or sounds using a vector of IDs|`search_media()`| #' #' #' @aliases search_all @@ -88,7 +90,9 @@ #' dplyr::filter(grepl("date", id)) #' } #' @export -search_all <- function(type, query){ +search_all <- function(type, + query, + all_fields = FALSE){ # vector of valid types for this function valid_types <- c( @@ -98,9 +102,10 @@ search_all <- function(type, query){ "collections", "datasets", "fields", + "identifiers", "licences", "lists", - "identifiers", + "media", "profiles", "providers", "reasons", @@ -120,28 +125,44 @@ search_all <- function(type, query){ } check_if_missing(query) + run_subsequent_query <- FALSE if(type == "taxa"){ check_if_in_pipe(query) - request_metadata(type = "taxa") |> - identify(query) |> - collect() + request <- request_metadata(type = "taxa") |> + identify(query) }else if(type == "identifiers"){ - request_metadata() |> - filter("identifier" == query) |> - collect() + request <- request_metadata() |> + filter("identifier" == query) + }else if(type == "media"){ + request <- request_metadata() |> + filter("media" == query) }else{ if(is_gbif() & type %in% c("collections", "datasets", "providers")){ # these support `q` arg in API - request_metadata() |> - filter({{type}} == query) |> - collect() + request <- request_metadata() |> + filter({{type}} == query) }else{ - request_metadata(type = type) |> - collect() |> - search_text_cols(query = query) + request <- request_metadata(type = type) + run_subsequent_query <- TRUE } } + + # add all_fields if requested + if(isTRUE(all_fields)){ + request <- request |> + dplyr::select(tidyselect::everything()) + } + + # collect the supplied `request` + result <- collect(request) + + # add search, or not, depending on behaviour + if(isTRUE(run_subsequent_query)){ + search_text_cols(result, query) + }else{ + result + } } #' Internal function to run a query over a tibble @@ -150,10 +171,10 @@ search_all <- function(type, query){ search_text_cols <- function(df, query){ query <- tolower(query) - keep_cols <- unlist(lapply(df, is.character)) & + keep_cols <- unlist(purrr::map(df, is.character)) & colnames(df) != "type" check_list <- purrr::map(df[, keep_cols], - \(a){grepl(query, tolower(a))}) + \(a){stringr::str_detect(tolower(a), query)}) check_vector <- purrr::list_transpose(check_list) |> purrr::map(any) |> unlist() @@ -200,67 +221,116 @@ check_if_in_pipe <- function(..., #' @rdname search_all #' @export -search_assertions <- function(query){search_all("assertions", query)} +search_assertions <- function(query, + all_fields = FALSE){ + search_all("assertions", query, all_fields) +} #' @rdname search_all #' @export -search_apis <- function(query){search_all("apis", query)} +search_apis <- function(query, + all_fields = FALSE){ + search_all("apis", query, all_fields) +} #' @rdname search_all #' @export -search_atlases <- function(query){search_all("atlases", query)} +search_atlases <- function(query, + all_fields = FALSE){ + search_all("atlases", query, all_fields) +} #' @rdname search_all #' @export -search_collections <- function(query){search_all("collections", query)} +search_collections <- function(query, + all_fields = FALSE){ + search_all("collections", query, all_fields) +} #' @rdname search_all #' @export -search_datasets <- function(query){search_all("datasets", query)} +search_datasets <- function(query, + all_fields = FALSE){ + search_all("datasets", query, all_fields) +} #' @rdname search_all #' @export -search_fields <- function(query){search_all("fields", query)} +search_fields <- function(query, + all_fields = FALSE){ + search_all("fields", query, all_fields) +} + +#' @rdname search_all +#' @export +search_identifiers <- function(..., + all_fields = FALSE){ + search_all("identifiers", + unlist(list(...)), + all_fields = all_fields) +} #' @rdname search_all #' @export -search_identifiers <- function(...){search_all("identifiers", unlist(list(...)))} +search_licences <- function(query, + all_fields = FALSE){ + search_all("licences", query, all_fields) +} #' @rdname search_all #' @export -search_licences <- function(query){search_all("licences", query)} +search_lists <- function(query, + all_fields = FALSE){ + search_all("lists", query, all_fields) +} #' @rdname search_all #' @export -search_lists <- function(query){search_all("lists", query)} +search_media <- function(query, + all_fields = FALSE){ + search_all("media", query, all_fields) +} #' @rdname search_all #' @export -search_profiles <- function(query){search_all("profiles", query)} +search_profiles <- function(query, + all_fields = FALSE){ + search_all("profiles", query, all_fields) +} #' @rdname search_all #' @export -search_providers <- function(query){search_all("providers", query)} +search_providers <- function(query, + all_fields = FALSE){ + search_all("providers", query, all_fields) +} #' @rdname search_all #' @export -search_ranks <- function(query){search_all("ranks", query)} +search_ranks <- function(query, + all_fields = FALSE){ + search_all("ranks", query, all_fields) +} #' @rdname search_all #' @export -search_reasons <- function(query){search_all("reasons", query)} +search_reasons <- function(query, + all_fields = FALSE){ + search_all("reasons", query, all_fields) +} #' @rdname search_all #' @export -search_taxa <- function(...){ +search_taxa <- function(..., + all_fields = FALSE){ dots <- list(...) if(length(dots) == 1L){ if(inherits(dots[[1]], "data.frame")){ - search_all("taxa", dots[[1]]) + search_all("taxa", dots[[1]], all_fields) }else{ - search_all("taxa", dots) + search_all("taxa", dots, all_fields) } }else{ - search_all("taxa", dots) + search_all("taxa", dots, all_fields) } } \ No newline at end of file diff --git a/R/show_all.R b/R/show_all.R index 0c0a0270..0610b85a 100644 --- a/R/show_all.R +++ b/R/show_all.R @@ -15,6 +15,9 @@ #' @param ... String showing what type of information is to be requested. See #' `Details` (below) for accepted values. #' @param limit Optional number of values to return. Defaults to NULL, i.e. all records +#' @param all_fields `r lifecycle::badge("experimental")` If `TRUE`, +#' `show_values()` also returns all columns available from the API, rather +#' than the 'default' columns traditionally provided via galah. #' @details There are five categories of information, each with their own #' specific sub-functions to look-up each type of information. #' The available types of information for `show_all_` are: @@ -58,9 +61,16 @@ #' # `show_all()` is synonymous with `request_metadata() |> collect()` #' request_metadata(type = "fields") |> #' collect() +#' +#' # using `all_fields = TRUE` is synonymous with `select(everything())` +#' request_metadata(type = "fields") |> +#' select(everything()) |> +#' collect() #' } #' @export -show_all <- function(..., limit = NULL){ +show_all <- function(..., + limit = NULL, + all_fields = FALSE){ dots <- rlang::enquos(..., .ignore_empty = "all") if(length(dots) < 1){ type_text <- "fields" @@ -70,7 +80,8 @@ show_all <- function(..., limit = NULL){ rlang::as_label(dots[[1]])) # handle case where type is quoted } show_all_generic(type = type_text, - limit = limit) + limit = limit, + all_fields = all_fields) } #' Internal function to handle `show_all` calls @@ -78,11 +89,16 @@ show_all <- function(..., limit = NULL){ #' `show_all()` and `collect()` #' @noRd #' @keywords Internal -show_all_generic <- function(type, limit){ +show_all_generic <- function(type, + limit, + all_fields){ x <- request_metadata(type = type) if(!is.null(limit)){ x <- x |> dplyr::slice_head(n = limit) } + if(isTRUE(all_fields)){ + x <- x |> dplyr::select(tidyselect::everything()) + } result <- collect(x) # `show_all()` always returns requested number of records # this differs from `collect()` which always returns what the API gives you @@ -96,79 +112,118 @@ show_all_generic <- function(type, limit){ #' @rdname show_all #' @export -show_all_apis <- function(limit = NULL){ - show_all_generic(type = "apis", limit = limit) +show_all_apis <- function(limit = NULL, + all_fields = FALSE){ + show_all_generic(type = "apis", + limit = limit, + all_fields = all_fields) } #' @rdname show_all #' @export -show_all_assertions <- function(limit = NULL){ - show_all_generic(type = "assertions", limit = limit) +show_all_assertions <- function(limit = NULL, + all_fields = FALSE){ + show_all_generic(type = "assertions", + limit = limit, + all_fields = all_fields) } #' @rdname show_all #' @export -show_all_atlases <- function(limit = NULL) { - show_all_generic(type = "atlases", limit = limit) +show_all_atlases <- function(limit = NULL, + all_fields = FALSE){ + show_all_generic(type = "atlases", + limit = limit, + all_fields = all_fields) } #' @rdname show_all #' @export -show_all_collections <- function(limit = NULL){ - show_all_generic(type = "collections", limit = limit) +show_all_collections <- function(limit = NULL, + all_fields = FALSE){ + show_all_generic(type = "collections", + limit = limit, + all_fields = all_fields) } #' @rdname show_all #' @export -show_all_datasets <- function(limit = NULL){ - show_all_generic(type = "datasets", limit = limit) +show_all_datasets <- function(limit = NULL, + all_fields = FALSE){ + show_all_generic(type = "datasets", + limit = limit, + all_fields = all_fields) } #' @rdname show_all #' @noRd #' @keywords Internal -show_all_distributions <- function(limit = NULL){ - show_all_generic(type = "distributions", limit = limit) +show_all_distributions <- function(limit = NULL, + all_fields = FALSE){ + show_all_generic(type = "distributions", + limit = limit, + all_fields = all_fields) } #' @rdname show_all #' @export -show_all_fields <- function(limit = NULL){ - show_all_generic(type = "fields", limit = limit) +show_all_fields <- function(limit = NULL, + all_fields = FALSE){ + show_all_generic(type = "fields", + limit = limit, + all_fields = all_fields) } #' @rdname show_all #' @export -show_all_licences <- function(limit = NULL){ - show_all_generic(type = "licences", limit = limit) +show_all_licences <- function(limit = NULL, + all_fields = FALSE){ + show_all_generic(type = "licences", + limit = limit, + all_fields = all_fields) } #' @rdname show_all #' @export -show_all_lists <- function(limit = NULL){ - show_all_generic(type = "lists", limit = limit) +show_all_lists <- function(limit = NULL, + all_fields = FALSE){ + show_all_generic(type = "lists", + limit = limit, + all_fields = all_fields) } #' @rdname show_all #' @export -show_all_profiles <- function(limit = NULL) { - show_all_generic(type = "profiles", limit = limit) +show_all_profiles <- function(limit = NULL, + all_fields = FALSE){ + show_all_generic(type = "profiles", + limit = limit, + all_fields = all_fields) } #' @rdname show_all #' @export -show_all_providers <- function(limit = NULL){ - show_all_generic(type = "providers", limit = limit) +show_all_providers <- function(limit = NULL, + all_fields = FALSE){ + show_all_generic(type = "providers", + limit = limit, + all_fields = all_fields) } #' @rdname show_all #' @export -show_all_ranks <- function(limit = NULL) { - show_all_generic(type = "ranks", limit = limit) +show_all_ranks <- function(limit = NULL, + all_fields = FALSE){ + show_all_generic(type = "ranks", + limit = limit, + all_fields = all_fields) } #' @rdname show_all #' @export -show_all_reasons <- function(limit = NULL){ - show_all_generic(type = "reasons", limit = limit) +show_all_reasons <- function(limit = NULL, + all_fields = FALSE){ + show_all_generic(type = "reasons", + limit = limit, + all_fields = all_fields) } \ No newline at end of file diff --git a/R/show_values.R b/R/show_values.R index f30ee6c8..6de1df34 100644 --- a/R/show_values.R +++ b/R/show_values.R @@ -29,14 +29,17 @@ #' @param df A search result from [search_fields()], [search_profiles()] or #' [search_lists()]. #' @param all_fields `r lifecycle::badge("experimental")` If `TRUE`, -#' `show_values()` also returns all raw data columns -#' (columns included prior to the dataset's ingestion into the ALA). -#' For many lists, this will include raw scientific names and vernacular -#' names. -#' For conservation lists like the EPBC list, this also includes columns -#' containing each species' conservation status information. -#' Default is set to `FALSE`. -#' Currently only implemented for metadata type `lists`. +#' `show_values()` also returns all columns available from the API, rather +#' than the 'default' columns traditionally provided via galah. +#' +#' For lists, this will include 'raw' columns; columns included prior to the +#' dataset's ingestion into the ALA, and will often include raw scientific +#' names and vernacular names. For conservation lists like the EPBC list, this +#' also includes columns containing each species' conservation status +#' information. +#' +#' For other forms of metadata, setting this to `TRUE` may return more +#' information than you want or need. Default is set to `FALSE`. #' @return A `tibble` of values for a specified field, profile or list. #' @examples \dontrun{ #' # Show values in field 'cl22' @@ -73,13 +76,7 @@ show_values <- function(df, "uid" # last option selected if above are exhausted ) match_name <- df[[match_column]][1] - - # add_fields for lists only - if(isTRUE(all_fields) && type != "lists") { - cli::cli_warn("`all_fields` only applies to type `lists`. Ignoring `all_fields = TRUE`.") - all_fields <- FALSE - } - + # specify the number matched fields # specify for which field the values are displayed if(nrow(df) > 1) { @@ -99,7 +96,7 @@ show_values <- function(df, } } - if(type == "lists" & isTRUE(all_fields)){ + if(isTRUE(all_fields)){ request_metadata() |> filter({{type}} == {{match_name}}) |> select(everything()) |> diff --git a/man/search_all.Rd b/man/search_all.Rd index ee34c402..ce7f41db 100644 --- a/man/search_all.Rd +++ b/man/search_all.Rd @@ -15,38 +15,41 @@ \alias{search_apis} \alias{search_identifiers} \alias{search_lists} +\alias{search_media} \alias{search_taxa} \title{Search for record information} \usage{ -search_all(type, query) +search_all(type, query, all_fields = FALSE) -search_assertions(query) +search_assertions(query, all_fields = FALSE) -search_apis(query) +search_apis(query, all_fields = FALSE) -search_atlases(query) +search_atlases(query, all_fields = FALSE) -search_collections(query) +search_collections(query, all_fields = FALSE) -search_datasets(query) +search_datasets(query, all_fields = FALSE) -search_fields(query) +search_fields(query, all_fields = FALSE) -search_identifiers(...) +search_identifiers(..., all_fields = FALSE) -search_licences(query) +search_licences(query, all_fields = FALSE) -search_lists(query) +search_lists(query, all_fields = FALSE) -search_profiles(query) +search_media(query, all_fields = FALSE) -search_providers(query) +search_profiles(query, all_fields = FALSE) -search_ranks(query) +search_providers(query, all_fields = FALSE) -search_reasons(query) +search_ranks(query, all_fields = FALSE) -search_taxa(...) +search_reasons(query, all_fields = FALSE) + +search_taxa(..., all_fields = FALSE) } \arguments{ \item{type}{A string to specify what type of parameters should be searched.} @@ -54,8 +57,9 @@ search_taxa(...) \item{query}{A string specifying a search term. Searches are not case-sensitive.} -\item{...}{A set of strings or a tibble to be queried; see -Details.} +\item{all_fields}{\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}} If \code{TRUE}, +\code{show_values()} also returns all columns available from the API, rather +than the 'default' columns traditionally provided via galah.} } \value{ An object of class \code{tbl_df} and \code{data.frame} (aka a tibble) @@ -79,7 +83,7 @@ types of information, acting as a wrapper around many \code{search_} sub-functio See \code{Details} (below) for accepted values. } \details{ -There are five categories of information, each with their own +There are six categories of information, each with their own specific sub-functions to look-up each type of information. The available types of information for \code{search_all()} are:\tabular{llll}{ \strong{Category} \tab \strong{Type} \tab \strong{Description} \tab \strong{Sub-functions} \cr @@ -97,6 +101,7 @@ The available types of information for \code{search_all()} are:\tabular{llll}{ data providers \tab \code{providers} \tab Search for which institutions have provided data \tab \code{search_providers()} \cr \tab \code{collections} \tab Search for the specific collections within those institutions \tab \code{search_collections()} \cr \tab \code{datasets} \tab Search for the data groupings within those collections \tab \code{search_datasets()} \cr + media \tab \code{media} \tab Search for images or sounds using a vector of IDs \tab \code{search_media()} \cr } } \examples{ diff --git a/man/show_all.Rd b/man/show_all.Rd index 876cdfce..634ae782 100644 --- a/man/show_all.Rd +++ b/man/show_all.Rd @@ -16,37 +16,41 @@ \alias{show_all_reasons} \title{Show valid record information} \usage{ -show_all(..., limit = NULL) +show_all(..., limit = NULL, all_fields = FALSE) -show_all_apis(limit = NULL) +show_all_apis(limit = NULL, all_fields = FALSE) -show_all_assertions(limit = NULL) +show_all_assertions(limit = NULL, all_fields = FALSE) -show_all_atlases(limit = NULL) +show_all_atlases(limit = NULL, all_fields = FALSE) -show_all_collections(limit = NULL) +show_all_collections(limit = NULL, all_fields = FALSE) -show_all_datasets(limit = NULL) +show_all_datasets(limit = NULL, all_fields = FALSE) -show_all_fields(limit = NULL) +show_all_fields(limit = NULL, all_fields = FALSE) -show_all_licences(limit = NULL) +show_all_licences(limit = NULL, all_fields = FALSE) -show_all_lists(limit = NULL) +show_all_lists(limit = NULL, all_fields = FALSE) -show_all_profiles(limit = NULL) +show_all_profiles(limit = NULL, all_fields = FALSE) -show_all_providers(limit = NULL) +show_all_providers(limit = NULL, all_fields = FALSE) -show_all_ranks(limit = NULL) +show_all_ranks(limit = NULL, all_fields = FALSE) -show_all_reasons(limit = NULL) +show_all_reasons(limit = NULL, all_fields = FALSE) } \arguments{ \item{...}{String showing what type of information is to be requested. See \code{Details} (below) for accepted values.} \item{limit}{Optional number of values to return. Defaults to NULL, i.e. all records} + +\item{all_fields}{\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}} If \code{TRUE}, +\code{show_values()} also returns all columns available from the API, rather +than the 'default' columns traditionally provided via galah.} } \value{ An object of class \code{tbl_df} and \code{data.frame} (aka a tibble) @@ -100,6 +104,11 @@ show_all(ranks) # `show_all()` is synonymous with `request_metadata() |> collect()` request_metadata(type = "fields") |> collect() + +# using `all_fields = TRUE` is synonymous with `select(everything())` +request_metadata(type = "fields") |> + select(everything()) |> + collect() } } \references{ diff --git a/man/show_values.Rd b/man/show_values.Rd index 162f8925..b15c05b7 100644 --- a/man/show_values.Rd +++ b/man/show_values.Rd @@ -14,14 +14,17 @@ search_values(df, query) \code{\link[=search_lists]{search_lists()}}.} \item{all_fields}{\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}} If \code{TRUE}, -\code{show_values()} also returns all raw data columns -(columns included prior to the dataset's ingestion into the ALA). -For many lists, this will include raw scientific names and vernacular -names. -For conservation lists like the EPBC list, this also includes columns -containing each species' conservation status information. -Default is set to \code{FALSE}. -Currently only implemented for metadata type \code{lists}.} +\code{show_values()} also returns all columns available from the API, rather +than the 'default' columns traditionally provided via galah. + +For lists, this will include 'raw' columns; columns included prior to the +dataset's ingestion into the ALA, and will often include raw scientific +names and vernacular names. For conservation lists like the EPBC list, this +also includes columns containing each species' conservation status +information. + +For other forms of metadata, setting this to \code{TRUE} may return more +information than you want or need. Default is set to \code{FALSE}.} \item{query}{A string specifying a search term. Not case sensitive.} } From 5e995f530119d47dbae4fb72a46dd8c33d5860c2 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Mon, 20 Oct 2025 16:54:57 +1100 Subject: [PATCH 34/94] Update printing - Add new object classes to ensure correct printing of filter args - switch to `cli` rather than `cat()` for printing objects --- NAMESPACE | 8 ++ R/filter_object_classes.R | 99 +++++++++++++++++++++++ R/galah_filter.R | 8 +- R/handle_quosures.R | 21 ++--- R/handle_quosures_GBIF.R | 19 ++--- R/print.R | 144 +++++++++++++++------------------ man/filter_object_classes.Rd | 36 +++++++++ tests/testthat/_snaps/print.md | 110 +++++++++++++++++++++++++ tests/testthat/test-print.R | 61 ++++++++++++++ 9 files changed, 403 insertions(+), 103 deletions(-) create mode 100644 R/filter_object_classes.R create mode 100644 man/filter_object_classes.Rd create mode 100644 tests/testthat/_snaps/print.md create mode 100644 tests/testthat/test-print.R diff --git a/NAMESPACE b/NAMESPACE index 2a54471d..030ac5d4 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -30,10 +30,14 @@ S3method(group_by,data_request) S3method(identify,data_request) S3method(identify,metadata_request) S3method(print,computed_query) +S3method(print,data_filter) S3method(print,data_request) +S3method(print,files_filter) S3method(print,files_request) S3method(print,galah_config) +S3method(print,metadata_filter) S3method(print,metadata_request) +S3method(print,predicates_filter) S3method(print,query) S3method(print,query_set) S3method(select,data_request) @@ -43,6 +47,10 @@ S3method(slice_head,metadata_request) S3method(st_crop,data_request) export(apply_profile) export(arrange) +export(as_data_filter) +export(as_files_filter) +export(as_metadata_filter) +export(as_predicates_filter) export(as_query) export(atlas_citation) export(atlas_counts) diff --git a/R/filter_object_classes.R b/R/filter_object_classes.R new file mode 100644 index 00000000..96133fa2 --- /dev/null +++ b/R/filter_object_classes.R @@ -0,0 +1,99 @@ +#' Object classes for `filter()` queries +#' +#' In galah, there are several ways to provide filter information. To ensure +#' these are handled and printed correctly, they are assigned classes +#' @param x a list +#' @rdname filter_object_classes +#' @order 1 +#' @export +as_data_filter <- function(x){ + structure(x, class = c("data_filter", + "tbl_df", + "tbl", + "data.frame")) +} + +#' @rdname filter_object_classes +#' @order 2 +#' @export +as_predicates_filter <- function(x){ + x |> + structure(class = c("predicates_filter", + "list")) +} + +#' @rdname filter_object_classes +#' @order 3 +#' @export +as_metadata_filter <- function(x){ + x |> + structure(class = c("metadata_filter", + "tbl_df", + "tbl", + "data.frame")) +} + +#' @rdname filter_object_classes +#' @order 4 +#' @export +as_files_filter <- function(x){ + list(variable = x$variable, + data = x$data) |> + structure(class = c("files_filter", "list")) +} + + +# Print functions for the above + +#' @rdname filter_object_classes +#' @order 5 +#' @export +print.data_filter <- function(x, ...){ + filter_string <- basic_filter_print(x) + glue::glue("Object of class `data_filter`: {filter_string}") |> + cat() +} + +#' @rdname filter_object_classes +#' @order 6 +#' @export +print.predicates_filter <- function(x, ...){ + # object of class `predicates_filter` + predicates_string <- glue::glue_collapse(unlist(x), sep = " ") + glue::glue("Object of class `predictes_filter`: {predicates_string}") |> + cat() +} + +#' @rdname filter_object_classes +#' @order 7 +#' @export +print.metadata_filter <- function(x, ...){ + filter_string <- basic_filter_print(x) + glue::glue("Object of class `metadata_filter`: {filter_string}") |> + cat() +} + +#' @rdname filter_object_classes +#' @order 8 +#' @export +print.files_filter <- function(x, ...){ + glue::glue("Object of class `files_filter` with {nrow(x$data)} rows") |> + cat() +} + +#' Internal function to print filter statements +#' @noRd +#' @keywords Internal +basic_filter_print <- function(x){ + if(ncol(x) > 2){ + df <- x[, 1:3] + }else{ + df <- x + } + if(nrow(df) > 1){ + df <- df[1, ] + } + glue::glue_collapse( + apply(df, 1, function(b){paste(b, collapse = " ")}), + sep = " | ") +} \ No newline at end of file diff --git a/R/galah_filter.R b/R/galah_filter.R index d07c9075..6919cd95 100644 --- a/R/galah_filter.R +++ b/R/galah_filter.R @@ -143,8 +143,8 @@ filter.metadata_request <- function(.data, ...){ #' @noRd #' @keywords Internal parse_quosures_metadata <- function(request, dots){ - dots_parsed <- parse_quosures_files(dots) - request$filter <- dots_parsed + dots_parsed <- parse_quosures_data(dots) + request$filter <- as_metadata_filter(dots_parsed) # The `filter` argument sets `type` when specified initial_type <- request$type supplied_type <- dots_parsed$variable[1] @@ -170,8 +170,8 @@ filter.files_request <- function(.data, ...){ check_named_input(dots) dots_parsed <- parse_quosures_files(dots) check_files_filter(dots_parsed) - .data$type <- dots_parsed$variable - .data$filter <- dots_parsed$data + .data$type <- dots_parsed$variable[1] + .data$filter <- dots_parsed .data } diff --git a/R/handle_quosures.R b/R/handle_quosures.R index bccfdb2c..15a7366b 100644 --- a/R/handle_quosures.R +++ b/R/handle_quosures.R @@ -27,7 +27,7 @@ parse_quosures_data <- function(dots){ value = character(), query = character()) } - result + as_data_filter(result) } # FIXME: work out how to propagate `rlang::caller_env()` through the below functions @@ -84,15 +84,16 @@ parse_quosures_files <- function(dots, "literal" = {rlang::quo_get_expr(x)}, cli::cli_abort("Quosure type not recognised.", call = error_call)) - if(inherits(rhs, "data.frame")){ - list(variable = dequote(lhs), - data = rhs) - }else{ - tibble::tibble( - variable = dequote(lhs), - logical = "==", - value = rhs) - } + # if(inherits(rhs, "data.frame")){ + list(variable = dequote(lhs), + data = rhs) |> + as_files_filter() + # }else{ + # tibble::tibble( + # variable = dequote(lhs), + # logical = "==", + # value = rhs) + # } }else{ NULL } diff --git a/R/handle_quosures_GBIF.R b/R/handle_quosures_GBIF.R index 5bec8584..00bba92b 100644 --- a/R/handle_quosures_GBIF.R +++ b/R/handle_quosures_GBIF.R @@ -12,17 +12,18 @@ #' @keywords internal parse_quosures_data_gbif <- function(dots){ if(length(dots) > 0){ - result <- purrr::map(dots, - switch_expr_type_pred) - names(result) <- NULL # NOTE: This step is *crucial* - # without it, jsonlite::toJSON() wraps predicates in `{}` instead of `[]` - # which is then rejected by GBIF - if(length(result) > 1L){ - list(type = "and", - predicates = result) + predicates <- purrr::map(dots, + switch_expr_type_pred) + names(predicates) <- NULL # NOTE: This step is *crucial* + # without it, jsonlite::toJSON() wraps predicates in `{}` instead of `[]` + # which is then rejected by GBIF + if(length(predicates) > 1L){ + result <- list(type = "and", + predicates = result) }else{ - result + result <- predicates } + as_predicates_filter(result) }else{ NULL } diff --git a/R/print.R b/R/print.R index d4adf148..c622bce5 100644 --- a/R/print.R +++ b/R/print.R @@ -63,21 +63,22 @@ print.metadata_request <- function(x, ...){ #' @keywords Internal format_request_text <- function(x, object_type){ filled_slots <- !unlist(purrr::map(x, is.null)) - formatted_object <- galah_pink(glue::glue("`{object_type}`")) if(any(filled_slots)){ - cli::cli_inform("Object of type {formatted_object} containing:") + cli::cli_text("Object of type {galah_pink(object_type)} containing:") x_names <- names(x)[filled_slots] purrr::map(x_names, function(a){ slot_name <- galah_green(a) - slot_content <- switch_slot_text(x, a) - glue::glue("{slot_name} {galah_grey(slot_content)}") - }) |> - unlist() |> - rlang::format_error_bullets() |> - cat() + if(a == "filter"){ + slot_content <- utils::capture.output(print(x[[a]])) # calls `print.data_filter()` etc. + }else{ + slot_content <- switch_slot_text(x, a) + } + glue::glue("{slot_name} {galah_grey(slot_content)}") + }) |> + cli::cli_li() }else{ - cli::cli_inform("An empty object of type {formatted_object}") + cli::cli_text("An empty object of type {galah_pink(object_type)}") } } @@ -98,24 +99,6 @@ switch_slot_text <- function(x, a){ glue::glue("{first_col}: {first_entry}") } }, - "filter" = { - # FIXME: predicates no longer have a class, so this won't work - if(inherits(x[[a]], "galah_filter_predicate")){ - glue::glue_collapse(unlist(x[[a]]), sep = " ") # messy but functional - }else{ - if(ncol(x[[a]]) > 2){ - df <- x[[a]][, 1:3] - }else{ - df <- x[[a]] - } - if(nrow(df) > 1){ - df <- df[1, ] - } - glue::glue_collapse( - apply(df, 1, function(b){paste(b, collapse = " ")}), - sep = " | ") - } - }, "select" = x[[a]]$summary, "group_by" = glue::glue_collapse(x[[a]]$name, sep = " | "), "data_profile" ={x[[a]][1]}, @@ -133,11 +116,11 @@ print.query <- function(x, ...){ slice <- galah_pale_green(glue::glue("\n slice: {x$arrange$slice_n}")) }else{ - slice <- "" + slice <- NULL } }else{ - arrange <-"" - slice <- "" + arrange <- NULL + slice <- NULL } if(!is.null(x$url)){ if(inherits(x$url, "data.frame")){ @@ -168,7 +151,7 @@ print.query <- function(x, ...){ subtext <- galah_grey(glue::glue("\n status: {x$status[1]}")) }else{ - subtext <- "" + subtext <- NULL } if(!is.null(x$select)){ select <- galah_grey(glue::glue("\n @@ -176,15 +159,17 @@ print.query <- function(x, ...){ }else{ select <- NULL } - cat(c( - crayon::silver("Object of class"), - galah_pink("query"), - crayon::silver("with type"), - galah_green(x$type), - subtext, # note: need code for url tibbles - select, - arrange, - slice)) + + # keep only populated levels + print_list <- list(subtext, # note: need code for url tibbles + select, + arrange, + slice) + print_list <- print_list[!unlist(purrr::map(print_list, is.null))] + + # print + cli::cli_text("Object of class {galah_pink(\"query\")} with type {galah_green(x$type)}") + cli::cli_li(print_list) } #' @rdname print_galah_objects @@ -197,11 +182,11 @@ print.computed_query <- function(x, ...){ slice <- galah_pale_green(glue("\n slice: {x$arrange$slice_n}")) }else{ - slice <- "" + slice <- NULL } }else{ - arrange <-"" - slice <- "" + arrange <- NULL + slice <- NULL } if(!is.null(x$url)){ if(inherits(x$url, "data.frame")){ @@ -232,28 +217,31 @@ print.computed_query <- function(x, ...){ subtext <- galah_grey(glue::glue("\n status: {x$status[1]}")) }else{ - subtext <- "" + subtext <- NULL } - cat(c( - crayon::silver("Object of class"), - galah_pink("computed_query"), - crayon::silver("with type"), - galah_green(x$type), - subtext, # note: need code for url tibbles - arrange, - slice)) + + # keep only populated levels + print_list <- list(subtext, # note: need code for url tibbles + arrange, + slice) + print_list <- print_list[!unlist(purrr::map(print_list, is.null))] + + # print + cli::cli_text("Object of class {galah_pink(\"computed query\")} with type {galah_green(x$type)}") + cli::cli_li(print_list) } #' @rdname print_galah_objects #' @export print.query_set <- function(x, ...){ n_queries <- length(x) - message(c(crayon::silver("Object of class "), - galah_pink("`query_set` "), - crayon::silver(glue::glue("containing ")), - ifelse(n_queries > 1, - crayon::silver(glue::glue("{n_queries} queries:")), - crayon::silver("1 query:")))) + header_text <- c("Object of class ", + galah_pink("query_set "), + "containing ", + ifelse(n_queries > 1, + "{n_queries} queries:", + "1 query:")) + cli::cli_text(header_text) purrr::map(x, function(a){ type_text <- galah_green(a$type) if(!is.null(a$url)){ @@ -270,44 +258,40 @@ print.query_set <- function(x, ...){ } glue::glue("{type_text} {subtext}") }) |> - unlist() |> - rlang::format_error_bullets() |> - cat() + cli::cli_li() } #' @rdname print_galah_objects #' @export print.galah_config <- function(x, ...){ - cli::cli_inform(galah_pink("Package")) + cli::cli_par() + cli::cli_text("`galah` package configuration:") + cli::cli_end() + cli::cli_par() + cli::cli_text("{galah_pink(\"Package\")}") package_settings <- galah_green(c("verbose", "run_checks", "send_email")) package_lookup <- unlist(x$package[1:3]) |> as.integer() + 1 names(package_settings) <- c("x", "v")[package_lookup] package_settings <- c(package_settings, "i" = glue::glue("{galah_green('directory')}: {galah_grey(x$package$directory)}")) |> - rlang::format_error_bullets() |> - cat() - cat("\n") - cli::cli_inform(galah_pink("User")) - values <- c( - "{galah_green('username')} {galah_grey(hide_secrets(x$user$username))}", + cli::cli_bullets() + cli::cli_end() + cli::cli_par() + cli::cli_text("{galah_pink(\"User\")}") + c("{galah_green('username')} {galah_grey(hide_secrets(x$user$username))}", "{galah_green('email')} {galah_grey(x$user$email)}", "{galah_green('password')} {galah_grey(hide_secrets(x$user$password))}", "{galah_green('api_key')} {galah_grey(hide_secrets(x$user$api_key))}", - "{galah_green('download_reason_id')} {galah_grey(x$user$download_reason_id)}") - password_settings <- purrr::map(values, - function(a, x){glue::glue_data(x, a)}, - x = x) |> - unlist() |> - rlang::format_error_bullets() |> - cat() - cat("\n") - cli::cli_inform(galah_pink("Atlas")) + "{galah_green('download_reason_id')} {galah_grey(x$user$download_reason_id)}") |> + cli::cli_bullets() + cli::cli_end() + cli::cli_par() + cli::cli_text("{galah_pink(\"Atlas\")}") atlas_text <- galah_green(x$atlas$organisation) atlas_subtext <- galah_grey(glue::glue("({x$atlas$acronym}), {x$atlas$region}")) - atlas_settings <- glue::glue("{atlas_text} {atlas_subtext}") |> - rlang::format_error_bullets() |> - cat() + cli::cli_bullets("{atlas_text} {atlas_subtext}") + cli::cli_end() } #' Internal function to prevent showing secret information in the console diff --git a/man/filter_object_classes.Rd b/man/filter_object_classes.Rd new file mode 100644 index 00000000..d81a0a4a --- /dev/null +++ b/man/filter_object_classes.Rd @@ -0,0 +1,36 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/filter_object_classes.R +\name{as_data_filter} +\alias{as_data_filter} +\alias{as_predicates_filter} +\alias{as_metadata_filter} +\alias{as_files_filter} +\alias{print.data_filter} +\alias{print.predicates_filter} +\alias{print.metadata_filter} +\alias{print.files_filter} +\title{Object classes for \code{filter()} queries} +\usage{ +as_data_filter(x) + +as_predicates_filter(x) + +as_metadata_filter(x) + +as_files_filter(x) + +\method{print}{data_filter}(x, ...) + +\method{print}{predicates_filter}(x, ...) + +\method{print}{metadata_filter}(x, ...) + +\method{print}{files_filter}(x, ...) +} +\arguments{ +\item{x}{a list} +} +\description{ +In galah, there are several ways to provide filter information. To ensure +these are handled and printed correctly, they are assigned classes +} diff --git a/tests/testthat/_snaps/print.md b/tests/testthat/_snaps/print.md new file mode 100644 index 00000000..89656403 --- /dev/null +++ b/tests/testthat/_snaps/print.md @@ -0,0 +1,110 @@ +# object of class `data-request` formats correctly + + Code + request_data() + Message + Object of type data_request containing: + * type occurrences + +# populated `data_request()` prints correctly + + Code + filter(identify(galah_call(), "crinia"), year == 2025) + Message + Object of type data_request containing: + * type occurrences + * identify search_term: crinia + * filter Object of class `data_filter`: year == 2025 + +# object of class `metadata-request` formats correctly + + Code + request_data() + Message + Object of type data_request containing: + * type occurrences + +# object of class `metadata-request` formats correctly with `filter() + + Code + filter(request_metadata(), list == "dr650") + Message + Object of type metadata_request containing: + * type lists + * filter Object of class `metadata_filter`: list == dr650 + +# object of class `metadata-request` formats correctly with `identify() + + Code + identify(request_metadata(), "Crinia") + Message + Object of type metadata_request containing: + * type taxa + * identify search_term: Crinia + +# object of class `metadata-request` formats correctly with `identify() |> unnest() + + Code + unnest(identify(request_metadata(), "Crinia")) + Message + Object of type metadata_request containing: + * type taxa-unnest + * identify search_term: Crinia + +# object of class `query` formats correctly + + Code + as_query(identify(request_metadata(), "Crinia")) + Message + Object of class query with type metadata/taxa-single + * url: https://api.ala.org.au/namematching/api/search?q=Crinia + * select: ~tidyselect::any_of(~c("search_term", "scientific_name", + "scientific_name_authorship", "taxon_concept_id", "taxon_concept_lsid", + "authority", "usage_key", "guid", "canonical_name", "status", "rank", + "match_type", "confidence", "time_taken", "vernacular_name", "issues", + "kingdom", "phylum", "class", "order", "family", "genus", "species")) + +# object of class `computed_query` formats correctly + + Code + x + Message + Object of class computed query with type metadata/taxa-single + * url: https://api.ala.org.au/namematching/api/search?q=Crinia + +# object of class `query_set` formats correctly + + Code + coalesce(filter(galah_call(), basisOfRecord == "HUMAN_OBSERVATION")) + Message + Object of class query_set containing 4 queries: + * metadata/fields url: https://api.ala.org.au/occurrences/index/fields + * metadata/assertions url: https://api.ala.org.au/occurrences/assertions/codes + * metadata/reasons url: https://api.ala.org.au/logger/service/logger/reasons + * data/occurrences url: + https://api.ala.org.au/occurrences/occurrences/offline/... + +# `galah_config()` formats correctly + + Code + galah_config() + Message + `galah` package configuration: + + Package + v verbose + v run_checks + x send_email + i directory: /var/folders/47/2_32ylzx64qgpyqlh7zkd_5h0000gn/T//RtmpauoXVz + + User + username [Not Provided] + email + password [Not Provided] + api_key [Not Provided] + download_reason_id 4 + + Atlas + Atlas of Living Australia (ALA), Australia + + diff --git a/tests/testthat/test-print.R b/tests/testthat/test-print.R new file mode 100644 index 00000000..80fe41cb --- /dev/null +++ b/tests/testthat/test-print.R @@ -0,0 +1,61 @@ +test_that("object of class `data-request` formats correctly", { + expect_snapshot(request_data()) +}) + +test_that("populated `data_request()` prints correctly", { + galah_call() |> + identify("crinia") |> + filter(year == 2025) |> + expect_snapshot() +}) + +test_that("object of class `metadata-request` formats correctly", { + request_data() |> + expect_snapshot() +}) + +test_that("object of class `metadata-request` formats correctly with `filter()", { + request_metadata() |> + filter(list == "dr650") |> + expect_snapshot() +}) + +test_that("object of class `metadata-request` formats correctly with `identify()", { + request_metadata() |> + identify("Crinia") |> + expect_snapshot() +}) + +test_that("object of class `metadata-request` formats correctly with `identify() |> unnest()", { + request_metadata() |> + identify("Crinia") |> + unnest() |> + expect_snapshot() +}) + +test_that("object of class `query` formats correctly", { + request_metadata() |> + identify("Crinia") |> + as_query() |> + expect_snapshot() +}) + +test_that("object of class `computed_query` formats correctly", { + x <- request_metadata() |> + identify("Crinia") |> + as_query() + class(x) <- c("computed_query", "list") + expect_snapshot(x) +}) + +test_that("object of class `query_set` formats correctly", { + galah_call() |> + filter(basisOfRecord == "HUMAN_OBSERVATION") |> + coalesce() |> + expect_snapshot() +}) + +test_that("`galah_config()` formats correctly", { + galah_config() |> + expect_snapshot() +}) \ No newline at end of file From d30e39dd0763701b96f2ec836156750f9a67b7f8 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Mon, 20 Oct 2025 16:59:10 +1100 Subject: [PATCH 35/94] Update `atlas_media()` to support `all_fields()` (#266) - swap to GET media metadata API as contains more useful information, doesn't require pagination - enforce metadata queries for media require a vector, not a tibble - update file download workflow --- R/as_query-files.R | 63 ++++++++++++++++++++---------- R/as_query-metadata.R | 36 +++++++++-------- R/as_query-occurrences.R | 2 +- R/as_query.R | 2 +- R/atlas_media.R | 24 ++++++++---- R/check.R | 18 +++++++-- R/collect_metadata.R | 5 ++- R/sysdata.rda | Bin 6455 -> 6429 bytes R/utilities_internal.R | 11 ++---- data-raw/2_internal_data.R | 11 ++++++ data-raw/node_config.csv | 4 +- man/as_query.data_request.Rd | 2 +- man/atlas_.Rd | 7 +++- tests/testthat/test-atlas_media.R | 38 ++++++++++++------ 14 files changed, 146 insertions(+), 77 deletions(-) diff --git a/R/as_query-files.R b/R/as_query-files.R index 0b4d0ab2..4bbfa239 100644 --- a/R/as_query-files.R +++ b/R/as_query-files.R @@ -8,10 +8,15 @@ as_query_media_files <- function(.query, ){ # handle filters if(is.null(.query$filter)){ - cli::cli_abort("`collapse()` requires a `filter()` argument to function.", + cli::cli_abort("`This function requires a `filter()` argument", + call = error_call) + }else if(!inherits(.query$filter, "files_filter")){ + cli::cli_abort("Downloading media files requires a `filter()` query to work", call = error_call) } - df <- .query$filter + df <- .query$filter$data + + # find unique identifiers anywhere you can if(any(colnames(df) == "media_id")){ identifiers <- df$media_id }else if(any(colnames(df) == "image_id")){ @@ -20,26 +25,41 @@ as_query_media_files <- function(.query, cli::cli_abort("No valid identifiers found in supplied data.", call = error_call) } - path <- build_file_path(ids = identifiers, types = df$mimetype) - if(any(colnames(df) == "image_url")){ - url <- df$image_url - }else{ - url <- url_lookup("files/images", - id = identifiers) - } - # handle thumbnails - if(thumbnail){ - url <- gsub("/original", "/thumbnail", url) - } + + # create output + list(type = "files/media", + url = build_media_tibble(identifiers, + df$mime_type, + thumbnail), + headers = build_headers()) |> + as_query() - # create result - result <- list( - type = "files/media", - url = tibble::tibble(url = url, path = path), - headers = build_headers()) +} + +#' Internal function to create API urls AND file paths +#' @noRd +#' @keywords Internal +build_media_tibble <- function(identifiers, + mimetype, + thumbnail = FALSE){ + # create a `size` vector + # this is complicated because thumbnails fail for sounds + size_vec <- rep( + ifelse(thumbnail, "thumbnail", "original"), + length(identifiers)) + image_lookup <- mimetype %in% c("image/jpg", "image/jpeg", "image/png") + if(any(!image_lookup) & isTRUE(thumbnail)){ + size_vec[!image_lookup] <- "original" + } - class(result) <- "query" - return(result) + # return a result + tibble::tibble( + url = url_lookup("files/media", + id = identifiers, + size = size_vec), + path = build_file_path(ids = identifiers, + types = mimetype) + ) } #' build file paths that include 1. path, 2. file name, 3. correct extension @@ -60,4 +80,5 @@ build_file_path <- function(ids, types){ "audio/vnd.wave" ~ "wav") glue::glue("{path}/{ids}.{ext}") |> as.character() -} \ No newline at end of file +} + diff --git a/R/as_query-metadata.R b/R/as_query-metadata.R index 185691fe..2151c603 100644 --- a/R/as_query-metadata.R +++ b/R/as_query-metadata.R @@ -225,25 +225,27 @@ as_query_media_metadata <- function(.query, cli::cli_abort("Requests for metadata of type = \"media\" must have information passed via `filter()`", call = error_call) } - occ <- .query$filter$data - if(any(colnames(occ) %in% c("images", "videos", "sounds"))){ # Australia, Sweden, Spain - media_cols <- which(colnames(occ) %in% c("images", "videos", "sounds")) - media_ids <- do.call(c, occ[, media_cols]) |> - unlist() - media_ids <- media_ids[!is.na(media_ids)] - names(media_ids) <- NULL - }else if(any(colnames(occ) == "all_image_url")){ # Austria, Sweden, UK - media_ids <- dplyr::pull(occ, "all_image_url") - media_ids <- media_ids[!is.na(media_ids)] - names(media_ids) <- NULL - }else{ - cli::cli_abort("Media metadata not found in supplied tibble", - call = error_call) - } + + ## Move this to `atlas_media()` + # occ <- .query$filter$data + # if(any(colnames(occ) %in% c("images", "videos", "sounds"))){ # Australia, Sweden, Spain + # media_cols <- which(colnames(occ) %in% c("images", "videos", "sounds")) + # media_ids <- do.call(c, occ[, media_cols]) |> + # unlist() + # media_ids <- media_ids[!is.na(media_ids)] + # names(media_ids) <- NULL + # }else if(any(colnames(occ) == "all_image_url")){ # Austria, Sweden, UK + # media_ids <- dplyr::pull(occ, "all_image_url") + # media_ids <- media_ids[!is.na(media_ids)] + # names(media_ids) <- NULL + # }else{ + # cli::cli_abort("Media metadata not found in supplied tibble", + # call = error_call) + # } list(type = "metadata/media", - url = url_lookup("metadata/media"), + url = tibble::tibble(url = url_lookup("metadata/media", + id= .query$filter$value)), headers = build_headers(), - body = jsonlite::toJSON(list(imageIds = media_ids)), filter = .query$filter) |> enforce_select_query(supplied_query = .query) |> as_query() diff --git a/R/as_query-occurrences.R b/R/as_query-occurrences.R index 288de81e..4249885d 100644 --- a/R/as_query-occurrences.R +++ b/R/as_query-occurrences.R @@ -88,7 +88,7 @@ as_query_occurrences_la <- function(.query, mint_doi = FALSE){ # set default columns if(is.null(.query$select)){ - .query$select <- galah_select(group = "basic") + .query <- .query |> select(group = "basic") } # build a query query <- c(build_query(identify = .query$identify, diff --git a/R/as_query.R b/R/as_query.R index 9251c385..80f77cdc 100644 --- a/R/as_query.R +++ b/R/as_query.R @@ -119,7 +119,7 @@ as_query.metadata_request <- function(x, ...){ #' @order 5 #' @export as_query.files_request <- function(x, - thumbnail, + thumbnail = FALSE, ...){ # NOTE: switch is technically superfluous right now, but could be useful # for future file types diff --git a/R/atlas_media.R b/R/atlas_media.R index 785e8233..e9c2b658 100644 --- a/R/atlas_media.R +++ b/R/atlas_media.R @@ -1,12 +1,16 @@ #' @rdname atlas_ #' @order 4 +#' @param all_fields `r lifecycle::badge("experimental")` If `TRUE`, +#' `show_values()` also returns all columns available from the media metadata +#' API, rather than the 'default' columns traditionally provided via galah. #' @export atlas_media <- function(request = NULL, identify = NULL, filter = NULL, select = NULL, geolocate = NULL, - data_profile = NULL + data_profile = NULL, + all_fields = FALSE ) { # check media is available @@ -25,8 +29,8 @@ atlas_media <- function(request = NULL, # ensure media columns are present in `select` if(is.null(.query$select)){ - .query <- update_request_object(.query, - select = galah_select(group = c("basic", "media"))) + .query <- .query |> + dplyr::select(group = c("basic", "media")) present_fields <- image_fields() present_fields <- present_fields[present_fields != "multimedia"] # check these fields for Spain query_collapse <- collapse(.query) @@ -78,11 +82,15 @@ atlas_media <- function(request = NULL, if(!any(colnames(occ) == "all_image_url")){ occ$media_id <- build_media_id(occ) } - + # collect media metadata - media <- request_metadata() |> - filter(media == occ) |> - collect() + media_query <- request_metadata() |> + filter(media == dplyr::pull(occ, "media_id")) + if(isTRUE(all_fields)){ + media_query <- media_query |> + dplyr::select(tidyselect::everything()) + } + media <- collect(media_query) # join and return if(any(colnames(occ) == "all_image_url")){ @@ -90,7 +98,7 @@ atlas_media <- function(request = NULL, } occ_media <- dplyr::right_join(occ, media, - by = dplyr::join_by("media_id" == "image_id")) + by = dplyr::join_by("media_id")) dplyr::relocate(occ_media, "media_id", 1) } diff --git a/R/check.R b/R/check.R index 15a4366e..ce8eeace 100644 --- a/R/check.R +++ b/R/check.R @@ -102,12 +102,22 @@ check_email <- function(.query, #' Check files are filtered properly #' @noRd #' @keywords Internal -check_files_filter <- function(x){ +check_files_filter <- function(x, + error_call = rlang::caller_env() + ){ if(!(x$variable %in% c("media"))){ - cli::cli_abort("Variable name must be a valid `type` accepted by `request_files()`.") + cli::cli_abort("Variable name must be a valid `type` accepted by `request_files()`.", + call = error_call) + } + if(is.null(x$data)){ + cli::cli_abort(c("rhs must be a `tibble` containing media information", + i = "at least, this tibble should contain `media_id` and `mime_type` columns"), + call = error_call) } if(!inherits(x$data, "data.frame")){ - cli::cli_abort("rhs must be a `tibble` containing media information") + cli::cli_abort(c("rhs must be a `tibble` containing media information", + i = "at least, this tibble should contain `media_id` and `mime_type` columns"), + call = error_call) } } @@ -117,7 +127,7 @@ check_files_filter <- function(x){ check_filter_tibbles <- function(x, # where x is a list of tibbles error_call = rlang::caller_env() ){ - syntax_valid <- lapply(x, function(a){ + syntax_valid <- purrr::map(x, \(a){ if(length(colnames(a)) == 4){ all(colnames(a) %in% c("variable", "logical", "value", "query")) }else{ diff --git a/R/collect_metadata.R b/R/collect_metadata.R index 6acb491e..82d652b3 100644 --- a/R/collect_metadata.R +++ b/R/collect_metadata.R @@ -342,7 +342,8 @@ collect_media_metadata <- function(.query, result <- query_API(.query) # ensure list-columns are imported correctly - result_df <- purrr::map(result, tidy_list_columns) |> + result_df <- result |> + purrr::map(tidy_list_columns) |> dplyr::bind_rows() # Select only the information we want @@ -350,7 +351,7 @@ collect_media_metadata <- function(.query, result_df |> dplyr::rename_with(camel_to_snake_case) |> parse_rename(.query) |> - dplyr::filter(!is.na(.data$image_id)) |> + dplyr::filter(!is.na(.data$media_id)) |> parse_select(.query) } diff --git a/R/sysdata.rda b/R/sysdata.rda index fe6d4cc949baa2a5782da71af378123a7b08bd29..9b898400a8098ecd8cef62b2684fe41009a73d9d 100644 GIT binary patch delta 6426 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 identify("Microseris lanceolata") |> filter(year == 2019) |> @@ -35,6 +35,16 @@ test_that("`atlas_media()` works", { expect_s3_class(media_data, c("tbl_df", "tbl", "data.frame")) expect_gte(nrow(media_data), 3) expect_gte(ncol(media_data), 3) + # set `all_fields` = TRUE + all_media_data <- galah_call() |> + identify("Microseris lanceolata") |> + filter(year == 2019) |> + quiet_media(all_fields = TRUE) + # test for `original_file_name` field (as per issue #266) + expect_gt(ncol(all_media_data), + ncol(media_data)) + any(colnames(all_media_data) == "original_file_name") |> + expect_true() }) test_that("collect_media suggests `galah_config(directory =)` when a temp folder is set as the directory", { @@ -70,7 +80,7 @@ test_that("`collapse()` and `collect()` work for `type = 'media'`", { } ## PART 1: request occurrence data - purrr_config(email = "ala4r@ala.org.au") + capture_config <- purrr_config(email = "ala4r@ala.org.au") occ_collect <- request_data() |> identify("Litoria peronii") |> filter(year == 2010, !is.na(images)) |> @@ -80,7 +90,7 @@ test_that("`collapse()` and `collect()` work for `type = 'media'`", { ## PART 2: request media metadata # collapse media_collapse <- request_metadata() |> - filter(media == occ_collect) |> + filter(media == unlist(dplyr::pull(occ_collect, "images"))) |> quiet_collapse() expect_true(inherits(media_collapse, "query")) expect_equal(length(media_collapse), 5) @@ -88,8 +98,8 @@ test_that("`collapse()` and `collect()` work for `type = 'media'`", { c("type", "url", "headers", - "body", - "filter")) + "filter", + "select")) expect_true(media_collapse$type == "metadata/media") # compute media_compute <- quiet_compute(media_collapse) @@ -99,8 +109,8 @@ test_that("`collapse()` and `collect()` work for `type = 'media'`", { c("type", "url", "headers", - "body", - "filter")) + "filter", + "select")) # collect media_collect <- quiet_collect(media_compute) expect_s3_class(media_collect, c("tbl_df", "tbl", "data.frame")) @@ -112,7 +122,7 @@ test_that("`collapse()` and `collect()` work for `type = 'media'`", { media_dir <- "test_media" unlink(media_dir, recursive = TRUE) dir.create(media_dir) - purrr_config(directory = media_dir) + capture_config <- purrr_config(directory = media_dir) # collapse df <- slice_head(media_collect, n = 3) files_collapse <- request_files() |> @@ -179,18 +189,22 @@ test_that("collect_media handles different file formats", { filter(year == 2024) |> quiet_media() # sample one of each multimedia type to shorten testing time - media_data <- media_data |> + media_summary <- media_data |> dplyr::group_by(multimedia) |> dplyr::sample_n(size = 1) expect_equal(sort(unique(media_data$multimedia)), c("Image", "Image | Sound", "Sound")) - result <- purrr_collect_media(media_data, thumbnail = TRUE) + result <- purrr_collect_media(media_summary, thumbnail = TRUE) downloads <- list.files(path = media_dir) expect_true(any(grepl(".mpg$", downloads))) # sounds expect_true(any(grepl(".jpg$", downloads))) # images - file_count <- length(list.files(media_dir)) - # expect_equal(file_count, nrow(media_data)) # FIXME correct n + file_count <- length(list.files(media_dir)) unlink(media_dir, recursive = TRUE) + # FIXME: we don't test that files are 'valid'; i.e. that images load + # properly. This is important for sound files which may not load properly if + # thumbnail settings are ignored. + + # also worth testing that `thumbnail` is ignored for sounds }) test_that("collect_media handles thumbnails", { From b07dbf7b3c73ecd2539ffae73d7a1274771017a5 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Thu, 23 Oct 2025 10:17:16 +1100 Subject: [PATCH 36/94] update onload to use `packageStartupMessage()` instead of `cli::cli_inform()` --- R/onload.R | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/R/onload.R b/R/onload.R index 1215d509..06b66570 100644 --- a/R/onload.R +++ b/R/onload.R @@ -29,13 +29,13 @@ # see ?packageStartupMessage (required by `check()`) c( glue::glue("galah version {galah_version}"), - i = cli::col_magenta('galah is currently configured to query {current_node} ({current_url}).'), - i = cli::col_magenta('You can see all supported organisations with `show_all(atlases)`.'), - i = cli::col_magenta('To change organisations, use e.g. `galah_config(atlas = \"GBIF\")`.') + "\n", # note: glue wipes newlines, so have to be outside + cli::col_magenta(glue::glue('This package is currently configured to query {current_node} ({current_url}).\n')), + "\n", + i = cli::col_magenta('- You can change this at any time using e.g. `galah_config(atlas = \"GBIF\")`.'), + "\n", + i = cli::col_magenta('- To see all supported organisations, run `show_all(atlases)`.') ) |> - cli::cli_inform(class = c("packageStartupMessage", - "simpleMessage", - "message", - "condition")) + packageStartupMessage() } } From 6d0734bdec5f29932d91e009d06e1713535f5c88 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Fri, 24 Oct 2025 16:11:33 +1100 Subject: [PATCH 37/94] Miscellaneous bug fixes detected using `test()` Following functions pass tests: - all `atlas_` functions - all `galah_` functions - show_all(), search_all(), show_values(), metadata_request() etc International atlases not tested yet --- R/as_query-species.R | 10 ++--- R/atlas_citation.R | 19 ++++----- R/atlas_taxonomy.R | 14 ++---- R/check.R | 29 +++++++------ R/collapse.R | 2 +- R/collapse_checks.R | 15 ++++--- R/{collapse_query.R => collapse_query_set.R} | 6 +-- R/collect_metadata.R | 6 +-- R/collect_taxa.R | 6 +-- R/compute.R | 3 +- R/filter_object_classes.R | 13 +++--- R/galah_filter.R | 3 +- R/galah_select.R | 12 +++--- R/print.R | 15 ++++++- R/utilities_internal.R | 3 +- tests/testthat/test-atlas_citation.R | 12 +++++- tests/testthat/test-atlas_media.R | 2 +- tests/testthat/test-atlas_species.R | 45 ++++++++++++++------ tests/testthat/test-atlas_taxonomy.R | 6 +-- tests/testthat/test-count_arrange_slice.R | 27 +++++++----- tests/testthat/test-galah_filter.R | 8 +--- tests/testthat/test-galah_select.R | 25 ++++++----- tests/testthat/test-show_all.R | 24 ++++++++--- 23 files changed, 181 insertions(+), 124 deletions(-) rename R/{collapse_query.R => collapse_query_set.R} (94%) diff --git a/R/as_query-species.R b/R/as_query-species.R index 5fde8b33..2083741f 100644 --- a/R/as_query-species.R +++ b/R/as_query-species.R @@ -18,7 +18,7 @@ as_query_species <- function(.query){ as_query_species_atlas <- function(.query){ # set default columns if(is.null(.query$select)){ - .query$select <- galah_select(group = "taxonomy") + .query <- .query |> select(group = "taxonomy") } # determine whether to use `group_by` or `species_facets()` @@ -61,15 +61,13 @@ as_query_species_atlas <- function(.query){ #' @keywords Internal parse_select_species <- function(.select){ # parse labels for supplied field names - quosure_check <- purrr::map(.select, rlang::is_quosure) |> - unlist() - if(any(quosure_check)){ - named_fields <- purrr::map(.select[quosure_check], - rlang::as_label) |> + if(length(.select$quosure) > 0){ + named_fields <- purrr::map(.select$quosure, rlang::as_label) |> unlist() }else{ named_fields <- NULL } + # create output result <- list( lookup = "false", diff --git a/R/atlas_citation.R b/R/atlas_citation.R index be45f5aa..26df89a4 100644 --- a/R/atlas_citation.R +++ b/R/atlas_citation.R @@ -17,7 +17,6 @@ #' atlas_citation(x) #' } #' @export - atlas_citation <- function(data) { # get basic information from file modified_date <- attributes(data)$modified_date @@ -34,15 +33,15 @@ atlas_citation <- function(data) { r_citation <- citation() |> print(style = 'text') |> utils::capture.output() |> - glue_collapse(sep = " ") + glue::glue_collapse(sep = " ") galah_citation <- c("Westgate M, Kellie D, Stevenson M & Newman P (2025):", "_galah: Biodiversity Data from the GBIF Node Network_.", - "R package version 2.1.1.", + "R package version 2.2.0.", "doi: 10.32614/CRAN.package.galah") |> - glue_collapse(sep = " ") + glue::glue_collapse(sep = " ") # ask users to cite galah and R - suffix_text <- glue(" + suffix_text <- glue::glue(" Please consider citing R & galah, in addition to your dataset: @@ -54,8 +53,8 @@ atlas_citation <- function(data) { # set case when DOI is missing if(!is.null(doi)) { # ALA - if(grepl("10.26197/ala.", doi)){ - result <- glue(" + if(stringr::str_detect(doi, "10.26197/ala.")){ + result <- glue::glue(" The citation for this dataset is: Atlas of Living Australia ({modified_date}) Occurrence download {doi} @@ -66,8 +65,8 @@ atlas_citation <- function(data) { cli::cli_text(result) invisible(result) # GBIF - }else if(grepl("10.15468/dl.", doi)){ - result <- glue(" + }else if(stringr::str_detect(doi, "10.15468/dl.")){ + result <- glue::glue(" The citation for this dataset is: GBIF.org ({modified_date}) GBIF Occurrence Download {doi} @@ -88,7 +87,7 @@ atlas_citation <- function(data) { } }else{ if(!is.null(citation)){ - result <- glue(" + result <- glue::glue(" The citation for this dataset is: {citation} diff --git a/R/atlas_taxonomy.R b/R/atlas_taxonomy.R index 46ef85af..055ed731 100644 --- a/R/atlas_taxonomy.R +++ b/R/atlas_taxonomy.R @@ -86,16 +86,10 @@ drill_down_taxonomy <- function(df, if(nrow(children) < 1){ return(df) }else{ - result <- children |> - dplyr::mutate(name = stringr::str_to_title(children$name), - taxon_concept_id = children$guid, - parent_taxon_concept_id = children$parentGuid) |> - dplyr::select("name", - "rank", - "taxon_concept_id", - "parent_taxon_concept_id") - if(!is.null(constrain_ids)){ - result <- result |> + if(is.null(constrain_ids)){ + result <- children + }else{ + result <- children |> constrain_id(constrain_to = constrain_ids) } if(nrow(result) < 1){ diff --git a/R/check.R b/R/check.R index ce8eeace..803b76e8 100644 --- a/R/check.R +++ b/R/check.R @@ -372,14 +372,15 @@ check_groups <- function(group, n){ #' function to replace search terms with identifiers via `search_taxa()` #' @noRd #' @keywords Internal -check_identifiers <- function(.query){ +check_identifiers <- function(.query, + error_call = rlang::caller_env()){ # For GBIF, which uses predicates, we 'promote' taxonomic queries to 'predicates' if(is_gbif()){ .query$body$identify <- .query$`metadata/taxa-single` .query # otherwise we replace "(`TAXON_PLACEHOLDER`)" }else{ - check_identifiers_la(.query) + check_identifiers_la(.query, error_call) } } @@ -644,7 +645,7 @@ check_profiles <- function(.query, query <- httr2::url_parse(.query$url[1])$query if(!is.null(query$qualityProfile)){ profile <- query$qualityProfile - if(!profile %in% .query[["metadata/profiles"]]$shortName){ + if(!profile %in% .query[["metadata/profiles"]]$short_name){ c("Unrecognised profile requested.", i = "See `?show_all(profiles)` for valid profiles.", x = "Can't find profile `{profile}` for specified atlas.") |> @@ -723,16 +724,20 @@ check_select <- function(.query, }else{ group_names <- NULL } - + # 3. parse quosures to get list of field names - check_quosures <- purrr::map(.query$select, rlang::is_quosure) |> - unlist() - dots <- .query$select[check_quosures] - dot_names <- purrr::map(dots, function(a){ - tidyselect::eval_select(a, data = df) |> - names() - }) |> - unlist() + if(length(.query$select$quosure) > 0){ + dot_names <- purrr::map(.query$select$quosure, + function(a){ + tidyselect::eval_select(a, + data = df, + error_call = error_call) |> + names() + }) |> + unlist() + }else{ + dot_names <- c() + } # 3a: set 'identifier' column name id_col <- default_columns()[1] diff --git a/R/collapse.R b/R/collapse.R index e03c68f8..2be6f675 100644 --- a/R/collapse.R +++ b/R/collapse.R @@ -70,7 +70,7 @@ collapse.query_set <- function(x, ...){ x |> collapse_build_checks() |> collapse_run_checks() |> - collapse_query() + collapse_query_set() } } diff --git a/R/collapse_checks.R b/R/collapse_checks.R index a434bbca..3e8d9957 100644 --- a/R/collapse_checks.R +++ b/R/collapse_checks.R @@ -40,19 +40,20 @@ collapse_build_checks <- function(.query){ #' called by `collapse()` #' @noRd #' @keywords Internal -collapse_run_checks <- function(.query){ +collapse_run_checks <- function(.query, + error_call = rlang::caller_env()){ # "data/" functions require pre-processing of metadata, if(stringr::str_detect(.query$type, "^data/")){ # some checks should happen regardless of `run_checks` .query <- .query |> - check_login() |> - check_identifiers() |> - check_select() + check_login(error_call) |> + check_identifiers(error_call) |> + check_select(error_call) if(potions::pour("package", "run_checks")) { .query <- .query |> - check_reason() |> - check_fields() |> - check_profiles() + check_reason(error_call) |> + check_fields(error_call) |> + check_profiles(error_call) } # as do `unnest()`/`show_values()` functions }else if(stringr::str_detect(.query$type, "-unnest$")){ diff --git a/R/collapse_query.R b/R/collapse_query_set.R similarity index 94% rename from R/collapse_query.R rename to R/collapse_query_set.R index 59d1b367..c1a77abe 100644 --- a/R/collapse_query.R +++ b/R/collapse_query_set.R @@ -2,8 +2,8 @@ #' @param x a `query_set` #' @noRd #' @keywords Internal -collapse_query <- function(x, - error_call = rlang::caller_env()){ +collapse_query_set <- function(x, + error_call = rlang::caller_env()){ switch(x$type, "data/occurrences" = collapse_occurrences(x), "data/occurrences-count" = { @@ -25,7 +25,7 @@ collapse_query <- function(x, purrr::pluck("query") |> names() if(length(which(query_names == "facets")) > 1){ - collapse_occurrences_count_atlas_groupby_crossed(x) + collapse_occurrences_count_atlas_groupby_crossed(x) }else{ collapse_occurrences_count_atlas_basic(x) } diff --git a/R/collect_metadata.R b/R/collect_metadata.R index 82d652b3..38f2a406 100644 --- a/R/collect_metadata.R +++ b/R/collect_metadata.R @@ -127,7 +127,7 @@ collect_collections <- function(.query){ result <- purrr::pluck(result, "results") } result_df <- result |> - tidy_list_columns() |> + purrr::map(tidy_list_columns) |> dplyr::bind_rows() # Then France }else if(potions::pour("atlas", "region", .pkg = "galah") == "France"){ @@ -168,7 +168,7 @@ collect_datasets <- function(.query){ result <- purrr::pluck(result, "results") } result_df <- result |> - tidy_list_columns() |> + purrr::map(tidy_list_columns) |> dplyr::bind_rows() }else if(potions::pour("atlas", "region", .pkg = "galah") == "France"){ result_df <- result |> @@ -388,7 +388,7 @@ collect_providers <- function(.query){ result <- purrr::pluck(result, "results") } result_df <- result |> - tidy_list_columns() |> + purrr::map(tidy_list_columns) |> dplyr::bind_rows() }else if(potions::pour("atlas", "region", .pkg = "galah") == "France"){ result_df <- tibble::tibble(name = { diff --git a/R/collect_taxa.R b/R/collect_taxa.R index 29ff8264..5efbd111 100644 --- a/R/collect_taxa.R +++ b/R/collect_taxa.R @@ -181,20 +181,20 @@ clean_la_taxa <- function(result, search_terms){ collect_identifiers <- function(.query){ search_terms <- .query$url$search_term result <- query_API(.query) |> - tidy_list_columns() |> + purrr::map(tidy_list_columns) |> dplyr::bind_rows() if(any(colnames(result) == "taxonConceptID")){ result <- result |> dplyr::filter(!duplicated(result$taxonConceptID)) } - + if(!any(colnames(result) == "success")){ # GBIF doesn't indicate success # we avoid `is_gbif()` here because other atlases use GBIF APIs result$success <- TRUE result <- result |> dplyr::relocate(success, .before = 1) |> - dplyr::rename("taxonConceptID" = "key") + parse_rename(.query) } result <- result |> diff --git a/R/compute.R b/R/compute.R index fd794909..50cf2d43 100644 --- a/R/compute.R +++ b/R/compute.R @@ -63,6 +63,5 @@ compute.query <- function(x, ...){ #' @noRd #' @keywords Internal as_computed_query <- function(x){ - class(x) <- "computed_query" - x + structure(x, class = "computed_query") } diff --git a/R/filter_object_classes.R b/R/filter_object_classes.R index 96133fa2..0d182978 100644 --- a/R/filter_object_classes.R +++ b/R/filter_object_classes.R @@ -28,18 +28,16 @@ as_predicates_filter <- function(x){ as_metadata_filter <- function(x){ x |> structure(class = c("metadata_filter", - "tbl_df", - "tbl", - "data.frame")) + "list")) } #' @rdname filter_object_classes #' @order 4 #' @export as_files_filter <- function(x){ - list(variable = x$variable, - data = x$data) |> - structure(class = c("files_filter", "list")) + x |> + structure(class = c("files_filter", + "list")) } @@ -68,8 +66,7 @@ print.predicates_filter <- function(x, ...){ #' @order 7 #' @export print.metadata_filter <- function(x, ...){ - filter_string <- basic_filter_print(x) - glue::glue("Object of class `metadata_filter`: {filter_string}") |> + glue::glue("Object of class `metadata_filter` with type `{x$variable}` (n = {length(x$data)} entries)") |> cat() } diff --git a/R/galah_filter.R b/R/galah_filter.R index 6919cd95..aa78e50c 100644 --- a/R/galah_filter.R +++ b/R/galah_filter.R @@ -143,7 +143,8 @@ filter.metadata_request <- function(.data, ...){ #' @noRd #' @keywords Internal parse_quosures_metadata <- function(request, dots){ - dots_parsed <- parse_quosures_data(dots) + dots_parsed <- parse_quosures_files(dots) + names(dots_parsed)[2] <- "value" request$filter <- as_metadata_filter(dots_parsed) # The `filter` argument sets `type` when specified initial_type <- request$type diff --git a/R/galah_select.R b/R/galah_select.R index c3d5275b..ca2eaca0 100644 --- a/R/galah_select.R +++ b/R/galah_select.R @@ -165,14 +165,16 @@ galah_select <- function(..., group){ NULL } }else{ - # dots <- dots |> - # add_summary() |> # FIXME: this *will* break rn - # add_group(group) if(inherits(dots[[1]], "data_request")){ + list(quosure = dots[-1], + summary = generate_summary(dots[-1])) |> + add_group(group) |> update_request_object(dots[[1]], - select = dots[-1]) + select = _) }else{ - dots + list(quosure = dots, + summary = generate_summary(dots)) |> + add_group(group) } } } diff --git a/R/print.R b/R/print.R index c622bce5..d80abcd6 100644 --- a/R/print.R +++ b/R/print.R @@ -175,6 +175,7 @@ print.query <- function(x, ...){ #' @rdname print_galah_objects #' @export print.computed_query <- function(x, ...){ + # calculate arrange/slice info if(!is.null(x$arrange)){ arrange <- galah_pale_green(glue("\n arrange: {x$arrange$variable} ({x$arrange$direction})")) @@ -188,6 +189,8 @@ print.computed_query <- function(x, ...){ arrange <- NULL slice <- NULL } + + # add url if(!is.null(x$url)){ if(inherits(x$url, "data.frame")){ url_temp <- x$url$url[1] @@ -220,8 +223,18 @@ print.computed_query <- function(x, ...){ subtext <- NULL } + # add a status ID + if(!is.null(x$status_url)){ + split_url <- strsplit(x$status_url, "\\/")[[1]] + id <- galah_grey(glue::glue("\n + id: {split_url[[length(split_url)]]}")) + }else{ + id <- NULL + } + # keep only populated levels - print_list <- list(subtext, # note: need code for url tibbles + print_list <- list(id, + subtext, # note: need code for url tibbles arrange, slice) print_list <- print_list[!unlist(purrr::map(print_list, is.null))] diff --git a/R/utilities_internal.R b/R/utilities_internal.R index 1b47e24f..e82db4d3 100644 --- a/R/utilities_internal.R +++ b/R/utilities_internal.R @@ -20,7 +20,7 @@ enforce_select_query <- function(new_query, supplied_query){ if(is.null(chosen_columns) & stringr::str_detect(specific_type, "-unnest$")){ chosen_columns <- supplied_query$filter |> - dplyr::pull(value) + purrr::pluck("value") } # if we have, after 2 attempts, found some chosen_columns, use them if(!is.null(chosen_columns)){ @@ -187,6 +187,7 @@ lookup_select_columns_taxa <- function(){ lookup_rename_columns <- function(type){ switch(type, "assertions" = c("id" = "name"), + "identifiers" = c("taxonConceptID" = "key"), "lists" = c("species_list_uid" = "data_resource_uid"), "lists-unnest" = c("taxon_concept_id" = "lsid"), "media" = c("media_id" = "image_identifier"), diff --git a/tests/testthat/test-atlas_citation.R b/tests/testthat/test-atlas_citation.R index c0811451..bd296599 100644 --- a/tests/testthat/test-atlas_citation.R +++ b/tests/testthat/test-atlas_citation.R @@ -37,17 +37,25 @@ test_that("atlas_citation attributes GBIF DOIs correctly", { expect_true(grepl("GBIF Occurrence Download", citation)) }) +quiet_collect <- function(x){ + quiet_fun <- purrr::quietly(collect.data_request) + quiet_fun(x) |> + purrr::pluck("result") +} + test_that("atlas_citation works on a real download", { skip_if_offline(); skip_on_ci() galah_config(email = "ala4r@ala.org.au") x <- galah_call() |> identify("Heleioporus") |> filter(year == 2022) |> - collect() + quiet_collect() text_out <- atlas_citation(x) |> suppressMessages() grepl("^The citation for this dataset is:", text_out) |> expect_true() grepl("Please consider citing R & galah", text_out) |> expect_true() -}) \ No newline at end of file +}) + +rm(quiet_collect) \ No newline at end of file diff --git a/tests/testthat/test-atlas_media.R b/tests/testthat/test-atlas_media.R index 903fa14f..7b195b65 100644 --- a/tests/testthat/test-atlas_media.R +++ b/tests/testthat/test-atlas_media.R @@ -220,7 +220,7 @@ test_that("collect_media handles thumbnails", { result <- purrr_collect_media(z) result |> purrr::pluck("messages") |> - stringr::str_detect("Downloaded 31 files successfully") |> + stringr::str_detect("Downloaded [[:digit:]]+ files successfully") |> any() |> expect_true() downloads <- list.files(path = media_dir) diff --git a/tests/testthat/test-atlas_species.R b/tests/testthat/test-atlas_species.R index bac61d5e..71bcd841 100644 --- a/tests/testthat/test-atlas_species.R +++ b/tests/testthat/test-atlas_species.R @@ -1,13 +1,28 @@ +quiet_collect <- function(x){ + quiet_fun <- purrr::quietly(dplyr::collect) + quiet_fun(x) |> + purrr::pluck("result") +} + +quiet_species <- function(...){ + quiet_fun <- purrr::quietly(atlas_species) + quiet_fun(...) |> + purrr::pluck("result") +} + test_that("atlas_species fails nicely if no email is provided", { skip_if_offline(); skip_on_ci() galah_config(email = "", run_checks = TRUE) # run_checks = FALSE doesn't provide error message - expect_error(atlas_species(identify = galah_identify("Osphranter"))) + galah_call() |> + identify("Osphranter") |> + atlas_species() |> + expect_error() galah_config(email = "ala4r@ala.org.au") }) test_that("`atlas_species()` returns a tibble", { skip_if_offline(); skip_on_ci() - species <- atlas_species(identify = galah_identify("Osphranter")) + species <- quiet_species(identify = galah_identify("Osphranter")) expect_s3_class(species, c("tbl_df", "tbl", "data.frame")) expect_gt(nrow(species), 1) }) @@ -17,7 +32,7 @@ test_that("`select()` works for type = 'species' with no arguments", { x <- galah_call(type = "species") |> identify("Crinia") |> select() |> - collect() + quiet_collect() expect_equal(colnames(x), "taxon_concept_id") expect_gt(nrow(x), 10) }) @@ -27,7 +42,7 @@ test_that("`select()` works for type = 'species' with `counts`", { x <- galah_call(type = "species") |> identify("Crinia") |> select(counts) |> - collect() + quiet_collect() expect_equal(colnames(x), c("taxon_concept_id", "count")) expect_gt(nrow(x), 10) }) @@ -38,7 +53,7 @@ test_that("`select()` works for type = 'species' with group = 'taxonomy'", { x <- galah_call(type = "species") |> identify("Crinia") |> select(counts, lists, group = "taxonomy") |> - collect() + quiet_collect() expect_true(all(c("taxon_concept_id", "count", "kingdom", "phylum") %in% colnames(x))) expect_gt(nrow(x), 10) }) @@ -49,7 +64,7 @@ test_that("`atlas_species()` returns correct results when piped", { species <- galah_call() |> identify("perameles") |> filter(year > 2000) |> - atlas_species() + quiet_species() expected_species <- c("Perameles nasuta", "Perameles gunnii", "Perameles fasciata", @@ -74,7 +89,7 @@ test_that("`atlas_species()` returns correct results filtered by galah_geolocate identify("perameles") |> filter(year > 2000) |> geolocate(wkt) |> - atlas_species() + quiet_species() expected_species <- c("Perameles gunnii") expected_cols <- c("taxon_concept_id", "species_name", "scientific_name_authorship", "taxon_rank", @@ -93,7 +108,7 @@ test_that("`atlas_species()` works when no species are present", { result <- galah_call() |> identify("eolophus") |> filter(cl1048 == "Kimberley") |> - atlas_species() + quiet_species() expect_s3_class(result, c("tbl_df", "tbl", "data.frame")) }) @@ -105,8 +120,8 @@ test_that("collapse -> compute -> collect workflow is functional", { filter(year > 2000) species_collapse <- query |> collapse() species_compute <- species_collapse |> compute() - species_collect <- species_compute |> collect() - atlas_species <- query |> atlas_species() + species_collect <- species_compute |> quiet_collect() + atlas_species <- query |> quiet_species() expect_s3_class(query, "data_request") expect_s3_class(species_collapse, "query") @@ -135,7 +150,7 @@ test_that("atlas_species reformats column names when empty tibble is returned", species <- galah_call() |> identify("sarcopterygii") |> filter(cl1048 == "Wet Tropics") |> - atlas_species() + quiet_species() expected_cols <- c("taxon_concept_id", "species_name", "scientific_name_authorship", "taxon_rank", "kingdom", "phylum", "class", "order", "family", @@ -153,18 +168,20 @@ test_that("`group_by()` works on occurrences", { filter(year == 2024, genus == "Crinia") |> group_by(speciesID) |> - collect() + quiet_collect() y <- galah_call() |> filter(year == 2024, genus == "Crinia") |> - atlas_species() + quiet_species() expect_equal(x, y) # try with a different variable z <- galah_call() |> filter(year == 2024, genus == "Crinia") |> group_by(genusID) |> - collect() + quiet_collect() expect_true(inherits(z, c("tbl_df", "tbl", "data.frame"))) expect_equal(colnames(z)[1], "taxon_concept_id") }) + +rm(quiet_collect, quiet_species) \ No newline at end of file diff --git a/tests/testthat/test-atlas_taxonomy.R b/tests/testthat/test-atlas_taxonomy.R index e7dea4f6..6da51990 100644 --- a/tests/testthat/test-atlas_taxonomy.R +++ b/tests/testthat/test-atlas_taxonomy.R @@ -23,7 +23,7 @@ test_that("`atlas_taxonomy()` requires a single taxon", { }) }) -test_that("`atlas_taxonomy()` makes a tree when piped", { +test_that("`atlas_taxonomy()` makes a `tibble` when piped", { skip_if_offline(); skip_on_ci() tree <- galah_call() |> identify("fungi") |> @@ -37,8 +37,8 @@ test_that("`atlas_taxonomy()` makes a tree when piped", { test_that("`atlas_taxonomy()` example runs", { skip_if_offline(); skip_on_ci() df <- galah_call() |> - galah_identify("chordata") |> - galah_filter(rank == class) |> + identify("chordata") |> + filter(rank == class) |> atlas_taxonomy() expect_s3_class(df, c("tbl_df", "tbl", "data.frame")) expect_equal(ncol(df), 4) diff --git a/tests/testthat/test-count_arrange_slice.R b/tests/testthat/test-count_arrange_slice.R index 5238b98e..cc0a69fa 100644 --- a/tests/testthat/test-count_arrange_slice.R +++ b/tests/testthat/test-count_arrange_slice.R @@ -1,10 +1,16 @@ +quiet_collect <- function(x){ + quiet_fun <- purrr::quietly(dplyr::collect) + quiet_fun(x) |> + purrr::pluck("result") +} + test_that("default is to arrange by decending order of count", { skip_if_offline(); skip_on_ci() result <- galah_call() |> filter(year >= 2015) |> group_by(year) |> count() |> - collect() + quiet_collect() expect_true(all(diff(result$count) < 0)) expect_true(nrow(result) > 7) expect_equal(ncol(result), 2) @@ -18,7 +24,7 @@ test_that("arrange in increasing order of count", { group_by(year) |> count() |> arrange(count) |> - collect() + quiet_collect() expect_true(all(diff(result$count) > 0)) expect_true(nrow(result) > 7) expect_equal(ncol(result), 2) @@ -32,7 +38,7 @@ test_that("arrange in decreasing order of count using `desc()`", { group_by(year) |> count() |> arrange(desc(count)) |> - collect() + quiet_collect() expect_true(all(diff(result$count) < 0)) expect_true(nrow(result) > 7) expect_equal(ncol(result), 2) @@ -46,7 +52,7 @@ test_that("arrange in increasing order of year", { group_by(year) |> count() |> arrange(year) |> - collect() + quiet_collect() expect_true(all(diff(as.integer(result$year)) == 1)) expect_true(nrow(result) > 7) expect_equal(ncol(result), 2) @@ -60,7 +66,7 @@ test_that("arrange in decreasing order of year using `desc()`", { group_by(year) |> count() |> arrange(desc(year)) |> - collect() + quiet_collect() expect_true(all(diff(as.integer(result$year)) == -1)) expect_true(nrow(result) > 7) expect_equal(ncol(result), 2) @@ -76,7 +82,7 @@ test_that("`arrange()` by `count` and `slice_head()` work together", { count() |> arrange(count) |> slice_head(n = 5) |> - collect() + quiet_collect() expect_true(all(diff(result$count) > 0)) expect_equal(nrow(result), 5) expect_equal(ncol(result), 2) @@ -88,7 +94,7 @@ test_that("`arrange()` by `count` and `slice_head()` work together", { count() |> arrange(desc(count)) |> slice_head(n = 5) |> - collect() + quiet_collect() expect_true(all(diff(result2$count) < 0)) expect_equal(nrow(result), 5) expect_equal(ncol(result), 2) @@ -106,7 +112,7 @@ test_that("`arrange()` by `year` and `slice_head()` work together", { count() |> arrange(year) |> slice_head(n = 5) |> - collect() + quiet_collect() expect_true(all(diff(as.integer(result$year)) == 1)) expect_equal(result$year[[1]], "2015") expect_equal(nrow(result), 5) @@ -119,7 +125,7 @@ test_that("`arrange()` by `year` and `slice_head()` work together", { count() |> arrange(desc(year)) |> slice_head(n = 5) |> - collect() + quiet_collect() expect_true(all(diff(as.integer(result2$year)) == -1)) expect_false(result2$year[[1]] == "2015") expect_equal(nrow(result), 5) @@ -137,9 +143,10 @@ test_that("`group_by()` with multiple fields works with `slice_head()`", { count() |> arrange(desc(count)) |> # NOTE: desc(count) applied within groups only; is this correct? slice_head(n = 5) |> # same issue as above - collect() + quiet_collect() expect_equal(ncol(result), 3) expect_equal(colnames(result), c("year", "basisOfRecord", "count")) expect_true(all(xtabs(~result$year) <= 5)) # current year may not have all values yet }) +rm(quiet_collect) \ No newline at end of file diff --git a/tests/testthat/test-galah_filter.R b/tests/testthat/test-galah_filter.R index 29c3d415..082a57e8 100644 --- a/tests/testthat/test-galah_filter.R +++ b/tests/testthat/test-galah_filter.R @@ -392,9 +392,7 @@ test_that("`filter() handles `method = 'metadata'` correctly", { expect_s3_class(x, "metadata_request") expect_equal(length(x), 2) expect_equal(names(x), c("type", "filter")) - expect_equal(colnames(x$filter), c("variable", "logical", "value")) - y <- request_metadata() |> filter(field == cl22) - expect_equal(x, y) + expect_equal(names(x$filter), c("variable", "value")) }) test_that("`filter() handles `method = 'files'` correctly", { @@ -406,9 +404,7 @@ test_that("`filter() handles `method = 'files'` correctly", { expect_s3_class(y, "files_request") expect_equal(length(y), 2) expect_equal(names(y), c("type", "filter")) - expect_equal(y$filter, x) - z <- request_files() |> filter(media == x) - expect_equal(y, z) + expect_equal(y$filter$data, x) }) rm(purrr_collect) \ No newline at end of file diff --git a/tests/testthat/test-galah_select.R b/tests/testthat/test-galah_select.R index 94ef7ebe..e9024f2b 100644 --- a/tests/testthat/test-galah_select.R +++ b/tests/testthat/test-galah_select.R @@ -6,12 +6,14 @@ quiet_collect <- function(x){ } quiet_occurrences <- purrr::quietly(atlas_occurrences) +config_capture <- galah_config(email = "ala4r@ala.org.au", + run_checks = TRUE) + test_that("`select.data_request()` adds content to a `data_request` object", { x <- galah_call() |> select(group = "basic") - expect_equal(x$select, - list(summary = "group = basic", - group = "basic")) + expect_equal(names(x$select), + c("quosure", "summary", "group")) }) test_that("`galah_select()` doesn't return error when columns don't exist", { @@ -19,20 +21,20 @@ test_that("`galah_select()` doesn't return error when columns don't exist", { expect_no_error(galah_select(year, basisOfRecord, eventdate)) }) -test_that("`select()` triggers error during `compute()` when columns don't exist", { +test_that("`select()` triggers error during `collapse()` when columns don't exist", { skip_if_offline(); skip_on_ci() expect_error( galah_call() |> identify("perameles") |> filter(year == 2003) |> - galah_select(basisOfRecors) |> - compute()) + select(basisOfRecors) |> + collapse()) expect_error( galah_call() |> identify("perameles") |> filter(year == 2003) |> select(year, basisOfRecors, eventdate) |> - compute()) + collapse()) }) test_that("`select()` builds expected columns when group = basic", { @@ -186,8 +188,11 @@ test_that("`select()` can use `tidyselect::last_col()` & group", { test_that("`select()` warns for invalid field names when type = 'species'", { skip_if_offline(); skip_on_ci() - expect_warning({galah_call(type = "species") |> + expect_warning({galah_call() |> identify("Crinia") |> + group_by(speciesID) |> select(an_unrecognised_field_name) |> - collapse()}) -}) \ No newline at end of file + as_query()}) +}) + +rm(quiet_collect, quiet_occurrences, config_capture) diff --git a/tests/testthat/test-show_all.R b/tests/testthat/test-show_all.R index 15e78bca..76d27667 100644 --- a/tests/testthat/test-show_all.R +++ b/tests/testthat/test-show_all.R @@ -14,7 +14,7 @@ test_that("show_all parses ... correctly", { expect_equal(fields1, fields2) }) -test_that("all show_all() functions return correctly with all syntax", { +test_that("all `show_all()` functions return correctly with all syntax", { skip_if_offline(); skip_on_ci() valid_types <- c("apis", "assertions", @@ -23,23 +23,37 @@ test_that("all show_all() functions return correctly with all syntax", { "datasets", "fields", "licences", - "lists", "profiles", "providers", "ranks", "reasons") - invisible(lapply(valid_types, function(a){ + invisible(purrr::map(valid_types, function(a){ syntax1 <- paste0("show_all_", a) |> do.call(args = list()) # e.g. show_all_fields() + limit_test <- paste0("show_all_", a) |> + do.call(args = list(limit = 3)) syntax2 <- paste0("show_all(", a, ")") |> parse(text = _) |> eval() # e.g. show_all(fields) - limit_test <- paste0("show_all_", a) |> - do.call(args = list(limit = 3)) expect_s3_class(syntax1, c("tbl_df", "tbl", "data.frame")) expect_equal(attributes(syntax1)$call, a) expect_equal(attributes(syntax1)$region, "Australia") expect_equal(syntax1, syntax2) expect_equal(nrow(limit_test), 3) })) +}) + +# lists queries are noisy, so run separately +test_that("`show_all_lists()` functions work correctly", { + quiet_lists <- function(...){ + list_fun <- purrr::quietly(show_all_lists) + list_fun(...) |> + purrr::pluck("result") + } + syntax1 <- quiet_lists() + limit_test <- quiet_lists(limit = 3) + expect_s3_class(syntax1, c("tbl_df", "tbl", "data.frame")) + expect_equal(attributes(syntax1)$call, "lists") + expect_equal(attributes(syntax1)$region, "Australia") + expect_equal(nrow(limit_test), 3) }) \ No newline at end of file From 7d93f5eea35166a851ca19f2adea556ae443cf25 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Tue, 28 Oct 2025 17:04:20 +1100 Subject: [PATCH 38/94] First pass at supporting authentication via JWT (#189) - new metadata type ("config") to give `client_id`, relevant urls & scopes - `galah_config()` now accepts `authenticate` argument (FALSE by default). When activated, this triggers `show_all_config()`, which caches the relevant info as per other metadata queries - `query_API()` checks whether authentication is requested, and if so, triggers `httr2::req_oauth_auth_code` - JWT tokens are cached on disk by `httr2` so they can be preserved across sessions Some misc bug fixes added along the way --- .gitignore | 2 + NAMESPACE | 1 + R/as_query-metadata.R | 16 ++++- R/as_query.R | 1 + R/check.R | 7 +- R/check_queue.R | 11 ++- R/coalesce.R | 88 ++++++++++++++--------- R/collapse_checks.R | 7 +- R/collapse_occurrences_count_atlas.R | 4 +- R/collect.R | 1 + R/collect_metadata.R | 27 ++++++- R/collect_occurrences.R | 10 ++- R/galah_call.R | 1 + R/galah_config.R | 55 +++++++++++--- R/galah_radius.R | 3 +- R/handle_quosures.R | 2 +- R/print.R | 17 +++-- R/query_API.R | 55 ++++++++++++-- R/read_zip.R | 8 ++- R/search_all.R | 3 +- R/show_all.R | 9 +++ R/sysdata.rda | Bin 6429 -> 6441 bytes R/tidyverse.R | 2 +- R/url_lookup.R | 9 ++- R/utilities_caching.R | 3 +- R/utilities_internal.R | 6 +- data-raw/node_config.csv | 33 +++++---- man/galah_call.Rd | 6 +- man/show_all.Rd | 4 ++ tests/testthat/_snaps/print.md | 6 +- tests/testthat/test-authentication.R | 103 +++++++++++++++++++++++++++ tests/testthat/test-galah_config.R | 5 ++ 32 files changed, 394 insertions(+), 111 deletions(-) create mode 100644 tests/testthat/test-authentication.R diff --git a/.gitignore b/.gitignore index 04e7b6ad..fcb38c10 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ docs/* /Meta/ docs SECRETS.txt +.secure-credentials +/TEST-SENSITIVE-DATA \ No newline at end of file diff --git a/NAMESPACE b/NAMESPACE index 030ac5d4..ae0007c0 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -107,6 +107,7 @@ export(show_all_apis) export(show_all_assertions) export(show_all_atlases) export(show_all_collections) +export(show_all_config) export(show_all_datasets) export(show_all_fields) export(show_all_licences) diff --git a/R/as_query-metadata.R b/R/as_query-metadata.R index 2151c603..e44e77c7 100644 --- a/R/as_query-metadata.R +++ b/R/as_query-metadata.R @@ -105,6 +105,20 @@ as_query_collections <- function(x){ # NOTE: LA collectory functions do not accept `max` or `offset` # Therefore they cannot be paginated. GBIF collectory funs can. +#' Internal function to create an auth-config query +#' @noRd +#' @keywords Internal +as_query_config <- function(x){ + query_type <- "metadata/config" + if(check_if_cache_update_needed("config")){ + result <- default_query(query_type) + }else{ + result <- default_cache(query_type) + } + result |> + as_query() +} + #' Internal function to create a datasets query #' @noRd #' @keywords Internal @@ -132,8 +146,6 @@ as_query_datasets <- function(x){ } #' Internal function to create a fields query -#' Note that this is inconsistent with `show_all_fields()` which returns data -#' from multiple APIs #' @noRd #' @keywords Internal as_query_fields <- function(x){ diff --git a/R/as_query.R b/R/as_query.R index 80f77cdc..3b46f4ac 100644 --- a/R/as_query.R +++ b/R/as_query.R @@ -92,6 +92,7 @@ as_query.metadata_request <- function(x, ...){ "assertions" = as_query_assertions(x), "atlases" = as_query_atlases(x), "collections" = as_query_collections(x), + "config" = as_query_config(x), "datasets" = as_query_datasets(x), "distributions" = as_query_distributions_metadata(x), "fields" = as_query_fields(x), diff --git a/R/check.R b/R/check.R index 803b76e8..88a29783 100644 --- a/R/check.R +++ b/R/check.R @@ -404,7 +404,8 @@ check_identifiers_la <- function(.query, # End query early when no taxonomic search terms were matched if (nrow(identifiers) > 0 && !("taxon_concept_id" %in% colnames(identifiers))) { - abort("No valid taxonomic identifiers detected.", call = error_call) + cli::cli_abort("No valid taxonomic identifiers detected.", + call = error_call) } taxa_ids <- build_taxa_query(identifiers$taxon_concept_id) @@ -590,7 +591,9 @@ check_occurrence_response <- function(.query, #' @noRd #' @keywords Internal check_occurrence_status <- function(.query){ - list(url = .query$status_url) |> + list(type = "data/occurrences", + url = .query$status_url) |> + as_query() |> query_API() |> as.list() |> check_occurrence_response() diff --git a/R/check_queue.R b/R/check_queue.R index 682c317c..e0d59851 100644 --- a/R/check_queue.R +++ b/R/check_queue.R @@ -5,13 +5,12 @@ check_queue <- function(.query, wait = FALSE){ # process supplied object if(.query$status == "incomplete"){ download_response <- c(list(type = .query$type), - check_occurrence_status(.query)) - class(download_response) <- "computed_query" + check_occurrence_status(.query)) |> + structure(class = "computed_query") if(wait){ - download_response <- c(list(type = .query$type), - check_queue_loop(.query)) - class(download_response) <- "computed_query" - download_response + c(list(type = .query$type), + check_queue_loop(.query)) |> + structure(class = "computed_query") }else{ download_response } diff --git a/R/coalesce.R b/R/coalesce.R index f8dd99da..bae7cba0 100644 --- a/R/coalesce.R +++ b/R/coalesce.R @@ -46,14 +46,20 @@ coalesce.data_request <- function(x, mint_doi, ...){ #' @order 3 #' @export coalesce.metadata_request <- function(x, ...){ + + # create an empty object to store results + result <- list() + + # add checks if required if(potions::pour("package", "run_checks")){ - result <- switch(x$type, - "fields-unnest" = list(request_metadata("fields") |> as_query()), - "profiles-unnest" = list(request_metadata("profiles") |> as_query()), - list()) - }else{ - result <- list() + result <- append(result, + switch(x$type, + "fields-unnest" = list(request_metadata("fields") |> as_query()), + "profiles-unnest" = list(request_metadata("profiles") |> as_query()), + NULL)) } + + # then handle `filter` and `identify` queries, where supported if(grepl("-unnest$", x$type)){ if(x$type == "taxa-unnest"){ # identify() calls must be parsed, irrespective of `run_checks` (which is parsed above) @@ -68,14 +74,18 @@ coalesce.metadata_request <- function(x, ...){ cli::cli_abort("Requests of type `{current_type}` must supply `filter()`.") } } + + # lists have extra steps if(x$type == "lists-unnest"){ query_obj <- as_query_lists_unnest(x, ...) }else{ query_obj <- as_query(x) } result[[(length(result) + 1)]] <- query_obj - class(result) <- "query_set" - result + + # return object of correct class + structure(result, + class = "query_set") } #' @rdname coalesce @@ -85,11 +95,10 @@ coalesce.files_request <- function(x, ...){ # NOTE: switch is technically superfluous right now, but could be useful # for future file types - result <- list(switch(x$type, + list(switch(x$type, "media" = as_query_media_files(x, ...) - )) - class(result) <- "query_set" - result + )) |> + structure(class = "query_set") } #' Internal function to build a `query_set` object @@ -97,10 +106,12 @@ coalesce.files_request <- function(x, #' @noRd #' @keywords Internal build_query_set_data <- function(x, mint_doi, ...){ + + # handle DOIs if(!missing(mint_doi)){ x$mint_doi <- mint_doi } - # x$type <- check_type(x$type) # needed? + # handle sending dois via `filter()` # important this happens first, as it affects `type`, which affects later code variables <- x$filter$variable # NOTE: breaks for GBIF @@ -109,43 +120,48 @@ build_query_set_data <- function(x, mint_doi, ...){ x$type <- "occurrences-doi" } } + + # set up an object + result <- list() + # handle `run_checks` fields_absent <- purrr::map( x[c("arrange", "filter", "select", "group_by")], - is.null - ) |> + is.null) |> unlist() - if (potions::pour("package", "run_checks") & + + if(potions::pour("package", "run_checks") & x$type != "occurrences-doi"){ # add check here to see whether any filters are specified # it is possible to only call `identify()`, for example - if (any(!fields_absent) | + if(any(!fields_absent) | x$type %in% c("species-count", "species")) { - result <- list(request_metadata("fields") |> as_query(), - request_metadata("assertions") |> as_query()) - } else { + result <- c(result, + list(request_metadata("fields") |> as_query(), + request_metadata("assertions") |> as_query())) + }else{ # for living atlases, we need `collapse_fields()` to check the `lsid` field # this isn't required for GBIF which doesn't use `fq` for taxon queries - if(!is.null(x$identify) &!is_gbif()){ - result <- list(request_metadata("fields") |> as_query()) - }else{ - result <- list() + if(!is.null(x$identify) & + !is_gbif()){ + result <- c(result, + list(request_metadata("fields") |> as_query())) } } - if (x$type %in% c("occurrences", "media", "species") & - reasons_supported()) { + if(x$type %in% c("occurrences", "media", "species") & + reasons_supported()) { result[[(length(result) + 1)]] <- request_metadata("reasons") |> as_query() } - } else { # if select is required, we need fields even if `run_checks == FALSE` + }else{ # if select is required, we need fields even if `run_checks == FALSE` if(!fields_absent[["select"]] | x$type %in% c("occurrences", "species")){ - result <- list(request_metadata("fields") |> as_query(), - request_metadata("assertions") |> as_query()) - }else{ - result <- list() + result <- c(result, + list(request_metadata("fields") |> as_query(), + request_metadata("assertions") |> as_query())) } } + # handle `identify()` if(!is.null(x$identify) & x$type != "occurrences-doi"){ @@ -153,15 +169,18 @@ build_query_set_data <- function(x, mint_doi, ...){ identify(x$identify) |> as_query() } + # handle `apply_profile()` if(!is.null(x$data_profile)){ result[[(length(result) + 1)]] <- request_metadata("profiles") |> as_query() } + # handle query result[[(length(result) + 1)]] <- as_query(x) - class(result) <- "query_set" - result + + # return + structure(result, class = "query_set") } #' Internal function to build a `query_set` object @@ -186,6 +205,5 @@ build_query_set_distributions <- function(x, ...){ result <- list(as_query_distributions_data(x)) } } - class(result) <- "query_set" - result + structure(result, class = "query_set") } \ No newline at end of file diff --git a/R/collapse_checks.R b/R/collapse_checks.R index 3e8d9957..9d2fa647 100644 --- a/R/collapse_checks.R +++ b/R/collapse_checks.R @@ -5,9 +5,10 @@ collapse_build_checks <- function(.query){ # get basic description of `query_set` object n <- length(.query) - names_vec <- unlist(purrr::map(.query, function(a){a$type})) - # look for any `data` - data_lookup <- grepl("^data", names_vec) + names_vec <- purrr::map(.query, + \(a){purrr::pluck(a, "type")}) |> + unlist() + data_lookup <- stringr::str_detect(names_vec, "^data") if(any(data_lookup)){ data_names <- names_vec[data_lookup] # parse any `metadata` diff --git a/R/collapse_occurrences_count_atlas.R b/R/collapse_occurrences_count_atlas.R index 038368fb..3259cb0a 100644 --- a/R/collapse_occurrences_count_atlas.R +++ b/R/collapse_occurrences_count_atlas.R @@ -47,7 +47,7 @@ collapse_occurrences_count_atlas_basic <- function(.query){ #' @noRd #' @keywords Internal collapse_occurrences_count_atlas_groupby_crossed <- function(.query, - error_call = caller_env()){ + error_call = rlang::caller_env()){ data_cached <- .query # get url url <- httr2::url_parse(.query$url) @@ -170,7 +170,7 @@ collapse_occurrences_count_atlas_groupby_crossed <- function(.query, #' @keywords Internal check_facet_count <- function(.query, warn = TRUE, - error_call = caller_env()){ + error_call = rlang::caller_env()){ url <- httr2::url_parse(.query$url) current_limit <- url$query$flimit diff --git a/R/collect.R b/R/collect.R index 27b2d438..591af91f 100644 --- a/R/collect.R +++ b/R/collect.R @@ -83,6 +83,7 @@ collect.computed_query <- function(x, "metadata/assertions" = collect_assertions(x), "metadata/atlases" = collect_atlases(x), "metadata/collections" = collect_collections(x), + "metadata/config" = collect_config(x), "metadata/datasets" = collect_datasets(x), "metadata/distributions" = collect_distributions_metadata(x), "metadata/fields" = collect_fields(x), diff --git a/R/collect_metadata.R b/R/collect_metadata.R index 38f2a406..48fff72e 100644 --- a/R/collect_metadata.R +++ b/R/collect_metadata.R @@ -154,6 +154,31 @@ collect_collections <- function(.query){ parse_select(result_df, .query) } +#' Internal function to `collect()` config +#' @returns a tibble containing 4 columns (strings) and one row: +#' - `client_id` +#' - `client_secret` (tbd) +#' - `authorize_url` +#' - `token_url` +#' - `scopes` +#' @details +#' This is the *ONLY* function in galah that doesn't call `query_API()`, +#' for the simple reason that it is sometimes called _inside_ that function, +#' and we dont' want recursive queries here. +#' @noRd +#' @keywords Internal +collect_config <- function(.query){ + if(!is.null(.query$data)){ + result <- retrieve_internal_data(.query) + }else{ + result <- query_API(.query) |> + tibble::as_tibble() |> + update_attributes(type = "config") + update_cache(config = result) + } + result +} + #' Internal function to `collect()` datasets #' @noRd #' @keywords Internal @@ -433,8 +458,8 @@ collect_reasons <- function(.query){ dplyr::bind_rows() result_df <- result |> dplyr::filter(!.data$deprecated) |> - parse_arrange() |> dplyr::relocate("id", "name") |> + parse_arrange() |> update_attributes(type = "reasons") update_cache(reasons = result_df) } diff --git a/R/collect_occurrences.R b/R/collect_occurrences.R index 357ac0d4..d62a48f0 100644 --- a/R/collect_occurrences.R +++ b/R/collect_occurrences.R @@ -58,10 +58,14 @@ collect_occurrences_default <- function(.query, wait, file, call){ } # sometimes lookup info critical, but not others - unclear when/why! if(any(names(download_response) == "download_url")){ - new_object <- list(url = download_response$download_url, - download = TRUE) - new_object$file <- check_download_filename(file) + new_object <- list(type = "data/occurrences", + url = download_response$download_url, + download = TRUE, + file = check_download_filename(file)) |> + as_query() + # run downloads query_API(new_object) + # import result <- read_zip(new_object$file) }else{ return(download_response) diff --git a/R/galah_call.R b/R/galah_call.R index 261918b4..9f112a1a 100644 --- a/R/galah_call.R +++ b/R/galah_call.R @@ -161,6 +161,7 @@ request_metadata <- function(type = c("fields", "assertions", "atlases", "collections", + "config", "datasets", # "distributions", "licences", diff --git a/R/galah_config.R b/R/galah_config.R index 3f5eac92..c464a2fc 100644 --- a/R/galah_config.R +++ b/R/galah_config.R @@ -96,22 +96,53 @@ galah_config <- function(...) { # check all values in dots to ensure they are valid result <- restructure_config(dots) + + # look up what information has been given, write specific callouts for unusual cases + supplied_names <- names(result) + + # if authentication is requested, cache config info + # NOTE: This is the only place in galah where we _silently_ query + # an API. For safety and clarity reasons, I've added the following steps: + # 1. giving some notice to the user that this has been performed + # 2. adding a warning message if the API call fails + if(any(supplied_names == "authenticate")){ + if(isTRUE(result$authenticate) & # value set to TRUE by user + is.null(retrieve_cache("config")) # not already cached + ){ + cli::cli_inform("Caching `config` information to support authentication") + config <- request_metadata(type = "config") |> + collect() |> + try(silent = TRUE) + if(inherits(config, "try-error")){ + c("`galah_config()` tried caching `config` information for authentication purposes, but failed.", + i = "This could mean you are offline or that the API is unavailable.", + i = "To try again, call `show_all_config()` or `galah_config(authenticate = TRUE)`") |> + cli::cli_warn() + } + } + } # add to `potions` object - if(any(names(result) == "atlas")){ + if(any(supplied_names == "atlas")){ potions::brew(atlas = list(atlas = result$atlas)) result <- result[names(result) != "atlas"] - result$atlas_config_called_by_user <- TRUE } if(length(result) > 0){ potions::brew(result, method = "leaves") } + + # invisibly return + x <- potions::pour() + structure(x, + class = c("galah_config", "list")) |> + invisible() }else{ + # visibly return x <- potions::pour() - class(x) <- c("galah_config", "list") - return(x) + structure(x, + class = c("galah_config", "list")) } } @@ -124,12 +155,11 @@ default_config <- function(){ verbose = TRUE, run_checks = TRUE, send_email = FALSE, - directory = tempdir(), - atlas_config_called_by_user = FALSE), + authenticate = FALSE, + directory = tempdir()), user = list( username = "", email = "", - api_key = "", password = "", download_reason_id = 4), atlas = list( @@ -145,7 +175,9 @@ default_config <- function(){ #' @keywords Internal restructure_config <- function(dots){ result <- purrr::map(names(dots), - \(a){validate_config(a, dots[[a]])}) + \(a){validate_config(a, + dots[[a]], + error_call = error_call)}) names(result) <- names(dots) result } @@ -156,13 +188,13 @@ restructure_config <- function(dots){ validate_config <- function(name, value, error_call = rlang::caller_env()) { - result <- switch(name, - "api_key" = enforce_character(value), + result <- switch(name, "atlas" = { value <- configure_atlas(value) # see whether atlases have changed, and if so, give a message check_atlas(potions::pour("atlas"), value) }, + "authenticate" = enforce_logical(value), "caching" = enforce_logical(value), "directory" = check_directory(value), "download_reason_id" = enforce_download_reason(value), @@ -172,7 +204,8 @@ validate_config <- function(name, "send_email" = enforce_logical(value), "username" = enforce_character(value), "verbose" = enforce_logical(value), - enforce_invalid_name(name)) + enforce_invalid_name(name, + error_call = error_call)) result } diff --git a/R/galah_radius.R b/R/galah_radius.R index f105f125..173c4fc9 100644 --- a/R/galah_radius.R +++ b/R/galah_radius.R @@ -26,7 +26,8 @@ galah_radius <- function(...){ #' parser for radius #' @noRd #' @keywords Internal -parse_point_radius <- function(..., error_call = caller_env()){ +parse_point_radius <- function(..., + error_call = rlang::caller_env()){ query <- rlang::try_fetch( list(...)[[1]], diff --git a/R/handle_quosures.R b/R/handle_quosures.R index 15a7366b..b8f1c29c 100644 --- a/R/handle_quosures.R +++ b/R/handle_quosures.R @@ -527,7 +527,7 @@ switch_solr <- function(df){ #' @keywords internal filter_error <- function(){ cli::cli_abort("Invalid argument passed to `filter()`.", - call = caller_env()) + call = rlang::caller_env()) } #' Subfunction called by `parse_solr()` diff --git a/R/print.R b/R/print.R index d80abcd6..2dd4a49b 100644 --- a/R/print.R +++ b/R/print.R @@ -281,21 +281,26 @@ print.galah_config <- function(x, ...){ cli::cli_text("`galah` package configuration:") cli::cli_end() cli::cli_par() + # print package settings cli::cli_text("{galah_pink(\"Package\")}") - package_settings <- galah_green(c("verbose", "run_checks", "send_email")) - package_lookup <- unlist(x$package[1:3]) |> - as.integer() + 1 - names(package_settings) <- c("x", "v")[package_lookup] + package_info <- purrr::pluck(x, "package") + package_logical_check <- purrr::map(package_info, is.logical) |> + unlist() + logical_values <- package_info[package_logical_check] |> + unlist() + package_settings <- names(logical_values) |> + galah_green() + names(package_settings) <- c("x", "v")[as.integer(logical_values) + 1] package_settings <- c(package_settings, "i" = glue::glue("{galah_green('directory')}: {galah_grey(x$package$directory)}")) |> cli::cli_bullets() cli::cli_end() + # print user settings cli::cli_par() - cli::cli_text("{galah_pink(\"User\")}") + cli::cli_text("{galah_pink(\"User\")}") c("{galah_green('username')} {galah_grey(hide_secrets(x$user$username))}", "{galah_green('email')} {galah_grey(x$user$email)}", "{galah_green('password')} {galah_grey(hide_secrets(x$user$password))}", - "{galah_green('api_key')} {galah_grey(hide_secrets(x$user$api_key))}", "{galah_green('download_reason_id')} {galah_grey(x$user$download_reason_id)}") |> cli::cli_bullets() cli::cli_end() diff --git a/R/query_API.R b/R/query_API.R index 93836016..899db3a7 100644 --- a/R/query_API.R +++ b/R/query_API.R @@ -6,7 +6,7 @@ #' @noRd #' @keywords Internal query_API <- function(.query, - error_call = caller_env()) { + error_call = rlang::caller_env()) { # first try situation when many urls are supplied # this is common for living atlases, where many urls are generated for # e.g. paginated queries, grouped counts etc @@ -19,7 +19,8 @@ query_API <- function(.query, data_tr$download <- TRUE data_tr$file <- .query$url$path[[a]] } - query_API_internal(data_tr) + query_API_internal(data_tr, + error_call = error_call) }, .progress = set_progress_bar_behaviour(nrow(.query$url) > 1)) # next handle multiple `body` arguments @@ -30,7 +31,8 @@ query_API <- function(.query, .f = function(a){ data_tr <- .query data_tr$body <- a$predicate[[1]] - a$result <- list(query_API_internal(data_tr)) + a$result <- list(query_API_internal(data_tr, + error_call = error_call)) a }, .progress = set_progress_bar_behaviour(length(.query$body) > 1)) |> @@ -38,7 +40,8 @@ query_API <- function(.query, # finally, some queries are 'simple'; one `url`, one or no `body` args # these we just run without any looping. }else{ - query_API_internal(.query) + query_API_internal(.query, + error_call = error_call) } } @@ -60,12 +63,33 @@ set_progress_bar_behaviour <- function(criteria){ #' Internal function to run an API call using httr2 #' @noRd #' @keywords Internal -query_API_internal <- function(.query, error_call = caller_env()) { +query_API_internal <- function(.query, + error_call = rlang::caller_env()) { query <- httr2::request(.query$url) |> add_headers(.query$headers) |> add_options(.query$options) |> # used by GBIF add_body(.query$body) # NOTE: adding `body` converts from GET to POST - # handle downloads first + + # set authentication behaviour + if(potions::pour("package", "authenticate", .pkg = "galah") & + .query$type != "metadata/config" # necessary to prevent circular problems + ){ + # check whether config data is available + auth_config <- retrieve_cache("config") + if(is.null(auth_config)){ + cli::cli_abort(c("`authenticate` is set to `TRUE`, but `config` data is not available", + i = "Call `request_metadata(type = \"config\") |> collect()`, then try again"), + call = error_call) + }else{ + query <- query |> + httr2::req_oauth_auth_code(client = build_auth_client(auth_config), + auth_url = dplyr::pull(auth_config, "authorize_url"), + scope = dplyr::pull(auth_config, "scopes"), + cache_disk = TRUE) + } + } + + # then handle downloads if(!is.null(.query$download)){ check_directory(.query$file) @@ -94,6 +118,25 @@ query_API_internal <- function(.query, error_call = caller_env()) { } } +#' create a client object +#' @noRd +#' @keywords Internal +build_auth_client <- function(config){ + # setting a temporary fail to force developers to enter secrets manually. + # once that is done, comment out this line + cli::cli_abort("galah dev team: Please manually enter `secret` to `build_auth_client()`") + + # this is the actual code: + httr2::oauth_client( + id = dplyr::pull(config, "client_id"), + # secret = purrr::pluck(config, "client_secret"), # future code + ## NOTE: cognito (test) doesn't need a secret, but CAS (prod) does + ## unclear whether we'll solve this by configuring CAS to generate a secret, + ## or CAS to ignore one. + secret = "ADD-SECRET-HERE", # NOTE: temporary fix, *DO NOT SHARE REAL SECRETS* + token_url = dplyr::pull(config, "token_url")) +} + #' If supplied, add `headers` arg to a `request()` #' @noRd #' @keywords Internal diff --git a/R/read_zip.R b/R/read_zip.R index 6e421425..570b511d 100644 --- a/R/read_zip.R +++ b/R/read_zip.R @@ -52,8 +52,12 @@ read_zip <- function(file){ suppressWarnings() # Note: DOIs for GBIF are stored in `compute()` stage, not in the zip file }else{ - available_files <- all_files[grepl(".csv$", all_files) & - grepl("^data|records", all_files)] + available_files <- all_files[stringr::str_detect(all_files, ".csv$") & + !(all_files %in% c("citation.csv", "headings.csv"))] + # grepl("^data|records", all_files)] + # typically files start with data or records, + # but downloads sometimes have the name of the zip file as the csv file + result <- purrr::map(available_files, function(a, x){ # create connection to a specific file within zip diff --git a/R/search_all.R b/R/search_all.R index c70d199a..683d1c6e 100644 --- a/R/search_all.R +++ b/R/search_all.R @@ -158,7 +158,7 @@ search_all <- function(type, result <- collect(request) # add search, or not, depending on behaviour - if(isTRUE(run_subsequent_query)){ + if(isTRUE(run_subsequent_query) & nrow(result) > 0){ search_text_cols(result, query) }else{ result @@ -178,6 +178,7 @@ search_text_cols <- function(df, query){ check_vector <- purrr::list_transpose(check_list) |> purrr::map(any) |> unlist() + result <- df |> dplyr::filter({{check_vector}}) # order search_all() results diff --git a/R/show_all.R b/R/show_all.R index 0610b85a..ce56b402 100644 --- a/R/show_all.R +++ b/R/show_all.R @@ -26,6 +26,7 @@ #' |---|---|---|---| #' | Configuration |`atlases`| Show what atlases are available | `show_all_atlases()` | #' | |`apis`| Show what APIs & functions are available for each atlas | `show_all_apis()` | +#' | |`config`| Show information necessary for authentication | `show_all_config()`| #' | |`reasons`| Show what values are acceptable as 'download reasons' for a specified atlas | `show_all_reasons()` | #' | Data providers|`providers`| Show which institutions have provided data | `show_all_providers()` | #' | |`collections`|Show the specific collections within those institutions| `show_all_collections()` | @@ -146,6 +147,14 @@ show_all_collections <- function(limit = NULL, all_fields = all_fields) } +#' @rdname show_all +#' @export +show_all_config <- function(){ + show_all_generic(type = "config", + limit = NULL, + all_fields = TRUE) +} + #' @rdname show_all #' @export show_all_datasets <- function(limit = NULL, diff --git a/R/sysdata.rda b/R/sysdata.rda index 9b898400a8098ecd8cef62b2684fe41009a73d9d..ce65a7771e1dc5c111b8c48b751e3701d08d1864 100644 GIT binary patch literal 6441 zcmV+^8P?`PT4*^jL0KkKSw)G4kN_TP|NZ~}|NsC0|NsC0|M|cF|N1~eK>$SIN(dML z0D(k>;2+nIovXUt_W^gO9ViYc)H!bJy?X^+fYn~gZh7tjptcQYXKi-cb*_dccsX5K z)Pu3Jvq8~RX3B3tyIZccY))#R()S4@LSi&D8cbC`VNIh{cnLO|o`g?R)csS*jUnof z)Q=R?(@4!U(+TQNG&MCg(rFC==@=ye0W~xsPbr892A-fA0B8n)000JtgFpZQpa1}a zCICzcgv8L%iHU$r0%Q#h8e%XLLjXep4441_69P>rgw)Dt)byvR>OB)iO%G55BTWqe z&}a;R0001bfB*mhXd+M~#Z&b?O_es2)jdy9>VBy8KS~=&XnIXON2CB`4=4&?Pbbi~ywABUziOhI;0h+-f~)bSMFuu?FiylxZ7%T=KyLu!cyvPyc3Kmw~UDHwpNY1HM}e>VU(^M8=gEi9)-7!jbx|t9IvJnQn zH5Yq73_NZQAxP-e+`s8zB*P|LHtn`}Ddy{-guQ^xBqYJ-@9_9v0^-_P@5BZw9ZIx< zB!7|8KnAlVNK*wODzi|ijS&nTo&mbdS0^5c%R(CIuRh#d%;+)}lADZBP(^fAvWw|=~|Y5WhF z5B)t9xgF9Ifih-xq!CrCcu^?=%T^$Ucx55p$hFAB$zLWNL&$0HV%2Hu%`;k3v}Z

    9(yPeTDwBt-^6QbZ&~kp(|2^B^jvgv3Zm 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 literal 6429 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 rbY=x$a?uz_cK - cli::cli_abort(call = caller_env()) + cli::cli_abort() } .query$type <- glue::glue("{supplied_type}-unnest") .query diff --git a/R/url_lookup.R b/R/url_lookup.R index 1ef0c127..5b7321ff 100644 --- a/R/url_lookup.R +++ b/R/url_lookup.R @@ -22,8 +22,8 @@ url_lookup <- function(type, type <- dots$type } url_string <- node_config |> - dplyr::filter(node_config$type == {{type}}, - node_config$atlas == {{current_atlas}}) |> + dplyr::filter(.data$type == {{type}} & + .data$atlas == {{current_atlas}}) |> dplyr::pull(url) # parse as needed if(length(url_string) > 0){ @@ -39,9 +39,8 @@ url_lookup <- function(type, if(quiet){ return(NULL) }else{ - c( - glue::glue("No API is available for type `{type}`"), - i = glue::glue("Selected atlas: {current_atlas}"), + c("No API is available for type `{type}`", + i = "Selected atlas: {current_atlas}", i = "Use `show_all_apis()` to list valid API calls") |> cli::cli_abort(call = error_call) } diff --git a/R/utilities_caching.R b/R/utilities_caching.R index 3bc97043..3e5bdf1b 100644 --- a/R/utilities_caching.R +++ b/R/utilities_caching.R @@ -34,6 +34,7 @@ update_cache <- function(...){ } #' Internal function to retrieve current state of the cache +#' @param slot_name (optional) slot to extract #' @noRd #' @keywords Internal retrieve_cache <- function(slot_name){ @@ -59,7 +60,7 @@ check_if_cache_update_needed <- function(function_name){ # build some checks is_local <- !is.null(attr(df, "ARCHIVED")) is_wrong_atlas <- attr(df, "region") != current_atlas - is_too_short <- nrow(df) <= 1 # somewhat arbitrary, but catches empty tibbles + is_too_short <- nrow(df) < 1 # somewhat arbitrary, but catches empty tibbles # evaluate those checks result <- is_local | is_wrong_atlas | is_too_short # if any, update is needed if(length(result) < 1){ # bug catcher diff --git a/R/utilities_internal.R b/R/utilities_internal.R index e82db4d3..26a91660 100644 --- a/R/utilities_internal.R +++ b/R/utilities_internal.R @@ -89,8 +89,7 @@ parse_rename <- function(df, .query){ #' @noRd #' @keywords Internal parse_arrange <- function(df){ - col <- colnames(df)[1] - dplyr::arrange(df, !!!col) + dplyr::arrange(df, dplyr::pull(df, 1)) } #' Choose column names to pass to `select()`. @@ -429,7 +428,8 @@ profiles_supported <- function(){ #' @keywords Internal reasons_supported <- function(){ atlas <- potions::pour("atlas", "region") - supported_atlases <- show_all(apis) |> + supported_atlases <- request_metadata(type = "apis") |> + collect() |> dplyr::filter(type == "metadata/reasons") |> dplyr::pull(atlas) atlas %in% supported_atlases diff --git a/data-raw/node_config.csv b/data-raw/node_config.csv index f9ce458a..1d302b64 100644 --- a/data-raw/node_config.csv +++ b/data-raw/node_config.csv @@ -10,6 +10,7 @@ Australia,data/species-count,https://api.ala.org.au/occurrences/occurrences/face Australia,files/media,https://api.ala.org.au/images/image/{id}/{size},TRUE Australia,metadata/assertions,https://api.ala.org.au/occurrences/assertions/codes,TRUE Australia,metadata/collections,https://api.ala.org.au/metadata/ws/collection,TRUE +Australia,metadata/config,https://api.ala.org.au/common/api/getAuthConfig,TRUE Australia,metadata/datasets,https://api.ala.org.au/metadata/ws/dataResource,TRUE Australia,metadata/distributions,https://api.ala.org.au/spatial-service/distributions,FALSE Australia,metadata/fields,https://api.ala.org.au/occurrences/index/fields,TRUE @@ -31,6 +32,7 @@ Austria,data/occurrences-count,https://biocache.biodiversityatlas.at/ws/occurren Austria,data/occurrences-count-groupby,https://biocache.biodiversityatlas.at/ws/occurrence/facets,TRUE Austria,data/species,https://biocache.biodiversityatlas.at/ws/occurrences/facets/download,TRUE Austria,data/species-count,https://biocache.biodiversityatlas.at/ws/occurrence/facets,TRUE +Austria,files/media,https://images.biodiversityatlas.at/ws/image/{id}/{size},TRUE Austria,metadata/assertions,https://biocache.biodiversityatlas.at/ws/assertions/codes,TRUE Austria,metadata/collections,https://collectory.biodiversityatlas.at/ws/collection,TRUE Austria,metadata/datasets,https://collectory.biodiversityatlas.at/ws/dataResource,TRUE @@ -39,7 +41,7 @@ Austria,metadata/fields-unnest,https://biocache.biodiversityatlas.at/ws/occurren Austria,metadata/licences,https://images.biodiversityatlas.at/ws/licence,TRUE Austria,metadata/lists,https://lists.biodiversityatlas.at/ws/speciesList/,TRUE Austria,metadata/lists-unnest,https://lists.biodiversityatlas.at/ws/speciesListItems/{list_id},TRUE -Austria,metadata/media,https://images.biodiversityatlas.at/ws/imageInfoForList,TRUE +Austria,metadata/media,https://images.biodiversityatlas.at/ws/image/{id},TRUE Austria,metadata/providers,https://collectory.biodiversityatlas.at/ws/dataProvider,TRUE Austria,metadata/reasons,https://logger.biodiversityatlas.at/service/logger/reasons,TRUE Austria,metadata/taxa-single,https://bie.biodiversityatlas.at/ws/search?q={name}&pageSize=5,TRUE @@ -49,15 +51,16 @@ Brazil,data/occurrences-count,https://biocache-service.sibbr.gov.br/biocache-ser Brazil,data/occurrences-count-groupby,https://biocache-service.sibbr.gov.br/biocache-service/occurrence/facets,TRUE Brazil,data/species,https://biocache-service.sibbr.gov.br/biocache-service/occurrences/facets/download,TRUE Brazil,data/species-count,https://biocache-service.sibbr.gov.br/biocache-service/occurrence/facets,TRUE +Brazil,files/media,https://images.sibbr.gov.br/ws/image/{id}/{size},TRUE Brazil,metadata/assertions,https://biocache-service.sibbr.gov.br/biocache-service/assertions/codes,TRUE Brazil,metadata/collections,https://collectory.sibbr.gov.br/collectory/ws/collection,TRUE Brazil,metadata/datasets,https://collectory.sibbr.gov.br/collectory/ws/dataResource,TRUE Brazil,metadata/fields,https://biocache-service.sibbr.gov.br/biocache-service/index/fields,TRUE Brazil,metadata/fields-unnest,https://biocache-service.sibbr.gov.br/biocache-service/occurrence/facets,TRUE -Brazil,metadata/licences,https://images.sibbr.gov.br/images/ws/licence,TRUE +Brazil,metadata/licences,https://images.sibbr.gov.br/ws/licence,TRUE Brazil,metadata/lists,https://specieslist.sibbr.gov.br/ws/speciesList/,TRUE Brazil,metadata/lists-unnest,https://specieslist.sibbr.gov.br/ws/speciesListItems/{list_id},TRUE -Brazil,metadata/media,https://images.sibbr.gov.br/ws/getImageInfoForIdList,TRUE +Brazil,metadata/media,https://images.sibbr.gov.br/ws/image/{id},TRUE Brazil,metadata/providers,https://collectory.sibbr.gov.br/collectory/ws/dataProvider,TRUE Brazil,metadata/taxa-single,https://bie-webservice.sibbr.gov.br/bie-index/search?q={name}&pageSize=5,TRUE Brazil,metadata/taxa-unnest,https://bie-webservice.sibbr.gov.br/bie-index/childConcepts/{id},TRUE @@ -78,7 +81,7 @@ Flanders,data/occurrences-count,https://natuurdata.inbo.be/biocache-service/occu Flanders,data/occurrences-count-groupby,https://natuurdata.inbo.be/biocache-service/occurrence/facets,TRUE Flanders,data/species,https://natuurdata.inbo.be/biocache-service/occurrences/facets/download,TRUE Flanders,data/species-count,https://natuurdata.inbo.be/biocache-service/occurrence/facets,TRUE -Flanders,files/media,https://natuurdata.inbo.be/image-service/ws/image/{id}/original,TRUE +Flanders,files/media,https://natuurdata.inbo.be/image-service/ws/image/{id}/{size},TRUE Flanders,metadata/assertions,https://natuurdata.inbo.be/biocache-service/assertions/codes,TRUE Flanders,metadata/collections,https://natuurdata.inbo.be/collectory/ws/collection,TRUE Flanders,metadata/datasets,https://natuurdata.inbo.be/collectory/ws/dataResource,TRUE @@ -88,7 +91,7 @@ Flanders,metadata/identifiers,https://api.gbif.org/v1/species/{id},TRUE Flanders,metadata/licences,https://natuurdata.inbo.be/image-service/ws/licence,TRUE Flanders,metadata/lists,https://natuurdata.inbo.be/species-list/ws/speciesList/,TRUE Flanders,metadata/lists-unnest,https://natuurdata.inbo.be/species-list/ws/speciesListItems/{list_id},TRUE -Flanders,metadata/media,https://natuurdata.inbo.be/image-service/ws/getImageInfoForIdList,TRUE +Flanders,metadata/media,https://natuurdata.inbo.be/image-service/ws/image/{id},TRUE Flanders,metadata/profiles,https://natuurdata.inbo.be/data-quality-filter-service/api/v1/data-profiles,TRUE Flanders,metadata/profiles-unnest,https://natuurdata.inbo.be/data-quality-filter-service/api/v1/data-profiles/{profile},TRUE Flanders,metadata/providers,https://natuurdata.inbo.be/collectory/ws/dataProvider,TRUE @@ -130,13 +133,14 @@ Guatemala,data/occurrences-count,https://snib.conap.gob.gt/registros-ws/occurren Guatemala,data/occurrences-count-groupby,https://snib.conap.gob.gt/registros-ws/occurrence/facets,TRUE Guatemala,data/species,https://snib.conap.gob.gt/registros-ws/occurrences/facets/download,FALSE Guatemala,data/species-count,https://snib.conap.gob.gt/registros-ws/occurrence/facets,TRUE +Guatemala,files/media,https://imagenes.snib.conap.gob.gt/ws/image/{id}/{size},TRUE Guatemala,metadata/assertions,https://snib.conap.gob.gt/registros-ws/assertions/codes,TRUE Guatemala,metadata/collections,https://snib.conap.gob.gt/colecciones/ws/collection,TRUE Guatemala,metadata/datasets,https://snib.conap.gob.gt/colecciones/ws/dataResource,TRUE Guatemala,metadata/fields,https://snib.conap.gob.gt/registros-ws/index/fields,TRUE Guatemala,metadata/fields-unnest,https://snib.conap.gob.gt/registros-ws/occurrence/facets,TRUE Guatemala,metadata/licences,https://imagenes.snib.conap.gob.gt/licence,TRUE -Guatemala,metadata/media,https://imagenes.snib.conap.gob.gt/ws/imageInfoForList,TRUE +Guatemala,metadata/media,https://imagenes.snib.conap.gob.gt/ws/image/{id},TRUE Guatemala,metadata/providers,https://snib.conap.gob.gt/colecciones/ws/dataProvider,TRUE Guatemala,metadata/reasons,https://snib.conap.gob.gt/logger/service/logger/reasons,TRUE Guatemala,metadata/taxa-single,https://snib.conap.gob.gt/especies-ws/search?q={name}&pageSize=5,TRUE @@ -146,7 +150,7 @@ Kew,data/occurrences-count,https://records-ws.data.kew.org/occurrences/search,TR Kew,data/occurrences-count-groupby,https://records-ws.data.kew.org/occurrences/facets,TRUE Kew,data/species,https://records-ws.data.kew.org/occurrences/facets/download,TRUE Kew,data/species-count,https://records-ws.data.kew.org/occurrences/facets,TRUE -Kew,files/media,https://images.data.kew.org/ws/image/{id}/original,TRUE +Kew,files/media,https://images.data.kew.org/ws/image/{id}/{size},TRUE Kew,metadata/assertions,https://records-ws.data.kew.org/assertions/codes,TRUE Kew,metadata/collections,https://collections.data.kew.org/ws/collection,TRUE Kew,metadata/datasets,https://collections.data.kew.org/ws/dataResource,TRUE @@ -155,7 +159,7 @@ Kew,metadata/fields-unnest,https://records-ws.data.kew.org/occurrences/facets,TR Kew,metadata/licences,https://images.data.kew.org/ws/licence,TRUE Kew,metadata/lists,https://lists.data.kew.org/ws/speciesList,TRUE Kew,metadata/lists-unnest,https://lists.data/kew.org/ws/speciesListItems/{list_id},TRUE -Kew,metadata/media,https://images.data.kew.org/ws/getImageInfoForIdList,TRUE +Kew,metadata/media,https://images.data.kew.org/ws/image/{id},TRUE Kew,metadata/providers,https://collections.data.kew.org/ws/dataProvider,TRUE Kew,metadata/reasons,https://logger.data.kew.org/service/logger/reasons,TRUE Kew,metadata/taxa-single,https://species-ws.data.kew.org/search?q={name}&pageSize=5,TRUE @@ -165,13 +169,14 @@ Portugal,data/occurrences-count,https://registos-ws.gbif.pt/occurrences/search,T Portugal,data/occurrences-count-groupby,https://registos-ws.gbif.pt/occurrence/facets,TRUE Portugal,data/species,https://registos-ws.gbif.pt/occurrences/facets/download,FALSE Portugal,data/species-count,https://registos-ws.gbif.pt/occurrence/facets,TRUE +Portugal,files/media,https://imagens.gbif.pt/ws/image/{id}/{size},TRUE Portugal,metadata/assertions,https://registos-ws.gbif.pt/assertions/codes,TRUE Portugal,metadata/collections,https://metadados.gbif.pt/ws/collection,TRUE Portugal,metadata/datasets,https://metadados.gbif.pt/ws/dataResource,TRUE Portugal,metadata/fields,https://registos-ws.gbif.pt/index/fields,TRUE Portugal,metadata/fields-unnest,https://registos-ws.gbif.pt/occurrence/facets,TRUE Portugal,metadata/licences,https://imagens.gbif.pt/ws/licence,TRUE -Portugal,metadata/media,https://imagens.gbif.pt/ws/imageInfoForList,TRUE +Portugal,metadata/media,https://imagens.gbif.pt/ws/image/{id},TRUE Portugal,metadata/providers,https://metadados.gbif.pt/ws/dataProvider,TRUE Portugal,metadata/reasons,https://logger.gbif.pt/service/logger/reasons,TRUE Portugal,metadata/taxa-single,https://api.gbif.org/v2/species/match?verbose=FALSE&scientificName={name},TRUE @@ -183,6 +188,7 @@ Spain,data/occurrences-count-groupby,https://registros-ws.gbif.es/occurrence/fac Spain,data/occurrences-doi,https://doi.gbif.es/doi/{doi_string}/download,TRUE Spain,data/species,https://registros-ws.gbif.es/occurrences/facets/download,TRUE Spain,data/species-count,https://registros-ws.gbif.es/occurrence/facets,TRUE +Spain,files/media,https://imagenes.gbif.es/ws/images/{id}/{size},TRUE Spain,metadata/assertions,https://registros-ws.gbif.es/assertions/codes,TRUE Spain,metadata/collections,https://colecciones.gbif.es/ws/collection,TRUE Spain,metadata/datasets,https://colecciones.gbif.es/ws/dataResource,TRUE @@ -192,7 +198,7 @@ Spain,metadata/identifiers,https://name-matching.gbif.es/api/getByTaxonID,TRUE Spain,metadata/licences,https://imagenes.gbif.es/ws/licence,TRUE Spain,metadata/lists,https://listas.gbif.es/ws/speciesList,TRUE Spain,metadata/lists-unnest,https://listas.gbif.es/ws/speciesListItems/{list_id},TRUE -Spain,metadata/media,https://imagenes.gbif.es/ws/getImageInfoForIdList,TRUE +Spain,metadata/media,https://imagenes.gbif.es/ws/images/{id},TRUE Spain,metadata/profiles,https://data-quality.gbif.es/api/v1/data-profiles,TRUE Spain,metadata/profiles-unnest,https://data-quality.gbif.es/api/v1/quality/activeProfile?profileName={profile},TRUE Spain,metadata/providers,https://colecciones.gbif.es/ws/dataProvider,TRUE @@ -205,7 +211,7 @@ Sweden,data/occurrences-count,https://records.biodiversitydata.se/ws/occurrences Sweden,data/occurrences-count-groupby,https://records.biodiversitydata.se/ws/occurrence/facets,TRUE Sweden,data/species,https://records.biodiversitydata.se/ws/occurrences/facets/download,TRUE Sweden,data/species-count,https://records.biodiversitydata.se/ws/occurrence/facets,TRUE -Sweden,files/media,https://images.biodiversitydata.se/image/{id}/original,TRUE +Sweden,files/media,https://images.biodiversitydata.se/image/{id}/{size},TRUE Sweden,metadata/assertions,https://records.biodiversitydata.se/ws/assertions/codes,TRUE Sweden,metadata/collections,https://collections.biodiversitydata.se/ws/collection,TRUE Sweden,metadata/datasets,https://collections.biodiversitydata.se/ws/dataResource,TRUE @@ -215,7 +221,7 @@ Sweden,metadata/identifiers,https://namematching.biodiversitydata.se/api/getByTa Sweden,metadata/licences,https://images.biodiversitydata.se/ws/licence,TRUE Sweden,metadata/lists,https://lists.biodiversitydata.se/ws/speciesList,TRUE Sweden,metadata/lists-unnest,https://lists.biodiversitydata.se/ws/speciesListItems/{list_id},TRUE -Sweden,metadata/media,https://images.biodiversitydata.se/ws/getImageInfoForIdList,TRUE +Sweden,metadata/media,https://images.biodiversitydata.se/ws/image/{id},TRUE Sweden,metadata/profiles,https://data-quality-service.biodiversitydata.se/api/v1/data-profiles?enabled=true,TRUE Sweden,metadata/profiles-unnest,https://data-quality-service.biodiversitydata.se/api/v1/data-profiles/{profile},TRUE Sweden,metadata/providers,https://collections.biodiversitydata.se/ws/dataProvider,TRUE @@ -228,6 +234,7 @@ United Kingdom,data/occurrences-count,https://records-ws.nbnatlas.org/occurrence United Kingdom,data/occurrences-count-groupby,https://records-ws.nbnatlas.org/occurrence/facets,TRUE United Kingdom,data/species,https://records-ws.nbnatlas.org/occurrences/facets/download,TRUE United Kingdom,data/species-count,https://records-ws.nbnatlas.org/occurrence/facets,TRUE +United Kingdom,files/media,https://images.nbnatlas.org/ws/image/{id}/{size},TRUE United Kingdom,metadata/assertions,https://records-ws.nbnatlas.org/assertions/codes,TRUE United Kingdom,metadata/collections,https://registry.nbnatlas.org/ws/collection,TRUE United Kingdom,metadata/datasets,https://registry.nbnatlas.org/ws/dataResource,TRUE @@ -236,7 +243,7 @@ United Kingdom,metadata/fields-unnest,https://records-ws.nbnatlas.org/occurrence United Kingdom,metadata/licences,https://images.nbnatlas.org/licence,TRUE United Kingdom,metadata/lists,https://lists.nbnatlas.org/ws/speciesList,TRUE United Kingdom,metadata/lists-unnest,https://lists.nbnatlas.org/ws/speciesListItems/{list_id},TRUE -United Kingdom,metadata/media,https://images.nbnatlas.org/ws/getImageInfoForIdList,TRUE +United Kingdom,metadata/media,https://images.nbnatlas.org/ws/image/{id},TRUE United Kingdom,metadata/providers,https://registry.nbnatlas.org/ws/dataProvider,TRUE United Kingdom,metadata/reasons,https://logger.nbnatlas.org/service/logger/reasons,TRUE United Kingdom,metadata/taxa-single,https://species-ws.nbnatlas.org/search?q={name}&pageSize=5,TRUE diff --git a/man/galah_call.Rd b/man/galah_call.Rd index 7922dc0b..67bc89cc 100644 --- a/man/galah_call.Rd +++ b/man/galah_call.Rd @@ -15,9 +15,9 @@ request_data( ) request_metadata( - type = c("fields", "apis", "assertions", "atlases", "collections", "datasets", - "licences", "lists", "media", "profiles", "providers", "ranks", "reasons", "taxa", - "identifiers") + type = c("fields", "apis", "assertions", "atlases", "collections", "config", + "datasets", "licences", "lists", "media", "profiles", "providers", "ranks", + "reasons", "taxa", "identifiers") ) request_files(type = "media") diff --git a/man/show_all.Rd b/man/show_all.Rd index 634ae782..8b2ad414 100644 --- a/man/show_all.Rd +++ b/man/show_all.Rd @@ -6,6 +6,7 @@ \alias{show_all_assertions} \alias{show_all_atlases} \alias{show_all_collections} +\alias{show_all_config} \alias{show_all_datasets} \alias{show_all_fields} \alias{show_all_licences} @@ -26,6 +27,8 @@ show_all_atlases(limit = NULL, all_fields = FALSE) show_all_collections(limit = NULL, all_fields = FALSE) +show_all_config() + show_all_datasets(limit = NULL, all_fields = FALSE) show_all_fields(limit = NULL, all_fields = FALSE) @@ -75,6 +78,7 @@ The available types of information for \code{show_all_} are:\tabular{llll}{ \strong{Category} \tab \strong{Type} \tab \strong{Description} \tab \strong{Sub-functions} \cr Configuration \tab \code{atlases} \tab Show what atlases are available \tab \code{show_all_atlases()} \cr \tab \code{apis} \tab Show what APIs & functions are available for each atlas \tab \code{show_all_apis()} \cr + \tab \code{config} \tab Show information necessary for authentication \tab \code{show_all_config()} \cr \tab \code{reasons} \tab Show what values are acceptable as 'download reasons' for a specified atlas \tab \code{show_all_reasons()} \cr Data providers \tab \code{providers} \tab Show which institutions have provided data \tab \code{show_all_providers()} \cr \tab \code{collections} \tab Show the specific collections within those institutions \tab \code{show_all_collections()} \cr diff --git a/tests/testthat/_snaps/print.md b/tests/testthat/_snaps/print.md index 89656403..b95570b6 100644 --- a/tests/testthat/_snaps/print.md +++ b/tests/testthat/_snaps/print.md @@ -31,7 +31,7 @@ Message Object of type metadata_request containing: * type lists - * filter Object of class `metadata_filter`: list == dr650 + * filter Object of class `metadata_filter` with type `list` (n = 0 entries) # object of class `metadata-request` formats correctly with `identify() @@ -95,13 +95,13 @@ v verbose v run_checks x send_email - i directory: /var/folders/47/2_32ylzx64qgpyqlh7zkd_5h0000gn/T//RtmpauoXVz + v authenticate + i directory: /var/folders/47/2_32ylzx64qgpyqlh7zkd_5h0000gn/T//Rtmpu7Kgdm User username [Not Provided] email password [Not Provided] - api_key [Not Provided] download_reason_id 4 Atlas diff --git a/tests/testthat/test-authentication.R b/tests/testthat/test-authentication.R new file mode 100644 index 00000000..289717b8 --- /dev/null +++ b/tests/testthat/test-authentication.R @@ -0,0 +1,103 @@ +quiet_config <- purrr::quietly(galah_config) + +test_that("`galah_config()` caches config info when `authenticate` is set to `TRUE`", { + skip_on_ci(); skip_on_cran() + x <- galah_config() + expect_false(x$package$authenticate) + expect_true(is.null(retrieve_cache("config"))) + y <- quiet_config(authenticate = TRUE) + stringr::str_detect(y$messages, + "Caching `config` information to support authentication") |> + any() |> + expect_true() + expect_true(y$result$package$authenticate) + cached_config <- retrieve_cache("config") + expect_false(is.null(cached_config)) + expect_equal(nrow(cached_config), 1) +}) + + +test_that("`request_metadata()` works for type = `config`", { + skip_on_ci(); skip_on_cran() + result <- request_metadata(type = "config") |> + collect() + expect_equal(nrow(result), 1) + expect_equal(ncol(result), 4) + expect_true(all( + c("client_id", "authorize_url", "token_url", "scopes") %in% + colnames(result))) +}) + +test_that("`request_metadata()` caches type `config` correctly", { + skip_on_ci(); skip_on_cran() + reset_cache() + result <- request_metadata(type = "config") |> + as_query() + expect_true(!is.null(result$data)) +}) + +test_that("setting `authentication` to `TRUE` doesn't break a query", { + skip("Authentication currently requires user interaction") + config <- quiet_config(authenticate = TRUE) + result <- request_metadata(type = "reasons") |> + collect() |> + expect_no_error() + result |> + inherits(c("tbl_df", "tbl", "data.frame")) |> + expect_true() + expect_gt(nrow(result), 1) + expect_equal(ncol(result), 2) + # reset + galah_config(authentication = FALSE) +}) + +test_that("setting `authentication` to `TRUE` changes data returned", { + skip_on_ci(); skip_on_cran() + skip_if(!file.exists(".secure-credentials"), + "Secret information not provided") + + # load credentials + do.call(galah_config, + jsonlite::fromJSON(".secure-credentials")) + # add other options + config <- quiet_config(directory = "TEST-SENSITIVE-DATA", + authenticate = TRUE, + run_checks = FALSE) + + # These credentials *should* give access to sensitive data for Tasmania *only* + # subset to species on Tasmania's sensitive species list + result <- galah_call() |> + filter(species_list_uid == "dr491") |> + collect() + + # check sensitive columns exist + colnames(result) |> + stringr::str_detect("^sensitive_") |> + any() |> + expect_true() + + # expect some sensitive spatial data to not be NA + na_values <- result |> + dplyr::pull(sensitive_decimalLatitude) |> + is.na() + any(!na_values) |> + expect_true() + + # expect higher precision in sensitive columns, where any data given + precise_latitude <- result$sensitive_decimalLatitude[!na_values] |> + as.character() |> + nchar() + imprecise_latitude <- result$decimalLatitude[!na_values] |> + as.character() |> + nchar() + imprecise_latitude[is.na(imprecise_latitude)] <- 0 + # test + all(precise_latitude >= imprecise_latitude) |> + expect_true() + + unlink("TEST-SENSITIVE-DATA", recursive = TRUE) + config <- quiet_config(authenticate = FALSE, + directory = "TESTING") +}) + +rm(quiet_config) \ No newline at end of file diff --git a/tests/testthat/test-galah_config.R b/tests/testthat/test-galah_config.R index 796e9e0d..3713c8d9 100644 --- a/tests/testthat/test-galah_config.R +++ b/tests/testthat/test-galah_config.R @@ -1,5 +1,10 @@ quiet_config <- purrr::quietly(galah_config) +test_that("`galah_config()` gives nice error messages for incorrect arguments", { + # FIXME: error comes from `purrr::map()`, not `galah_config()` + expect_error(quiet_config(something = "nothing")) +}) + test_that("galah_config warns that `cache_directory` is deprecated", { unlink("temp", recursive = TRUE) dir.create("temp") From faeb1cd074328b8a210fb8a341021b96e18078a3 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Wed, 29 Oct 2025 11:29:55 +1100 Subject: [PATCH 39/94] Add vignette on sensitive data (#189) - update package-level information - minor update to `galah_config()` to support quiet addition on email, passwords --- R/galah-package.R | 38 +- R/galah_config.R | 51 +- _pkgdown.yml | 17 +- man/galah.Rd | 39 +- man/galah_config.Rd | 48 +- vignettes/accessing_sensitive_data.Rmd | 625 ++++++++++++++++++ vignettes/accessing_sensitive_data.Rmd.orig | 137 ++++ vignettes/downloading_images_and_sounds.Rmd | 15 + .../downloading_images_and_sounds.Rmd.orig | 18 + vignettes/precompile.R | 6 +- 10 files changed, 931 insertions(+), 63 deletions(-) create mode 100644 vignettes/accessing_sensitive_data.Rmd create mode 100644 vignettes/accessing_sensitive_data.Rmd.orig create mode 100644 vignettes/downloading_images_and_sounds.Rmd create mode 100644 vignettes/downloading_images_and_sounds.Rmd.orig diff --git a/R/galah-package.R b/R/galah-package.R index 323c5195..2507fa4a 100644 --- a/R/galah-package.R +++ b/R/galah-package.R @@ -23,8 +23,8 @@ #' #' **Getting Started** #' -#' * [galah_call()]/\code{\link[=request_data]{request_()}} Start to build a query #' * [galah_config()] Set package configuration options +#' * [galah_call()]/\code{\link[=request_data]{request_()}} Start to build a query #' * [show_all()] & [search_all()] Data for generating filter queries #' * [show_values()] & [search_values()] Show or search for values _within_ #' `fields`, `profiles`, `lists`, `collections`, `datasets` or `providers` @@ -35,7 +35,7 @@ #' * \code{\link[=arrange.data_request]{arrange()}} Arrange rows of a query on the server side #' * \code{\link[=count.data_request]{count()}} Request counts of the specified data type #' * [desc()] Arrange counts in descending order (when combined with \code{\link[=arrange.data_request]{arrange()}}) -#' * \code{\link[=filter.data_request]{filter()}}/[galah_filter()] Filter records +#' * \code{\link[=filter.data_request]{filter()}}/[galah_filter()] Filter records (see also \code{\link[=filter_object_classes]{filter_object_classes}})) #' * [geolocate()]/[galah_geolocate()] Spatial filtering of a query #' * \code{\link[=group_by.data_request]{group_by()}}/[galah_group_by()] Group counts by one or more fields #' * \code{\link[=identify.data_request]{identify()}}/[galah_identify()] Search for taxonomic identifiers (see also \code{\link[=taxonomic_searches]{taxonomic_searches}}) @@ -43,12 +43,23 @@ #' * \code{\link[=slice_head.data_request]{slice_head()}} Choose the first n rows of a download #' * [unnest()] Expand metadata for `fields`, `lists`, `profiles` or `taxa` #' -#' **Execute a query via API** +#' **Object-oriented processes** #' -#' * \code{\link[=collapse.data_request]{collapse()}} Convert a `data_request` into a `query` +#' * [as_query()] Represent a `data_request` as a `query` object +#' * [coalesce()] Convert a `data_request` or `query` into a `query_set` showing all calls needed for evaluation +#' * \code{\link[=collapse.data_request]{collapse()}} Convert an object to a valid `query` #' * \code{\link[=compute.data_request]{compute()}} Compute a query #' * \code{\link[=collect.data_request]{collect()}}/\code{\link[=atlas_]{atlas_()}}/[collect_media()] Retrieve a database query #' +#' **Wrappers for accessing data** +#' +#' * [atlas_occurrences()] Download occurrence data +#' * [atlas_counts()] Get a summary of the number of records or species +#' * [atlas_species()] Download occurrences grouped by `speciesID` +#' * [atlas_taxonomy()] Download taxonomic trees +#' * [atlas_media()] Download media metadata linked to occurrences +#' * [collect_media()] Download media (images and sounds) +#' #' **Miscellaneous functions** #' #' * [atlas_citation()] Get a citation for a dataset @@ -67,18 +78,20 @@ #' **assertions** attached to the record. Each piece of information #' associated with a given occurrence record is stored in a **field**, #' which corresponds to a **column** when imported to an -#' `R data.frame`. See `show_all(fields)` to view valid fields, +#' `tibble`. See `show_all(fields)` to view valid fields, #' layers and assertions, or conduct a search using `search_all(fields)`. #' #' Data fields are important because they provide a means to **filter** #' occurrence records; i.e. to return only the information that you need, and #' no more. Consequently, much of the architecture of `galah` has been #' designed to make filtering as simple as possible. The easiest way to do this -#' is to start a pipe with `galah_call()` and follow it with the relevant -#' `dplyr` function; starting with `filter()`, but also including `select()`, -#' `group_by()` or others. Functions without a relevant `dplyr` synonym include -#' [galah_identify()]/`identify()` for choosing a taxon, or [galah_geolocate()]/ -#' `st_crop()` for choosing a specific location. By combining different filters, +#' is to start a pipe with [galah_call()] and follow it with the relevant +#' `dplyr` function; starting with \code{\link[=filter.data_request]{filter()}}, +#' but also including \code{\link[=select.data_request]{select()}}, +#' \code{\link[=group_by.data_request]{group_by()}} or others. Functions without +#' a relevant `dplyr` synonym include +#' \code{\link[=identify.data_request]{identify()}} for choosing a taxon, or +#' [geolocate()] for choosing a specific location. By combining different filters, #' it is possible to build complex queries to return only the most valuable #' information for a given problem. #' @@ -89,9 +102,8 @@ #' However, there are many possible data quality checks, and it is not always #' clear which are most appropriate in a given instance. Therefore, `galah` #' supports data quality **profiles**, which can be passed to -#' [galah_apply_profile()] to quickly remove undesirable records. A full list of -#' data quality profiles is returned by `show_all(profiles)`. Note this service -#' is currently only available for the Australian atlas (ALA). +#' [apply_profile()] to quickly remove undesirable records. A full list of +#' data quality profiles is returned by `show_all(profiles)`. #' #' @importFrom rlang caller_env #' @importFrom rlang .data diff --git a/R/galah_config.R b/R/galah_config.R index c464a2fc..0ed33427 100644 --- a/R/galah_config.R +++ b/R/galah_config.R @@ -1,18 +1,29 @@ -#' Get or set configuration options that control galah behaviour +#' View or set package behaviour #' -#' The `galah` package supports large data downloads, and also -#' interfaces with the ALA which requires that users of some services -#' provide a registered email address and reason for downloading data. The -#' `galah_config` function provides a way to manage these issues as simply -#' as possible. -#' -#' @param \dots Options can be defined using the form `name = "value"`. -#' Valid arguments are: +#' The `galah` package supports queries to a number of different data providers, +#' and once selected, it is desirable that all later queries are sent to that +#' organisation. Rather than supply this information separately in each +#' query, therefore, it is more parsimonious to cache that information centrally +#' and call it as needed, which is what this function supports. Beyond choosing +#' an organisation, there are several other use cases for caching. Many +#' GBIF nodes require the user to supply a registered email address, +#' password, and (in some cases) a reason for downloading data, all stored via +#' `galah_config()`. This function also provides a convenient place to control +#' optional package behaviours, such as checking queries to ensure they are +#' valid (`run_checks`), informing you via email when your downloads are ready +#' (`send_email`), or controlling whether galah will provide updates on your +#' query as they are processed (`verbose`). +#' @param \dots Options can be defined using the form `name = "value"`, or +#' as a (single) named list. See details for accepted fields. +#' @details +#' Valid arguments to this function are: #' #' * `api-key` string: A registered API key (currently unused). #' * `atlas` string: Living Atlas to point to, Australia by default. Can be #' an organisation name, acronym, or region (see [show_all_atlases()] for #' admissible values) +#' * `authenticate` logical: should `galah` authenticate your queries using +#' JWT tokens? Defaults to `FALSE`. #' * `directory` string: the directory to use for the cache. #' By default this is a temporary directory, which means that results will #' only be cached @@ -39,12 +50,9 @@ #' specific downloads for later citation. #' * `username` string: A registered username (GBIF only) #' * `verbose` logical: should `galah` give verbose such as progress bars? -#' Defaults to FALSE. -#' -#' @return For `galah_config()`, a `list` of all options. -#' When `galah_config(...)` is called with arguments, nothing is returned -#' but the configuration is set. -#' +#' Defaults to `FALSE`. +#' @return Returns an object with classes `galah_config` and `list`, invisibly +#' if arguments are supplied. #' @examples \dontrun{ #' # To download occurrence records, enter your email in `galah_config()`. #' # This email should be registered with the atlas in question. @@ -62,6 +70,10 @@ #' #' # Make debugging in your session easier by setting `verbose = TRUE` #' galah_config(verbose = TRUE) +#' +#' # Optionally supply arguments via a named list +#' list(email = "your-email@email.com") |> +#' galah_config() #' } #' @export galah_config <- function(...) { @@ -87,6 +99,15 @@ galah_config <- function(...) { names(dots)[dots_location] <- "directory" } + # add exception so that people can supply a named list to `galah_config()` + # this avoids calling things like: + # `x <- list(email = "something); do.call(galah_config, x)` + if(length(dots) == 1){ + if(is.list(dots[[1]])){ + dots <- dots[[1]] + } + } + # check all values in dots to ensure they are named if(length(dots) != length(names(dots))){ c("All arguments to `galah_config() must be named.", diff --git a/_pkgdown.yml b/_pkgdown.yml index 3f6d290b..c2241f5b 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -34,12 +34,18 @@ navbar: href: articles/narrow_your_results.html - text: Download data href: articles/download_data.html + - text: Downloading images and sounds + href: articles/downloading_images_and_sounds.html - text: --- - text: Advanced - text: Object-oriented programming href: articles/object_oriented_programming.html - text: Reproduciblility href: articles/download-data-reproducibly.html + - text: Accessing sensitive data + href: articles/accessing_sensitive_data.html + - text: --- + - text: Filtering - text: Taxonomic filtering href: articles/taxonomic_filtering.html - text: Spatial filtering @@ -53,8 +59,8 @@ navbar: reference: - title: Getting started contents: - - galah_call - galah_config + - galah_call - show_all - search_all - search_taxa @@ -80,11 +86,15 @@ reference: - select.data_request - slice_head.data_request - reexports -- title: Execute a query via API +- title: Object-oriented processes contents: + - as_query.data_request + - coalesce - collapse.data_request - compute.data_request - collect.data_request +- title: Wrappers for accessing data + contents: - atlas_occurrences - atlas_counts - atlas_species @@ -94,7 +104,8 @@ reference: - title: Miscellaneous functions contents: - taxonomic_searches - - print_galah_objects - read_zip - atlas_citation + - as_data_filter + - as_data_filter - title: internal diff --git a/man/galah.Rd b/man/galah.Rd index 15076889..d296b200 100644 --- a/man/galah.Rd +++ b/man/galah.Rd @@ -28,8 +28,8 @@ For those outside Australia, 'galah' is the common name of \strong{Getting Started} \itemize{ -\item \code{\link[=galah_call]{galah_call()}}/\code{\link[=request_data]{request_()}} Start to build a query \item \code{\link[=galah_config]{galah_config()}} Set package configuration options +\item \code{\link[=galah_call]{galah_call()}}/\code{\link[=request_data]{request_()}} Start to build a query \item \code{\link[=show_all]{show_all()}} & \code{\link[=search_all]{search_all()}} Data for generating filter queries \item \code{\link[=show_values]{show_values()}} & \code{\link[=search_values]{search_values()}} Show or search for values \emph{within} \code{fields}, \code{profiles}, \code{lists}, \code{collections}, \code{datasets} or \code{providers} @@ -41,7 +41,7 @@ For those outside Australia, 'galah' is the common name of \item \code{\link[=arrange.data_request]{arrange()}} Arrange rows of a query on the server side \item \code{\link[=count.data_request]{count()}} Request counts of the specified data type \item \code{\link[=desc]{desc()}} Arrange counts in descending order (when combined with \code{\link[=arrange.data_request]{arrange()}}) -\item \code{\link[=filter.data_request]{filter()}}/\code{\link[=galah_filter]{galah_filter()}} Filter records +\item \code{\link[=filter.data_request]{filter()}}/\code{\link[=galah_filter]{galah_filter()}} Filter records (see also \code{\link[=filter_object_classes]{filter_object_classes}})) \item \code{\link[=geolocate]{geolocate()}}/\code{\link[=galah_geolocate]{galah_geolocate()}} Spatial filtering of a query \item \code{\link[=group_by.data_request]{group_by()}}/\code{\link[=galah_group_by]{galah_group_by()}} Group counts by one or more fields \item \code{\link[=identify.data_request]{identify()}}/\code{\link[=galah_identify]{galah_identify()}} Search for taxonomic identifiers (see also \code{\link[=taxonomic_searches]{taxonomic_searches}}) @@ -50,13 +50,25 @@ For those outside Australia, 'galah' is the common name of \item \code{\link[=unnest]{unnest()}} Expand metadata for \code{fields}, \code{lists}, \code{profiles} or \code{taxa} } -\strong{Execute a query via API} +\strong{Object-oriented processes} \itemize{ -\item \code{\link[=collapse.data_request]{collapse()}} Convert a \code{data_request} into a \code{query} +\item \code{\link[=as_query]{as_query()}} Represent a \code{data_request} as a \code{query} object +\item \code{\link[=coalesce]{coalesce()}} Convert a \code{data_request} or \code{query} into a \code{query_set} showing all calls needed for evaluation +\item \code{\link[=collapse.data_request]{collapse()}} Convert an object to a valid \code{query} \item \code{\link[=compute.data_request]{compute()}} Compute a query \item \code{\link[=collect.data_request]{collect()}}/\code{\link[=atlas_]{atlas_()}}/\code{\link[=collect_media]{collect_media()}} Retrieve a database query } +\strong{Wrappers for accessing data} +\itemize{ +\item \code{\link[=atlas_occurrences]{atlas_occurrences()}} Download occurrence data +\item \code{\link[=atlas_counts]{atlas_counts()}} Get a summary of the number of records or species +\item \code{\link[=atlas_species]{atlas_species()}} Download occurrences grouped by \code{speciesID} +\item \code{\link[=atlas_taxonomy]{atlas_taxonomy()}} Download taxonomic trees +\item \code{\link[=atlas_media]{atlas_media()}} Download media metadata linked to occurrences +\item \code{\link[=collect_media]{collect_media()}} Download media (images and sounds) +} + \strong{Miscellaneous functions} \itemize{ \item \code{\link[=atlas_citation]{atlas_citation()}} Get a citation for a dataset @@ -78,18 +90,20 @@ also run a number of quality checks against each record, resulting in \strong{assertions} attached to the record. Each piece of information associated with a given occurrence record is stored in a \strong{field}, which corresponds to a \strong{column} when imported to an -\verb{R data.frame}. See \code{show_all(fields)} to view valid fields, +\code{tibble}. See \code{show_all(fields)} to view valid fields, layers and assertions, or conduct a search using \code{search_all(fields)}. Data fields are important because they provide a means to \strong{filter} occurrence records; i.e. to return only the information that you need, and no more. Consequently, much of the architecture of \code{galah} has been designed to make filtering as simple as possible. The easiest way to do this -is to start a pipe with \code{galah_call()} and follow it with the relevant -\code{dplyr} function; starting with \code{filter()}, but also including \code{select()}, -\code{group_by()} or others. Functions without a relevant \code{dplyr} synonym include -\code{\link[=galah_identify]{galah_identify()}}/\code{identify()} for choosing a taxon, or \code{\link[=galah_geolocate]{galah_geolocate()}}/ -\code{st_crop()} for choosing a specific location. By combining different filters, +is to start a pipe with \code{\link[=galah_call]{galah_call()}} and follow it with the relevant +\code{dplyr} function; starting with \code{\link[=filter.data_request]{filter()}}, +but also including \code{\link[=select.data_request]{select()}}, +\code{\link[=group_by.data_request]{group_by()}} or others. Functions without +a relevant \code{dplyr} synonym include +\code{\link[=identify.data_request]{identify()}} for choosing a taxon, or +\code{\link[=geolocate]{geolocate()}} for choosing a specific location. By combining different filters, it is possible to build complex queries to return only the most valuable information for a given problem. @@ -100,9 +114,8 @@ be used to filter out records that are unsuitable for particular applications. However, there are many possible data quality checks, and it is not always clear which are most appropriate in a given instance. Therefore, \code{galah} supports data quality \strong{profiles}, which can be passed to -\code{\link[=galah_apply_profile]{galah_apply_profile()}} to quickly remove undesirable records. A full list of -data quality profiles is returned by \code{show_all(profiles)}. Note this service -is currently only available for the Australian atlas (ALA). +\code{\link[=apply_profile]{apply_profile()}} to quickly remove undesirable records. A full list of +data quality profiles is returned by \code{show_all(profiles)}. } \seealso{ diff --git a/man/galah_config.Rd b/man/galah_config.Rd index 9faf623d..6800e9a0 100644 --- a/man/galah_config.Rd +++ b/man/galah_config.Rd @@ -2,18 +2,42 @@ % Please edit documentation in R/galah_config.R \name{galah_config} \alias{galah_config} -\title{Get or set configuration options that control galah behaviour} +\title{View or set package behaviour} \usage{ galah_config(...) } \arguments{ -\item{\dots}{Options can be defined using the form \code{name = "value"}. -Valid arguments are: +\item{\dots}{Options can be defined using the form \code{name = "value"}, or +as a (single) named list. See details for accepted fields.} +} +\value{ +Returns an object with classes \code{galah_config} and \code{list}, invisibly +if arguments are supplied. +} +\description{ +The \code{galah} package supports queries to a number of different data providers, +and once selected, it is desirable that all later queries are sent to that +organisation. Rather than supply this information separately in each +query, therefore, it is more parsimonious to cache that information centrally +and call it as needed, which is what this function supports. Beyond choosing +an organisation, there are several other use cases for caching. Many +GBIF nodes require the user to supply a registered email address, +password, and (in some cases) a reason for downloading data, all stored via +\code{galah_config()}. This function also provides a convenient place to control +optional package behaviours, such as checking queries to ensure they are +valid (\code{run_checks}), informing you via email when your downloads are ready +(\code{send_email}), or controlling whether galah will provide updates on your +query as they are processed (\code{verbose}). +} +\details{ +Valid arguments to this function are: \itemize{ \item \code{api-key} string: A registered API key (currently unused). \item \code{atlas} string: Living Atlas to point to, Australia by default. Can be an organisation name, acronym, or region (see \code{\link[=show_all_atlases]{show_all_atlases()}} for admissible values) +\item \code{authenticate} logical: should \code{galah} authenticate your queries using +JWT tokens? Defaults to \code{FALSE}. \item \code{directory} string: the directory to use for the cache. By default this is a temporary directory, which means that results will only be cached @@ -40,20 +64,8 @@ useful in some instances, for example for tracking DOIs assigned to specific downloads for later citation. \item \code{username} string: A registered username (GBIF only) \item \code{verbose} logical: should \code{galah} give verbose such as progress bars? -Defaults to FALSE. -}} +Defaults to \code{FALSE}. } -\value{ -For \code{galah_config()}, a \code{list} of all options. -When \code{galah_config(...)} is called with arguments, nothing is returned -but the configuration is set. -} -\description{ -The \code{galah} package supports large data downloads, and also -interfaces with the ALA which requires that users of some services -provide a registered email address and reason for downloading data. The -\code{galah_config} function provides a way to manage these issues as simply -as possible. } \examples{ \dontrun{ @@ -73,5 +85,9 @@ show_all(reasons) # Make debugging in your session easier by setting `verbose = TRUE` galah_config(verbose = TRUE) + +# Optionally supply arguments via a named list +list(email = "your-email@email.com") |> + galah_config() } } diff --git a/vignettes/accessing_sensitive_data.Rmd b/vignettes/accessing_sensitive_data.Rmd new file mode 100644 index 00000000..45c4ff46 --- /dev/null +++ b/vignettes/accessing_sensitive_data.Rmd @@ -0,0 +1,625 @@ +--- +title: "Accessing sensitive data" +author: "Martin Westgate" +date: "2025-10-29" +output: + rmarkdown::html_vignette +vignette: > + %\VignetteIndexEntry{Accessing sensitive data} + %\VignetteEngine{knitr::rmarkdown} + %\VignetteEncoding{UTF-8} +--- + + +`galah` provides access to biodiversity information stored by GBIF and its' +partner nodes. While much of this information is shared freely and without +restriction, there are a number of cases where this is not appropriate, such +as the locations of: + + - threatened species, whose locations are protected by law in many jurisdictions + - species at risk from poachers, particularly for wildlife trade + +This leads to a problem: data that is more open helps scientists and policy-makers +understand and conserve biodiversity, but also generates threats to those +species. To balance these concerns against one another, it is common to +'obfuscate' sensitive data, which consists of providing location information +at a lower spatial resolution, making it harder to locate threatened species on +the ground. + +# How ALA handles sensitive data + +While many data providers solve this problem themselves in a range of ways - +such as by not sharing data at all, randomising some locations, or reducing +the number of decimal places of their locations - others provide high-precision +data on the understanding that it will only be made available to specific users, +and then only by written agreement. In these cases, the ALA displays the +obfuscated data publicly, but retains the original data for use for specific +purposes. Researchers can request access to the original data via the National +Framework for the Sharing of Restricted Access Species Data in Australia, more +simply known as the 'RASD framework' (https://www.rasd.org.au). + +If your access to sensitive data is approved by the provider(s) in question, +from version 2.2.0 you can use 'galah' to access that sensitive data. + +# Switching on authentication + +First, you will need to add your email address and password, as usual for +downloads via `galah`. Because you might want to share your script at some +point, we do not recommend that you simply type these into your script. Instead, +if you save your information in json format, you can import it directly to +R without ever showing the text in your script. For example, if you save this +text into a file: + + +``` r +{"email":"my.email@email.com", "password":"the-most-secure-password-ever"} +``` + +Then, if we import this from JSON to a list, we can pass it directly to +`galah_config()` without typing secret information into our script. + + +``` r +jsonlite::fromJSON("my_secret_information.txt") |> + galah_config() +``` + +The second step is to switch on authentication, also via `galah_config()`. +This downloads a client ID and set of URLs from the ALA that enable +authentication, and also triggers later queries to run an authentication process. + + +``` r +galah_config(authenticate = TRUE) +``` + +That's it! What happens next is that your first query will trigger your browser +to open. Once you have successfully signed in to the ALA, later queries will +have the full set of permissions available to you via `galah`. + +# Deciding what sensitive data to access + +In the the ALA, sensitive data are stored in bespoke fields, which have an +existing field name prefixed with `sensitive_`. They cannot be requested +directly. Instead, if you request a field that has a sensitive counterpart, both +the public and sensitive version of that field will be returned. + + +``` +## +## Attaching package: 'gt' +``` + +``` +## The following object is masked from 'package:testthat': +## +## matches +``` + +

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    public_fieldrestricted_field
    eventIDsensitive_eventID
    eventDatesensitive_eventDate
    eventTimesensitive_eventTime
    monthsensitive_month
    daysensitive_day
    localitysensitive_locality
    locationRemarkssensitive_locationRemarks
    decimalLatitudesensitive_decimalLatitude
    decimalLongitudesensitive_decimalLongitude
    footprintWKTsensitive_footprintWKT
    verbatimEventDatesensitive_verbatimEventDate
    verbatimLocalitysensitive_verbatimLocality
    verbatimCoordinatessensitive_verbatimCoordinates
    verbatimLatitudesensitive_verbatimLatitude
    verbatimLongitudesensitive_verbatimLongitude
    +
    + + +So to download data, we might try something like: + +``` +result <- galah_call() |> + filter(species_list_uid == "dr491") |> + collect() + +> result +# A tibble: 487 × 12 + recordID scientificName taxonConceptID decimalLatitude sensitive_decimalLat…¹ decimalLongitude + + 1 00825ab0-… Caladenia vul… https://id.bi… -37.8 NA 145. + 2 0094e7df-… Caladenia vul… https://id.bi… -37.7 NA 141. + 3 00d3a4e3-… Caladenia vul… https://id.bi… -39.6 NA 147. + 4 02305849-… Caladenia vul… https://id.bi… -38.0 NA 145. + 5 02e28dc1-… Caladenia vul… https://id.bi… -37.7 NA 145. + 6 068b4c68-… Caladenia vul… https://id.bi… -37.7 NA 141. + 7 07b5356b-… Caladenia vul… https://id.bi… -37.0 NA 143. + 8 0807fcbe-… Caladenia vul… https://id.bi… -37.9 NA 145. + 9 093df7f4-… Caladenia vul… https://id.bi… -37.9 NA 145. +10 0942b709-… Caladenia vul… https://id.bi… -38.4 NA 145. +# ℹ 477 more rows +# ℹ abbreviated name: ¹​sensitive_decimalLatitude +# ℹ 6 more variables: sensitive_decimalLongitude , eventDate , +# sensitive_eventDate , basisOfRecord , occurrenceStatus , dataResourceName +# ℹ Use `print(n = ...)` to see more rows + +> colnames(result) + [1] "recordID" "scientificName" "taxonConceptID" + [4] "decimalLatitude" "sensitive_decimalLatitude" "decimalLongitude" + [7] "sensitive_decimalLongitude" "eventDate" "sensitive_eventDate" +[10] "basisOfRecord" "occurrenceStatus" "dataResourceName" +``` diff --git a/vignettes/accessing_sensitive_data.Rmd.orig b/vignettes/accessing_sensitive_data.Rmd.orig new file mode 100644 index 00000000..3602c1ba --- /dev/null +++ b/vignettes/accessing_sensitive_data.Rmd.orig @@ -0,0 +1,137 @@ +--- +title: "Accessing sensitive data" +author: "Martin Westgate" +date: "2025-10-29" +output: + rmarkdown::html_vignette +vignette: > + %\VignetteIndexEntry{Accessing sensitive data} + %\VignetteEngine{knitr::rmarkdown} + %\VignetteEncoding{UTF-8} +--- +```{r, include = FALSE} +galah_config(atlas = "Australia", verbose = FALSE) +``` + +`galah` provides access to biodiversity information stored by GBIF and its' +partner nodes. While much of this information is shared freely and without +restriction, there are a number of cases where this is not appropriate, such +as the locations of: + + - threatened species, whose locations are protected by law in many jurisdictions + - species at risk from poachers, particularly for wildlife trade + +This leads to a problem: data that is more open helps scientists and policy-makers +understand and conserve biodiversity, but also generates threats to those +species. To balance these concerns against one another, it is common to +'obfuscate' sensitive data, which consists of providing location information +at a lower spatial resolution, making it harder to locate threatened species on +the ground. + +# How ALA handles sensitive data + +While many data providers solve this problem themselves in a range of ways - +such as by not sharing data at all, randomising some locations, or reducing +the number of decimal places of their locations - others provide high-precision +data on the understanding that it will only be made available to specific users, +and then only by written agreement. In these cases, the ALA displays the +obfuscated data publicly, but retains the original data for use for specific +purposes. Researchers can request access to the original data via the National +Framework for the Sharing of Restricted Access Species Data in Australia, more +simply known as the 'RASD framework' (https://www.rasd.org.au). + +If your access to sensitive data is approved by the provider(s) in question, +from version 2.2.0 you can use 'galah' to access that sensitive data. + +# Switching on authentication + +First, you will need to add your email address and password, as usual for +downloads via `galah`. Because you might want to share your script at some +point, we do not recommend that you simply type these into your script. Instead, +if you save your information in json format, you can import it directly to +R without ever showing the text in your script. For example, if you save this +text into a file: + +```{r, eval = FALSE} +{"email":"my.email@email.com", "password":"the-most-secure-password-ever"} +``` + +Then, if we import this from JSON to a list, we can pass it directly to +`galah_config()` without typing secret information into our script. + +```{r, eval = FALSE} +jsonlite::fromJSON("my_secret_information.txt") |> + galah_config() +``` + +The second step is to switch on authentication, also via `galah_config()`. +This downloads a client ID and set of URLs from the ALA that enable +authentication, and also triggers later queries to run an authentication process. + +```{r, eval = FALSE} +galah_config(authenticate = TRUE) +``` + +That's it! What happens next is that your first query will trigger your browser +to open. Once you have successfully signed in to the ALA, later queries will +have the full set of permissions available to you via `galah`. + +# Deciding what sensitive data to access + +In the the ALA, sensitive data are stored in bespoke fields, which have an +existing field name prefixed with `sensitive_`. They cannot be requested +directly. Instead, if you request a field that has a sensitive counterpart, both +the public and sensitive version of that field will be returned. + +```{r, echo = FALSE} +library(gt) +library(tibble) + +fields <- c("eventID", "eventDate", + "eventTime", "month", "day", + "locality", "locationRemarks", + "decimalLatitude", "decimalLongitude", + "footprintWKT", + "verbatimEventDate", "verbatimLocality", + "verbatimCoordinates", "verbatimLatitude", + "verbatimLongitude") +tibble::tibble(public_field = fields, + restricted_field = glue::glue("sensitive_{fields}")) |> + gt::gt() |> + gt::cols_align(align = "left") +``` + + +So to download data, we might try something like: + +``` +result <- galah_call() |> + filter(species_list_uid == "dr491") |> + collect() + +> result +# A tibble: 487 × 12 + recordID scientificName taxonConceptID decimalLatitude sensitive_decimalLat…¹ decimalLongitude + + 1 00825ab0-… Caladenia vul… https://id.bi… -37.8 NA 145. + 2 0094e7df-… Caladenia vul… https://id.bi… -37.7 NA 141. + 3 00d3a4e3-… Caladenia vul… https://id.bi… -39.6 NA 147. + 4 02305849-… Caladenia vul… https://id.bi… -38.0 NA 145. + 5 02e28dc1-… Caladenia vul… https://id.bi… -37.7 NA 145. + 6 068b4c68-… Caladenia vul… https://id.bi… -37.7 NA 141. + 7 07b5356b-… Caladenia vul… https://id.bi… -37.0 NA 143. + 8 0807fcbe-… Caladenia vul… https://id.bi… -37.9 NA 145. + 9 093df7f4-… Caladenia vul… https://id.bi… -37.9 NA 145. +10 0942b709-… Caladenia vul… https://id.bi… -38.4 NA 145. +# ℹ 477 more rows +# ℹ abbreviated name: ¹​sensitive_decimalLatitude +# ℹ 6 more variables: sensitive_decimalLongitude , eventDate , +# sensitive_eventDate , basisOfRecord , occurrenceStatus , dataResourceName +# ℹ Use `print(n = ...)` to see more rows + +> colnames(result) + [1] "recordID" "scientificName" "taxonConceptID" + [4] "decimalLatitude" "sensitive_decimalLatitude" "decimalLongitude" + [7] "sensitive_decimalLongitude" "eventDate" "sensitive_eventDate" +[10] "basisOfRecord" "occurrenceStatus" "dataResourceName" +``` \ No newline at end of file diff --git a/vignettes/downloading_images_and_sounds.Rmd b/vignettes/downloading_images_and_sounds.Rmd new file mode 100644 index 00000000..7f1d11f3 --- /dev/null +++ b/vignettes/downloading_images_and_sounds.Rmd @@ -0,0 +1,15 @@ +--- +title: "Downloading images and sounds" +author: "Martin Westgate" +date: '2025-10-20' +output: + rmarkdown::html_vignette +vignette: > + %\VignetteIndexEntry{Downloading images and sounds} + %\VignetteEngine{knitr::rmarkdown} + %\VignetteEncoding{UTF-8} +--- + + + +This is a placeholder only diff --git a/vignettes/downloading_images_and_sounds.Rmd.orig b/vignettes/downloading_images_and_sounds.Rmd.orig new file mode 100644 index 00000000..3c448275 --- /dev/null +++ b/vignettes/downloading_images_and_sounds.Rmd.orig @@ -0,0 +1,18 @@ +--- +title: "Downloading images and sounds" +author: "Martin Westgate" +date: '2025-10-20' +output: + rmarkdown::html_vignette +vignette: > + %\VignetteIndexEntry{Downloading images and sounds} + %\VignetteEngine{knitr::rmarkdown} + %\VignetteEncoding{UTF-8} +--- + +```{r, include = FALSE} +# set up galah session +galah_config(email = "ala4r@ala.org.au", verbose = FALSE) +``` + +This is a placeholder only \ No newline at end of file diff --git a/vignettes/precompile.R b/vignettes/precompile.R index 394abe43..635b9608 100644 --- a/vignettes/precompile.R +++ b/vignettes/precompile.R @@ -43,9 +43,9 @@ all_files <- list.files(folder) selected_files <- paste0(folder, all_files[grepl(".orig$", all_files)]) out_files <- sub(".orig$", "", selected_files) -lapply( +purrr::map( seq_along(selected_files), - function(a){ + \(a){ knit(selected_files[[a]], out_files[[a]]) }) @@ -54,4 +54,4 @@ lapply( # # next steps devtools::build_vignettes() -pkgdown::build_site() +pkgdown::build_site() \ No newline at end of file From 31568381d0424f9d8f3f7c75a1ddba199bc95257 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Wed, 29 Oct 2025 14:14:02 +1100 Subject: [PATCH 40/94] Minor bug fixes for authentication (#189) - authentication with pkce doesn't require `secret`, removed - ensure `galah_config()` always checks for authentication and caches config if activated - ensure `sensitive_` fields don't trigger `check_field_identities()` (because not present in `select()` - remove `api-key` content from `build_headers()`, `galah_config()` --- R/build_query.R | 14 -------- R/check.R | 15 ++++++--- R/collect_occurrences.R | 2 +- R/galah_config.R | 49 +++++++++++++++------------- R/onload.R | 8 +++-- R/query_API.R | 11 +------ _pkgdown.yml | 2 +- man/galah_config.Rd | 1 - tests/testthat/test-authentication.R | 22 ++++++++----- 9 files changed, 59 insertions(+), 65 deletions(-) diff --git a/R/build_query.R b/R/build_query.R index 4bea431e..5b524ff8 100644 --- a/R/build_query.R +++ b/R/build_query.R @@ -3,20 +3,6 @@ #' @keywords Internal build_headers <- function(){ list("User-Agent" = galah_version_string()) - ## Below code adds API keys (not yet implemented) - ## Add these as optional - # if(pour("atlas", "acronym") == "ALA"){ - # list( - # "User-Agent" = galah_version_string(), - # "x-api-key" = pour("user", "api_key") - ## if(){ # something about if we're using JWT tokens - ## list( - ## "User-Agent" = galah_version_string(), - ##. "Authorization" = paste("Bearer", access_token)) - ## } - # }else{ - # list("User-Agent" = galah_version_string()) - # } } #' Build query list from constituent arguments diff --git a/R/check.R b/R/check.R index 88a29783..de90ed63 100644 --- a/R/check.R +++ b/R/check.R @@ -216,10 +216,17 @@ check_field_identities <- function(df, added_check <- !(field_names %in% .query$fields) if(any(added_check)){ added_fields <- field_names[added_check] - names(added_fields) <- rep("*", length(added_fields)) - c("The following fields were downloaded, but weren't requested in your query:", - added_fields) |> - cli::cli_warn(call = error_call) + # if authentication has occurred, remove `sensitive_` fields + if(potions::pour("package", "authenticate", .pkg = "galah")){ + added_fields <- added_fields[!stringr::str_detect(added_fields, "^sensitive")] + } + # then, if any remain, warn + if(length(added_fields) > 0){ + names(added_fields) <- rep("*", length(added_fields)) + c("The following fields were downloaded, but weren't requested in your query:", + added_fields) |> + cli::cli_warn(call = error_call) + } } } df diff --git a/R/collect_occurrences.R b/R/collect_occurrences.R index d62a48f0..45f2122e 100644 --- a/R/collect_occurrences.R +++ b/R/collect_occurrences.R @@ -76,7 +76,7 @@ collect_occurrences_default <- function(.query, wait, file, call){ return(tibble::tibble()) }else{ result <- result |> - check_field_identities(.query) |> + check_field_identities(.query, error_call = call) |> check_media_cols() # check for, and then clean, media info # exception for GBIF to ensure DOIs are preserved if(!is.null(download_response$doi)){ diff --git a/R/galah_config.R b/R/galah_config.R index 0ed33427..d0ff8a43 100644 --- a/R/galah_config.R +++ b/R/galah_config.R @@ -18,7 +18,6 @@ #' @details #' Valid arguments to this function are: #' -#' * `api-key` string: A registered API key (currently unused). #' * `atlas` string: Living Atlas to point to, Australia by default. Can be #' an organisation name, acronym, or region (see [show_all_atlases()] for #' admissible values) @@ -120,28 +119,6 @@ galah_config <- function(...) { # look up what information has been given, write specific callouts for unusual cases supplied_names <- names(result) - - # if authentication is requested, cache config info - # NOTE: This is the only place in galah where we _silently_ query - # an API. For safety and clarity reasons, I've added the following steps: - # 1. giving some notice to the user that this has been performed - # 2. adding a warning message if the API call fails - if(any(supplied_names == "authenticate")){ - if(isTRUE(result$authenticate) & # value set to TRUE by user - is.null(retrieve_cache("config")) # not already cached - ){ - cli::cli_inform("Caching `config` information to support authentication") - config <- request_metadata(type = "config") |> - collect() |> - try(silent = TRUE) - if(inherits(config, "try-error")){ - c("`galah_config()` tried caching `config` information for authentication purposes, but failed.", - i = "This could mean you are offline or that the API is unavailable.", - i = "To try again, call `show_all_config()` or `galah_config(authenticate = TRUE)`") |> - cli::cli_warn() - } - } - } # add to `potions` object if(any(supplied_names == "atlas")){ @@ -155,6 +132,7 @@ galah_config <- function(...) { # invisibly return x <- potions::pour() + check_authentication(x) structure(x, class = c("galah_config", "list")) |> invisible() @@ -162,6 +140,7 @@ galah_config <- function(...) { }else{ # visibly return x <- potions::pour() + check_authentication(x) structure(x, class = c("galah_config", "list")) } @@ -364,4 +343,28 @@ check_atlas <- function(current_data, new_data){ "Atlas selected: {new_data$organisation} ({new_data$acronym}) [{new_data$region}]") } new_data +} + +#' if authentication is requested, cache config info +#' @noRd +#' @keywords Internal +check_authentication <- function(x){ + # NOTE: This is the only place in galah where we _silently_ query + # an API. For safety and clarity reasons, I've added the following steps: + # 1. giving some notice to the user that this has been performed + # 2. adding a warning message if the API call fails + if(isTRUE(purrr::pluck(x, "package", "authenticate")) & # value set to TRUE by user + is.null(retrieve_cache("config")) # not already cached + ){ + cli::cli_inform("Caching `config` information to support authentication") + config <- request_metadata(type = "config") |> + collect() |> + try(silent = TRUE) + if(inherits(config, "try-error")){ + c("`galah_config()` tried caching `config` information for authentication purposes, but failed.", + i = "This could mean you are offline or that the API is unavailable.", + i = "To try again, call `show_all_config()` or `galah_config(authenticate = TRUE)`") |> + cli::cli_warn() + } + } } \ No newline at end of file diff --git a/R/onload.R b/R/onload.R index 06b66570..40e3be04 100644 --- a/R/onload.R +++ b/R/onload.R @@ -5,9 +5,10 @@ if (pkgname == "galah") { # set up storage of standard information via {potions} - potions::brew(.pkg = "galah") - galah_config() # to cache defaults - reset_cache() + reset_cache() # remove previously stored data + potions::brew(.pkg = "galah") # set up caching of behaviour + quiet_config <- purrr::quietly(galah_config) + config_info <- quiet_config() # to cache defaults without raising a message # get information to display to the user ## get the galah version, if we can @@ -15,6 +16,7 @@ suppressWarnings( try(galah_version <- utils::packageDescription("galah")[["Version"]], silent = TRUE)) + # show currently-selected atlas current_node <- potions::pour("atlas", .pkg = "galah") |> purrr::pluck("acronym") diff --git a/R/query_API.R b/R/query_API.R index 899db3a7..ad0a3296 100644 --- a/R/query_API.R +++ b/R/query_API.R @@ -85,6 +85,7 @@ query_API_internal <- function(.query, httr2::req_oauth_auth_code(client = build_auth_client(auth_config), auth_url = dplyr::pull(auth_config, "authorize_url"), scope = dplyr::pull(auth_config, "scopes"), + pkce = TRUE, cache_disk = TRUE) } } @@ -122,18 +123,8 @@ query_API_internal <- function(.query, #' @noRd #' @keywords Internal build_auth_client <- function(config){ - # setting a temporary fail to force developers to enter secrets manually. - # once that is done, comment out this line - cli::cli_abort("galah dev team: Please manually enter `secret` to `build_auth_client()`") - - # this is the actual code: httr2::oauth_client( id = dplyr::pull(config, "client_id"), - # secret = purrr::pluck(config, "client_secret"), # future code - ## NOTE: cognito (test) doesn't need a secret, but CAS (prod) does - ## unclear whether we'll solve this by configuring CAS to generate a secret, - ## or CAS to ignore one. - secret = "ADD-SECRET-HERE", # NOTE: temporary fix, *DO NOT SHARE REAL SECRETS* token_url = dplyr::pull(config, "token_url")) } diff --git a/_pkgdown.yml b/_pkgdown.yml index c2241f5b..2cc52b01 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -107,5 +107,5 @@ reference: - read_zip - atlas_citation - as_data_filter - - as_data_filter + - print_galah_objects - title: internal diff --git a/man/galah_config.Rd b/man/galah_config.Rd index 6800e9a0..17fd55e7 100644 --- a/man/galah_config.Rd +++ b/man/galah_config.Rd @@ -32,7 +32,6 @@ query as they are processed (\code{verbose}). \details{ Valid arguments to this function are: \itemize{ -\item \code{api-key} string: A registered API key (currently unused). \item \code{atlas} string: Living Atlas to point to, Australia by default. Can be an organisation name, acronym, or region (see \code{\link[=show_all_atlases]{show_all_atlases()}} for admissible values) diff --git a/tests/testthat/test-authentication.R b/tests/testthat/test-authentication.R index 289717b8..5aeec8ae 100644 --- a/tests/testthat/test-authentication.R +++ b/tests/testthat/test-authentication.R @@ -56,19 +56,19 @@ test_that("setting `authentication` to `TRUE` changes data returned", { skip_if(!file.exists(".secure-credentials"), "Secret information not provided") - # load credentials - do.call(galah_config, - jsonlite::fromJSON(".secure-credentials")) - # add other options - config <- quiet_config(directory = "TEST-SENSITIVE-DATA", - authenticate = TRUE, - run_checks = FALSE) + # load credentials, set authenticate to TRIE + config <- c( + jsonlite::fromJSON(".secure-credentials"), + list(directory = "TEST-SENSITIVE-DATA", + authenticate = TRUE)) |> + quiet_config() # These credentials *should* give access to sensitive data for Tasmania *only* # subset to species on Tasmania's sensitive species list result <- galah_call() |> filter(species_list_uid == "dr491") |> - collect() + collect() |> + expect_no_error() # check for exception to `check_field_identities()` # check sensitive columns exist colnames(result) |> @@ -100,4 +100,10 @@ test_that("setting `authentication` to `TRUE` changes data returned", { directory = "TESTING") }) +# Downloading from a DOI fails +# galah_call() |> +# filter(doi == "ala.3d0e08ac-d0ec-420d-a1f7-8cde778e82f6") |> +# collect() +# May be same problem as previously documented + rm(quiet_config) \ No newline at end of file From 13271c6cd3b7cfc757a4847f0e26c8e6203d0a9e Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Tue, 4 Nov 2025 16:40:51 +1100 Subject: [PATCH 41/94] First pass at revised authentication architecture (#189) Still testing this, but new approach would have three ways to authenticate; - via a new bespoke function `authenticate()` - via `galah_config(authenticate = TRUE)` - in-pipe via `use_authentication()` This approach also uses the `coaelsce()` mechanism to ensure `show_all_config()` is available --- NAMESPACE | 2 + R/as_query-occurrences.R | 10 +- R/as_query.R | 14 +-- R/authenticate.R | 113 ++++++++++++++++++++ R/check.R | 10 ++ R/coalesce.R | 28 ++++- R/compute_occurrences.R | 24 ++--- R/galah_config.R | 12 ++- R/onload.R | 1 - R/query_API.R | 7 +- man/authenticate.Rd | 23 ++++ man/coalesce.Rd | 3 + tests/testthat/test-authentication.R | 67 +++++++++--- vignettes/accessing_sensitive_data.Rmd.orig | 27 +++++ 14 files changed, 289 insertions(+), 52 deletions(-) create mode 100644 R/authenticate.R create mode 100644 man/authenticate.Rd diff --git a/NAMESPACE b/NAMESPACE index ae0007c0..119baf9e 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -8,6 +8,7 @@ S3method(as_query,metadata_request) S3method(coalesce,data_request) S3method(coalesce,files_request) S3method(coalesce,metadata_request) +S3method(coalesce,query) S3method(collapse,data_request) S3method(collapse,files_request) S3method(collapse,metadata_request) @@ -58,6 +59,7 @@ export(atlas_media) export(atlas_occurrences) export(atlas_species) export(atlas_taxonomy) +export(authenticate) export(coalesce) export(collapse) export(collect) diff --git a/R/as_query-occurrences.R b/R/as_query-occurrences.R index 4249885d..7ce9fb4b 100644 --- a/R/as_query-occurrences.R +++ b/R/as_query-occurrences.R @@ -46,8 +46,7 @@ as_query_occurrences_uk <- function(.query, ...){ headers = build_headers(), filter = .query$filter, select = .query$select) - class(result) <- "query" - return(result) + as_query(result) } #' calculate the query to be returned for GBIF @@ -76,8 +75,7 @@ as_query_occurrences_gbif <- function(.query, identify = .query$identify, geolocate = .query$geolocate, format = "SIMPLE_CSV")) - class(result) <- "query" - return(result) + as_query(result) } #' calculate the query to be returned for a given living atlas @@ -120,6 +118,6 @@ as_query_occurrences_la <- function(.query, headers = build_headers(), filter = .query$filter, select = .query$select) - class(result) <- "query" - return(result) + + as_query(result) } diff --git a/R/as_query.R b/R/as_query.R index 3b46f4ac..ec7b93d5 100644 --- a/R/as_query.R +++ b/R/as_query.R @@ -80,7 +80,8 @@ as_query.data_request <- function(x, "species-count" = as_query_species_count(x), "distributions" = as_query_distributions_data(x), cli::cli_abort("Unrecognised 'type'")) |> - structure(class = c("query", "list")) + check_authentication(source = x) |> + as_query() } #' @rdname as_query.data_request @@ -111,7 +112,8 @@ as_query.metadata_request <- function(x, ...){ "identifiers" = as_query_identifiers(x), cli::cli_abort("Unrecognised 'type'") ) |> - structure(class = c("query", "list")) + check_authentication(source = x) |> + as_query() } #' @rdname as_query.data_request @@ -126,10 +128,10 @@ as_query.files_request <- function(x, # for future file types # This code is identical to `collapse.files_request()` - result <- list(switch(x$type, + list(switch(x$type, "media" = as_query_media_files(x, thumbnail = thumbnail) - )) - class(result) <- "query_set" - result + )) |> + check_authentication(source = x) |> + as_query() # NOTE: previously returned `query_set` } \ No newline at end of file diff --git a/R/authenticate.R b/R/authenticate.R new file mode 100644 index 00000000..ac9a3dc2 --- /dev/null +++ b/R/authenticate.R @@ -0,0 +1,113 @@ +#' Set up authentication +#' +#' This is early-stage code. It's purpose is to trigger the browser to generate +#' a JWT token. [authenticate()] sets `galah_config(authenticate = TRUE)`, but +#' differs in triggering authentication on execution (which `galah_config()` +#' does not). [use_authentication()] is an in-pipe method for setting +#' authentication and associated behaviour. It is primarily intended for +#' internal use, but is exported for completeness and debugging purposes. +#' @name authenticate +#' @details +#' By default, authentication is only triggered during occurrence downloads +#' and query uploads. This can be overrided by calling [use_authentication()]. +#' @export +authenticate <- function(){ + + # offer user menu to confirm if not in batch run (testthat or knitr) + if(rlang::is_interactive()){ + + choice <- cli_menu( + c(" ", + "This function will open an authentication screen in your browser", + " "), + "Do you want to continue? (0 to exit)", + choices = c("Yes", "No") + ) + + if (choice == 1) { + # authenticate now + # NOTE: I'm trialling the use of `oauth_flow_auth_code()` here. + # This *should* generate a request to the browser without requiring a + # dummy API call; but hasn't been found to work yet. + auth_config <- show_all_config() # cache config info + galah_config(authenticate = TRUE) # cache authentication behaviour + result <- httr2::oauth_flow_auth_code( + client = build_auth_client(auth_config), + auth_url = dplyr::pull(auth_config, "authorize_url"), + scope = dplyr::pull(auth_config, "scopes"), + pkce = TRUE) + } else { + cli::cli_inform(c( + i = "Exiting..." + )) + # exits process quietly + invokeRestart("abort") + } + invisible() + + } +} + +#' @rdname authenticate +use_authentication <- function(.data, + cache_disk = FALSE){ + .data$authenticate <- list( + use_jwt = TRUE, + use_apikey = FALSE, # not supported yet + cache_disk = cache_disk) + .data +} + + +#' Interactive menu function +#' @description +#' Built on top of utils::menu(). +#' Originally proposed by Hadley here: https://github.com/r-lib/cli/issues/228#issuecomment-1453614104 +#' Full code from gargle here: https://github.com/r-lib/gargle/blob/main/R/utils-ui.R +#' This version borrowed verbatim from `galaxias` v. 0.1.0 +#' @noRd +#' @keywords Internal +cli_menu <- function(header, + prompt, + choices, + not_interactive = choices, + exit = integer(), + .envir = rlang::caller_env(), + error_call = rlang::caller_env()) { + if (!rlang::is_interactive()) { + cli::cli_abort( + c(header, not_interactive), + .envir = .envir, + call = error_call + ) + } + + choices <- paste0(cli::style_bold(seq_along(choices)), ": ", choices) + cli::cli_inform( + c(header, prompt, choices), + .envir = .envir + ) + + repeat { + selected <- cli::cli_readline("Selection: ") + if (selected %in% c("0", seq_along(choices))) { + break + } + cli::cli_inform( + "Enter a number between 1 and {length(choices)}, or enter 0 to exit." + ) + } + + selected <- as.integer(selected) + if (selected %in% c(0, exit)) { + if (is_testing()) { + cli::cli_abort("Exiting...", call = NULL) + } else { + cli::cli_alert_danger("Exiting...") + # simulate user pressing Ctrl + C + invokeRestart("abort") + } + } + + selected +} \ No newline at end of file diff --git a/R/check.R b/R/check.R index de90ed63..92c6c7e0 100644 --- a/R/check.R +++ b/R/check.R @@ -12,6 +12,16 @@ check_atlas_inputs <- function(args){ } } +#' Internal function to pass authentication information forward +#' @noRd +#' @keywords Internal +check_authentication <- function(x, source){ + if(!is.null(source$authenticate)){ + x$authenticate <- source$authenticate + } + x +} + #' Internal function to check for `data_request`s #' @noRd #' @keywords Internal diff --git a/R/coalesce.R b/R/coalesce.R index bae7cba0..146152f2 100644 --- a/R/coalesce.R +++ b/R/coalesce.R @@ -50,6 +50,12 @@ coalesce.metadata_request <- function(x, ...){ # create an empty object to store results result <- list() + # handle authentication + if(!is.null(x$authenticate)){ + result <- append(result, + list(request_metadata("config") |> as_query())) + } + # add checks if required if(potions::pour("package", "run_checks")){ result <- append(result, @@ -96,11 +102,23 @@ coalesce.files_request <- function(x, # NOTE: switch is technically superfluous right now, but could be useful # for future file types list(switch(x$type, - "media" = as_query_media_files(x, ...) + "media" = as_query_media_files(x, ...) )) |> structure(class = "query_set") } +#' @rdname coalesce +#' @order 5 +#' @export +coalesce.query <- function(x, ...){ + type_extracted <- stringr::str_extract(x$type, "^[[:alnum:]]+/") |> + stringr::str_remove("/$") + switch(type_extracted, + "metadata" = coalesce.metadata_request(x, ...), + "data" = coalesce.data_request(x, ...), + "files" = coalesce.files_request(x, ...)) +} + #' Internal function to build a `query_set` object #' for object of class `data_request` #' @noRd @@ -124,6 +142,14 @@ build_query_set_data <- function(x, mint_doi, ...){ # set up an object result <- list() + # handle authentication + if(isTRUE(potions::pour("package", "authenticate", .pkg = "galah")) | + !is.null(x$authenticate) + ){ + result <- append(result, + list(request_metadata("config") |> as_query())) + } + # handle `run_checks` fields_absent <- purrr::map( x[c("arrange", "filter", "select", "group_by")], diff --git a/R/compute_occurrences.R b/R/compute_occurrences.R index cddf8dbf..412e059b 100644 --- a/R/compute_occurrences.R +++ b/R/compute_occurrences.R @@ -14,10 +14,9 @@ compute_occurrences <- function(.query){ #' @noRd #' @keywords Internal compute_occurrences_la_direct <- function(.query){ - result <- c(.query, - list(fields = extract_fields(.query))) - class(result) <- "computed_query" - result + c(.query, + list(fields = extract_fields(.query))) |> + structure(class = "computed_query") } #' Internal function to `compute()` for `type = "occurrences"` for GBIF @@ -31,11 +30,9 @@ compute_occurrences_gbif <- function(.query){ url = glue::glue("https://api.gbif.org/v1/occurrence/download/{post_result}")) |> query_API() |> check_occurrence_response() - result <- c( - list(type = "data/occurrences"), - status_code) - class(result) <- "computed_query" - return(result) + c(list(type = "data/occurrences"), + status_code) |> + structure(class = "computed_query") } #' Internal function to `compute()` for `type = "occurrences"` for ALA @@ -51,12 +48,11 @@ compute_occurrences_la <- function(.query){ cli::cli_inform("Request for {n_records} occurrences placed in queue") } # return a useful object - result <- c( - list(type = "data/occurrences"), + c(list(type = "data/occurrences"), status_code, - list(fields = extract_fields(.query))) - class(result) <- "computed_query" - result + list(fields = extract_fields(.query))) |> + check_authentication(source = .query) |> + structure(class = "computed_query") } #' Internal function to get the `fields` vector from a url diff --git a/R/galah_config.R b/R/galah_config.R index d0ff8a43..3ded94e4 100644 --- a/R/galah_config.R +++ b/R/galah_config.R @@ -132,7 +132,7 @@ galah_config <- function(...) { # invisibly return x <- potions::pour() - check_authentication(x) + # check_authentication_argument(x) structure(x, class = c("galah_config", "list")) |> invisible() @@ -140,7 +140,7 @@ galah_config <- function(...) { }else{ # visibly return x <- potions::pour() - check_authentication(x) + # check_authentication_argument(x) structure(x, class = c("galah_config", "list")) } @@ -194,7 +194,7 @@ validate_config <- function(name, # see whether atlases have changed, and if so, give a message check_atlas(potions::pour("atlas"), value) }, - "authenticate" = enforce_logical(value), + "authenticate" = enforce_logical(value), "caching" = enforce_logical(value), "directory" = check_directory(value), "download_reason_id" = enforce_download_reason(value), @@ -348,7 +348,7 @@ check_atlas <- function(current_data, new_data){ #' if authentication is requested, cache config info #' @noRd #' @keywords Internal -check_authentication <- function(x){ +check_authentication_argument <- function(x){ # NOTE: This is the only place in galah where we _silently_ query # an API. For safety and clarity reasons, I've added the following steps: # 1. giving some notice to the user that this has been performed @@ -367,4 +367,6 @@ check_authentication <- function(x){ cli::cli_warn() } } -} \ No newline at end of file +} +# NOTE: Need to add trigger to stop this process for orgs that are not ALA. +# This will mean moving it higher up in the workflow. \ No newline at end of file diff --git a/R/onload.R b/R/onload.R index 40e3be04..9628f7b8 100644 --- a/R/onload.R +++ b/R/onload.R @@ -5,7 +5,6 @@ if (pkgname == "galah") { # set up storage of standard information via {potions} - reset_cache() # remove previously stored data potions::brew(.pkg = "galah") # set up caching of behaviour quiet_config <- purrr::quietly(galah_config) config_info <- quiet_config() # to cache defaults without raising a message diff --git a/R/query_API.R b/R/query_API.R index ad0a3296..244c51ff 100644 --- a/R/query_API.R +++ b/R/query_API.R @@ -71,14 +71,15 @@ query_API_internal <- function(.query, add_body(.query$body) # NOTE: adding `body` converts from GET to POST # set authentication behaviour - if(potions::pour("package", "authenticate", .pkg = "galah") & + if(!is.null(.query$authenticate) & .query$type != "metadata/config" # necessary to prevent circular problems ){ + # check whether config data is available auth_config <- retrieve_cache("config") if(is.null(auth_config)){ cli::cli_abort(c("`authenticate` is set to `TRUE`, but `config` data is not available", - i = "Call `request_metadata(type = \"config\") |> collect()`, then try again"), + i = "Call `show_all_config`, then try again"), call = error_call) }else{ query <- query |> @@ -86,7 +87,7 @@ query_API_internal <- function(.query, auth_url = dplyr::pull(auth_config, "authorize_url"), scope = dplyr::pull(auth_config, "scopes"), pkce = TRUE, - cache_disk = TRUE) + cache_disk = purrr::pluck(.query, "authenticate", "cache_disk")) } } diff --git a/man/authenticate.Rd b/man/authenticate.Rd new file mode 100644 index 00000000..9fb9105e --- /dev/null +++ b/man/authenticate.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/authenticate.R +\name{authenticate} +\alias{authenticate} +\alias{use_authentication} +\title{Set up authentication} +\usage{ +authenticate() + +use_authentication(.data, cache_disk = FALSE) +} +\description{ +This is early-stage code. It's purpose is to trigger the browser to generate +a JWT token. \code{\link[=authenticate]{authenticate()}} sets \code{galah_config(authenticate = TRUE)}, but +differs in triggering authentication on execution (which \code{galah_config()} +does not). \code{\link[=use_authentication]{use_authentication()}} is an in-pipe method for setting +authentication and associated behaviour. It is primarily intended for +internal use, but is exported for completeness and debugging purposes. +} +\details{ +By default, authentication is only triggered during occurrence downloads +and query uploads. This can be overrided by calling \code{\link[=use_authentication]{use_authentication()}}. +} diff --git a/man/coalesce.Rd b/man/coalesce.Rd index e9830f22..69ec7881 100644 --- a/man/coalesce.Rd +++ b/man/coalesce.Rd @@ -5,6 +5,7 @@ \alias{coalesce.data_request} \alias{coalesce.metadata_request} \alias{coalesce.files_request} +\alias{coalesce.query} \title{Force evaluation of a database query} \usage{ coalesce(x, ...) @@ -14,6 +15,8 @@ coalesce(x, ...) \method{coalesce}{metadata_request}(x, ...) \method{coalesce}{files_request}(x, ...) + +\method{coalesce}{query}(x, ...) } \arguments{ \item{x}{An object to be coalesced. Works for \code{data_request}, diff --git a/tests/testthat/test-authentication.R b/tests/testthat/test-authentication.R index 5aeec8ae..c2fb8a1e 100644 --- a/tests/testthat/test-authentication.R +++ b/tests/testthat/test-authentication.R @@ -6,7 +6,7 @@ test_that("`galah_config()` caches config info when `authenticate` is set to `TR expect_false(x$package$authenticate) expect_true(is.null(retrieve_cache("config"))) y <- quiet_config(authenticate = TRUE) - stringr::str_detect(y$messages, + stringr::str_detect(y$messages, "Caching `config` information to support authentication") |> any() |> expect_true() @@ -16,7 +16,6 @@ test_that("`galah_config()` caches config info when `authenticate` is set to `TR expect_equal(nrow(cached_config), 1) }) - test_that("`request_metadata()` works for type = `config`", { skip_on_ci(); skip_on_cran() result <- request_metadata(type = "config") |> @@ -36,19 +35,55 @@ test_that("`request_metadata()` caches type `config` correctly", { expect_true(!is.null(result$data)) }) -test_that("setting `authentication` to `TRUE` doesn't break a query", { - skip("Authentication currently requires user interaction") - config <- quiet_config(authenticate = TRUE) - result <- request_metadata(type = "reasons") |> - collect() |> - expect_no_error() - result |> - inherits(c("tbl_df", "tbl", "data.frame")) |> - expect_true() - expect_gt(nrow(result), 1) - expect_equal(ncol(result), 2) - # reset - galah_config(authentication = FALSE) +test_that("`use_authentication()` works in-pipe for metadata", { + query <- request_metadata(type = "reasons") |> + use_authentication() + + result <- as_query(query) + is.null(result$authenticate) |> + expect_false() + + result2 <- coalesce(result) + expect_equal(length(result2), 2) + + # NOTE: this shows both datasets are set to `data` (not `url`) + # so `use_authentication()` won't do anything + # perhaps a solution is to have a `force` argument to ensure query happens + # this would be logical to put in `collect()` and `show_all()`; + # but would be evaluated in `collapse()` so would go there too + + # in which case, setting `use_authentication()` should set `force = TRUE` + + # NOTE: `use_credentials()` could be a good counterpoint for setting email etc +}) + +test_that("`use_authentication()` works in-pipe for occurrences", { + galah_config(email = "ala4r@ala.org.au") + + query <- galah_call() |> + identify("Litoria dentata") |> + filter(year == 2025) |> + use_authentication() |> + coalesce() + expect_equal(length(query), 6) + is.null(query[[6]]$authenticate) |> + expect_false() + + x <- collapse(query) # FIXME: errors with no email address found + # note that this shouldn't happen if authentication has worked; + # BUT we haven't tested that yet + x |> + purrr::pluck("authenticate") |> + is.null() |> + expect_false() + + y <- compute(x) + # failing here + + # once auth works, this should still contain authentication metadata + + z <- collect(y) + }) test_that("setting `authentication` to `TRUE` changes data returned", { @@ -106,4 +141,4 @@ test_that("setting `authentication` to `TRUE` changes data returned", { # collect() # May be same problem as previously documented -rm(quiet_config) \ No newline at end of file +rm(quiet_config) diff --git a/vignettes/accessing_sensitive_data.Rmd.orig b/vignettes/accessing_sensitive_data.Rmd.orig index 3602c1ba..64be6ecc 100644 --- a/vignettes/accessing_sensitive_data.Rmd.orig +++ b/vignettes/accessing_sensitive_data.Rmd.orig @@ -45,6 +45,33 @@ from version 2.2.0 you can use 'galah' to access that sensitive data. # Switching on authentication +You have three options for setting authentication in galah. In practice, each of +these calls the versions below it. + +The ideal way to set authentication is to call `authenticate()`. This checks for +and caches `show_all_config()`, which contains the information necessary to +run an authentication pipeline. It then triggers a dummy query which, in turn, +opens a browser window to open for you to enter credentials. Once authentication +is achieved, default behaviour is applied, namely caching only for: + +- occurrence downloads +- query uploads +- (species list uploads?) + +An alternative is to call `galah_config(authenticate = TRUE)`. This will +establish default authentication behaviour, but won't open a browser until +a query is run that requires authentication. + +The *final* way to set up authentication is to call it in-pipe with +`use_authentication()`. This is called internally by the above functions, and +amends a `_request` object to add a `authenticate` slot. +When this slot exists, a query to `show_all_config()` is included by +`coalesce()` and executed by `collapse()`. The benefit of this function is it +allows overruling of package defaults for authentication. + + +# Keeping emails and passwords secure + First, you will need to add your email address and password, as usual for downloads via `galah`. Because you might want to share your script at some point, we do not recommend that you simply type these into your script. Instead, From f080488472f887f3c167e86ee3391cebf7e75d9b Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Wed, 5 Nov 2025 09:22:08 +1100 Subject: [PATCH 42/94] Fix bug with query handling preventing execution of API calls basically just more careful handling of `query` objects. --- R/as_query-occurrences.R | 58 +++++++++++++--------------- R/as_query-occurrences_count.R | 33 +++++++--------- R/as_query-occurrences_doi.R | 14 +++---- R/as_query-species.R | 16 ++++---- R/as_query-species_count.R | 3 +- R/as_query.R | 44 +++++++++++---------- R/collapse_occurrences_count_atlas.R | 10 ++--- R/galah_call.R | 14 +++---- R/galah_config.R | 31 +++++++-------- R/handle_request_objects.R | 4 +- man/as_query.data_request.Rd | 9 +++-- 11 files changed, 110 insertions(+), 126 deletions(-) diff --git a/R/as_query-occurrences.R b/R/as_query-occurrences.R index 7ce9fb4b..238f2b97 100644 --- a/R/as_query-occurrences.R +++ b/R/as_query-occurrences.R @@ -40,13 +40,12 @@ as_query_occurrences_uk <- function(.query, ...){ reasonTypeId = potions::pour("user", "download_reason_id"), dwcHeaders = "true") # build output - result <- list( - type = "data/occurrences", - url = httr2::url_build(url), - headers = build_headers(), - filter = .query$filter, - select = .query$select) - as_query(result) + list(type = "data/occurrences", + url = httr2::url_build(url), + headers = build_headers(), + filter = .query$filter, + select = .query$select) |> + as_query() } #' calculate the query to be returned for GBIF @@ -60,22 +59,21 @@ as_query_occurrences_gbif <- function(.query, password <- potions::pour("user", "password", .pkg = "galah") user_string <- glue::glue("{username}:{password}") # build object - result <- list( - type = "data/occurrences", - url = url_lookup("data/occurrences"), - headers = list( - `User-Agent` = galah_version_string(), - `X-USER-AGENT` = galah_version_string(), - `Content-Type` = "application/json", - Accept = "application/json"), - options = list( - httpauth = 1, - userpwd = user_string), - body = list(filter = .query$filter, - identify = .query$identify, - geolocate = .query$geolocate, - format = "SIMPLE_CSV")) - as_query(result) + list(type = "data/occurrences", + url = url_lookup("data/occurrences"), + headers = list( + `User-Agent` = galah_version_string(), + `X-USER-AGENT` = galah_version_string(), + `Content-Type` = "application/json", + Accept = "application/json"), + options = list( + httpauth = 1, + userpwd = user_string), + body = list(filter = .query$filter, + identify = .query$identify, + geolocate = .query$geolocate, + format = "SIMPLE_CSV")) |> + as_query() } #' calculate the query to be returned for a given living atlas @@ -112,12 +110,10 @@ as_query_occurrences_la <- function(.query, httr2::url_parse() url$query <- query # build output - result <- list( - type = "data/occurrences", - url = httr2::url_build(url), - headers = build_headers(), - filter = .query$filter, - select = .query$select) - - as_query(result) + list(type = "data/occurrences", + url = httr2::url_build(url), + headers = build_headers(), + filter = .query$filter, + select = .query$select) |> + as_query() } diff --git a/R/as_query-occurrences_count.R b/R/as_query-occurrences_count.R index 9cfbc0cf..287c806a 100644 --- a/R/as_query-occurrences_count.R +++ b/R/as_query-occurrences_count.R @@ -65,8 +65,7 @@ as_query_occurrences_count_atlas <- function(identify = NULL, filter = filter, arrange = slice_arrange) } - class(result) <- "query" - return(result) + as_query(result) } #' collapse for counts on GBIF @@ -106,23 +105,19 @@ as_query_occurrences_count_gbif <- function(identify = NULL, # build object ## Note that unlike with other atlases, parsing of `group_by` is handled ## by `collapse()` rather than here. - result <- list( - type = data_type, - url = url_lookup("data/occurrences-count"), - headers = list( - `User-Agent` = galah_version_string(), - `X-USER-AGENT` = galah_version_string(), - `Content-Type` = "application/json", - Accept = "application/json"), - options = list( - httpauth = 1, - userpwd = user_string), - body = predicates_info, - slot_name = "count") - - # classify and return - class(result) <- "query" - result + list(type = data_type, + url = url_lookup("data/occurrences-count"), + headers = list( + `User-Agent` = galah_version_string(), + `X-USER-AGENT` = galah_version_string(), + `Content-Type` = "application/json", + Accept = "application/json"), + options = list( + httpauth = 1, + userpwd = user_string), + body = predicates_info, + slot_name = "count") |> + as_query() } #' Internal function to check `slice` and `arrange` for counts diff --git a/R/as_query-occurrences_doi.R b/R/as_query-occurrences_doi.R index 54dc240d..f85a5156 100644 --- a/R/as_query-occurrences_doi.R +++ b/R/as_query-occurrences_doi.R @@ -37,12 +37,10 @@ as_query_occurrences_doi <- function(.query, cli::cli_abort(call = error_call) } - result <- list( - type = "data/occurrences-doi", - url = url_lookup("data/occurrences-doi", - doi_string = doi_str), - headers = build_headers(), - download = TRUE) - class(result) <- "data_query" - return(result) + list(type = "data/occurrences-doi", + url = url_lookup("data/occurrences-doi", + doi_string = doi_str), + headers = build_headers(), + download = TRUE) |> + as_query() } diff --git a/R/as_query-species.R b/R/as_query-species.R index 2083741f..5d85d5a1 100644 --- a/R/as_query-species.R +++ b/R/as_query-species.R @@ -45,15 +45,13 @@ as_query_species_atlas <- function(.query){ httr2::url_parse() url$query <- query # build output - result <- list( - type = "data/species", - url = httr2::url_build(url), - headers = build_headers(), - filter = .query$filter, - group_by = .query$group_by, - download = TRUE) - class(result) <- "query" - result + list(type = "data/species", + url = httr2::url_build(url), + headers = build_headers(), + filter = .query$filter, + group_by = .query$group_by, + download = TRUE) |> + as_query() } #' parse `select()` for `atlas_species()` diff --git a/R/as_query-species_count.R b/R/as_query-species_count.R index 9368cbf6..a42cb715 100644 --- a/R/as_query-species_count.R +++ b/R/as_query-species_count.R @@ -60,6 +60,5 @@ as_query_species_count_atlas <- function(identify = NULL, filter = filter, arrange = slice_arrange) } - class(result) <- "query" - return(result) + as_query(result) } diff --git a/R/as_query.R b/R/as_query.R index ec7b93d5..c12a8e2a 100644 --- a/R/as_query.R +++ b/R/as_query.R @@ -51,17 +51,10 @@ as_query <- function(x, ...){ UseMethod("as_query") } -#' @rdname as_query.data_request -#' @order 2 -as_query.list <- function(x){ - # TODO add some checks here? - structure(x, class = c("query", "list")) -} - #' @rdname as_query.data_request #' @param mint_doi Logical: should a DOI be minted for this download? Only #' applies to `type = "occurrences"` when atlas chosen is "ALA". -#' @order 3 +#' @order 2 #' @export as_query.data_request <- function(x, mint_doi = FALSE, @@ -80,12 +73,11 @@ as_query.data_request <- function(x, "species-count" = as_query_species_count(x), "distributions" = as_query_distributions_data(x), cli::cli_abort("Unrecognised 'type'")) |> - check_authentication(source = x) |> - as_query() + check_authentication(source = x) } #' @rdname as_query.data_request -#' @order 4 +#' @order 3 #' @export as_query.metadata_request <- function(x, ...){ switch(x$type, @@ -112,14 +104,13 @@ as_query.metadata_request <- function(x, ...){ "identifiers" = as_query_identifiers(x), cli::cli_abort("Unrecognised 'type'") ) |> - check_authentication(source = x) |> - as_query() + check_authentication(source = x) } #' @rdname as_query.data_request #' @param thumbnail Logical: should thumbnail-size images be returned? Defaults #' to `FALSE`, indicating full-size images are required. -#' @order 5 +#' @order 4 #' @export as_query.files_request <- function(x, thumbnail = FALSE, @@ -128,10 +119,23 @@ as_query.files_request <- function(x, # for future file types # This code is identical to `collapse.files_request()` - list(switch(x$type, - "media" = as_query_media_files(x, - thumbnail = thumbnail) - )) |> - check_authentication(source = x) |> - as_query() # NOTE: previously returned `query_set` + switch(x$type, + "media" = as_query_media_files(x, + thumbnail = thumbnail), + cli::cli_abort("Unrecognised 'type'") + ) |> + check_authentication(source = x) +} + +#' @rdname as_query.data_request +#' @order 5 +as_query.list <- function(x){ + # TODO add some checks here? + structure(x, class = c("query", "list")) +} + +#' @rdname as_query.data_request +#' @order 6 +as_query.query <- function(x){ + x } \ No newline at end of file diff --git a/R/collapse_occurrences_count_atlas.R b/R/collapse_occurrences_count_atlas.R index 3259cb0a..a200a4a7 100644 --- a/R/collapse_occurrences_count_atlas.R +++ b/R/collapse_occurrences_count_atlas.R @@ -156,12 +156,10 @@ collapse_occurrences_count_atlas_groupby_crossed <- function(.query, result_df <- dplyr::select(result_df, -query) # join and export - result <- c(list( - type = data_cached$type, - url = result_df), - data_cached[!(names(data_cached) %in% c("url", "type"))]) - class(result) <- "query" - result + c(list(type = data_cached$type, + url = result_df), + data_cached[!(names(data_cached) %in% c("url", "type"))]) |> + as_query() } #' Internal function to check number of facets to be returned by a `group_by` query diff --git a/R/galah_call.R b/R/galah_call.R index 9f112a1a..e52e5eee 100644 --- a/R/galah_call.R +++ b/R/galah_call.R @@ -150,8 +150,8 @@ request_data <- function(type = c("occurrences", default_call$type <- type # check_type(type) # set default for limit? # default_call$limit <- 100 ? - class(default_call) <- "data_request" - default_call + structure(default_call, + class = "data_request") } #' @rdname galah_call @@ -182,9 +182,8 @@ request_metadata <- function(type = c("fields", x = "Can't find metadata type `{type}`.") |> cli::cli_abort() } - x <- list(type = type_checked) - class(x) <- "metadata_request" - return(x) + list(type = type_checked) |> + structure(class = "metadata_request") } #' @rdname galah_call @@ -193,7 +192,6 @@ request_files <- function( type = "media" # note: option to add `...` here for consistency with `request_data()` ){ - x <- list(type = match.arg(type)) - class(x) <- "files_request" - return(x) + list(type = match.arg(type)) |> + structure(class = "files_request") } diff --git a/R/galah_config.R b/R/galah_config.R index 3ded94e4..899f8235 100644 --- a/R/galah_config.R +++ b/R/galah_config.R @@ -150,24 +150,19 @@ galah_config <- function(...) { #' @noRd #' @keywords Internal default_config <- function(){ - x <- list( - package = list( - verbose = TRUE, - run_checks = TRUE, - send_email = FALSE, - authenticate = FALSE, - directory = tempdir()), - user = list( - username = "", - email = "", - password = "", - download_reason_id = 4), - atlas = list( - organisation = "Atlas of Living Australia", - acronym = "ALA", - region = "Australia")) - class(x) <- c("galah_config", "list") - x + list(package = list(verbose = TRUE, + run_checks = TRUE, + send_email = FALSE, + authenticate = FALSE, + directory = tempdir()), + user = list(username = "", + email = "", + password = "", + download_reason_id = 4), + atlas = list(organisation = "Atlas of Living Australia", + acronym = "ALA", + region = "Australia")) |> + structure(class = c("galah_config", "list")) } #' Place new options into correctly nested structure diff --git a/R/handle_request_objects.R b/R/handle_request_objects.R index 99eb4707..943037b5 100644 --- a/R/handle_request_objects.R +++ b/R/handle_request_objects.R @@ -76,8 +76,8 @@ update_request_object <- function(x, ...){ if(any(missing_names)){ result <- append(result, dots[missing_names]) } - class(result) <- class_tr - result + structure(result, + class = class_tr) } #' Internal function to join together two `select` objects diff --git a/man/as_query.data_request.Rd b/man/as_query.data_request.Rd index f8990f94..aa990437 100644 --- a/man/as_query.data_request.Rd +++ b/man/as_query.data_request.Rd @@ -3,20 +3,23 @@ \name{as_query.data_request} \alias{as_query.data_request} \alias{as_query} -\alias{as_query.list} \alias{as_query.metadata_request} \alias{as_query.files_request} +\alias{as_query.list} +\alias{as_query.query} \title{Convert an object to class \code{query}} \usage{ as_query(x, ...) -\method{as_query}{list}(x) - \method{as_query}{data_request}(x, mint_doi = FALSE, ...) \method{as_query}{metadata_request}(x, ...) \method{as_query}{files_request}(x, thumbnail = FALSE, ...) + +\method{as_query}{list}(x) + +\method{as_query}{query}(x) } \arguments{ \item{x}{An object to convert to a \code{query}. Supported classes are the same From 06189026ce7ffadff55cc8b4116d5656d95c8770 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Thu, 6 Nov 2025 16:36:42 +1100 Subject: [PATCH 43/94] Ensure authentication pipeline works for occurrences (#189) - refactor such that client and config are cached within the session (but not on disk), in theory preventing auth process being re-run each time - turn off email argument for downloads when authentication is enabled, and subsequent warning messages - add missing type converters to allow compute or collect on a query_set --- NAMESPACE | 2 + R/as_query-occurrences.R | 21 ++-- R/as_query-species.R | 6 +- R/as_query.R | 11 +- R/authenticate.R | 120 ++++++++++++++++++-- R/check.R | 40 ++++--- R/collect.R | 10 ++ R/compute.R | 9 ++ R/compute_occurrences.R | 9 +- R/query_API.R | 30 ++--- R/utilities_internal.R | 43 ++++++- man/collect.data_request.Rd | 3 + man/compute.data_request.Rd | 3 + tests/testthat/test-authentication.R | 57 +++++++--- vignettes/accessing_sensitive_data.Rmd.orig | 6 +- 15 files changed, 286 insertions(+), 84 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 119baf9e..57426c43 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -19,10 +19,12 @@ S3method(collect,data_request) S3method(collect,files_request) S3method(collect,metadata_request) S3method(collect,query) +S3method(collect,query_set) S3method(compute,data_request) S3method(compute,files_request) S3method(compute,metadata_request) S3method(compute,query) +S3method(compute,query_set) S3method(count,data_request) S3method(filter,data_request) S3method(filter,files_request) diff --git a/R/as_query-occurrences.R b/R/as_query-occurrences.R index 238f2b97..67321967 100644 --- a/R/as_query-occurrences.R +++ b/R/as_query-occurrences.R @@ -25,6 +25,7 @@ as_query_occurrences_uk <- function(.query, ...){ if(is.null(.query$select)){ .query$select <- galah_select(group = "basic") } + # build a url # NOTE: providing an email blocks this from executing (2023-08-30) url <- url_lookup("data/occurrences") |> @@ -39,6 +40,7 @@ as_query_occurrences_uk <- function(.query, ...){ fileType = "csv", reasonTypeId = potions::pour("user", "download_reason_id"), dwcHeaders = "true") + # build output list(type = "data/occurrences", url = httr2::url_build(url), @@ -58,6 +60,7 @@ as_query_occurrences_gbif <- function(.query, username <- potions::pour("user", "username", .pkg = "galah") password <- potions::pour("user", "password", .pkg = "galah") user_string <- glue::glue("{username}:{password}") + # build object list(type = "data/occurrences", url = url_lookup("data/occurrences"), @@ -82,10 +85,12 @@ as_query_occurrences_gbif <- function(.query, #' @keywords Internal as_query_occurrences_la <- function(.query, mint_doi = FALSE){ + # set default columns if(is.null(.query$select)){ .query <- .query |> select(group = "basic") } + # build a query query <- c(build_query(identify = .query$identify, filter = .query$filter, @@ -93,22 +98,20 @@ as_query_occurrences_la <- function(.query, data_profile = .query$data_profile), fields = "`SELECT_PLACEHOLDER`", qa = "`ASSERTIONS_PLACEHOLDER`", - facet = "false", # not tested - emailNotify = email_notify(), + facet = "false", sourceTypeId = {potions::pour("atlas", "region") |> source_type_id_lookup()}, reasonTypeId = potions::pour("user", "download_reason_id"), - email = potions::pour("user", "email"), - dwcHeaders = "true") - # DOI conditional on this service being offered - if(isTRUE(.query$mint_doi) & - potions::pour("atlas", "region") == "Australia"){ - query$mintDoi <- TRUE - } + dwcHeaders = "true") |> + add_email_notify() |> + add_email_address(query = .query) |> + add_doi_request(query = .query) + # build url url <- url_lookup("data/occurrences") |> httr2::url_parse() url$query <- query + # build output list(type = "data/occurrences", url = httr2::url_build(url), diff --git a/R/as_query-species.R b/R/as_query-species.R index 5d85d5a1..ab34a637 100644 --- a/R/as_query-species.R +++ b/R/as_query-species.R @@ -36,10 +36,10 @@ as_query_species_atlas <- function(.query){ emailNotify = email_notify(), sourceTypeId = 2004, reasonTypeId = potions::pour("user", "download_reason_id"), - email = potions::pour("user", "email"), facets = .query$group_by$name, - parse_select_species(.query$select) - ) + parse_select_species(.query$select)) |> + add_email_address(query = .query) + # build url url <- url_lookup("data/species") |> httr2::url_parse() diff --git a/R/as_query.R b/R/as_query.R index c12a8e2a..f81d6dd5 100644 --- a/R/as_query.R +++ b/R/as_query.R @@ -59,6 +59,7 @@ as_query <- function(x, ...){ as_query.data_request <- function(x, mint_doi = FALSE, ...){ + x <- check_authentication(x) switch(x$type, "occurrences" = { if(is.null(x$group_by)){ @@ -73,13 +74,14 @@ as_query.data_request <- function(x, "species-count" = as_query_species_count(x), "distributions" = as_query_distributions_data(x), cli::cli_abort("Unrecognised 'type'")) |> - check_authentication(source = x) + retain_authentication(source = x) } #' @rdname as_query.data_request #' @order 3 #' @export as_query.metadata_request <- function(x, ...){ + x <- check_authentication(x) switch(x$type, "apis" = as_query_apis(x), "assertions" = as_query_assertions(x), @@ -104,7 +106,7 @@ as_query.metadata_request <- function(x, ...){ "identifiers" = as_query_identifiers(x), cli::cli_abort("Unrecognised 'type'") ) |> - check_authentication(source = x) + retain_authentication(source = x) } #' @rdname as_query.data_request @@ -122,9 +124,8 @@ as_query.files_request <- function(x, switch(x$type, "media" = as_query_media_files(x, thumbnail = thumbnail), - cli::cli_abort("Unrecognised 'type'") - ) |> - check_authentication(source = x) + cli::cli_abort("Unrecognised 'type'")) |> + retain_authentication(source = x) } #' @rdname as_query.data_request diff --git a/R/authenticate.R b/R/authenticate.R index ac9a3dc2..3466dbf8 100644 --- a/R/authenticate.R +++ b/R/authenticate.R @@ -29,13 +29,14 @@ authenticate <- function(){ # NOTE: I'm trialling the use of `oauth_flow_auth_code()` here. # This *should* generate a request to the browser without requiring a # dummy API call; but hasn't been found to work yet. - auth_config <- show_all_config() # cache config info - galah_config(authenticate = TRUE) # cache authentication behaviour + auth_info <- get_auth_info() result <- httr2::oauth_flow_auth_code( - client = build_auth_client(auth_config), - auth_url = dplyr::pull(auth_config, "authorize_url"), - scope = dplyr::pull(auth_config, "scopes"), + client = auth_info$client, + auth_url = dplyr::pull(auth_info$config, "authorize_url"), + scope = dplyr::pull(auth_info$config, "scopes"), pkce = TRUE) + galah_config(authenticate = TRUE) # cache authentication behaviour + return(invisible(result)) } else { cli::cli_inform(c( i = "Exiting..." @@ -49,6 +50,7 @@ authenticate <- function(){ } #' @rdname authenticate +#' @export use_authentication <- function(.data, cache_disk = FALSE){ .data$authenticate <- list( @@ -58,6 +60,67 @@ use_authentication <- function(.data, .data } +#' Internal function to lookup requests for authentication +#' Note this is currently only called on `data_request` objects, and +#' then only before parsing +#' @noRd +#' @keywords Internal +check_authentication <- function(x){ + if( + isTRUE(potions::pour("package", + "authenticate", + .pkg = "galah")) & + x$type %in% c("occurrences") # possible to add other allowed queries + ){ + x |> use_authentication() + }else{ + x + } +} + +#' Internal function to pass authentication information forward +#' @noRd +#' @keywords Internal +retain_authentication <- function(source, x){ + if( + !is.null(source$authenticate) & # i.e.. authenticate was supplied + is.null(x$authenticate) # but was then lost + ){ + x$authenticate <- source$authenticate + } + x +} + +#' get a client, and if it doesn't exist, make one +#' @noRd +#' @keywords Internal +get_auth_info <- function(error_call = rlang::caller_env()){ + x <- retrieve_cache("client") # this is cached by build_auth_client() + auth_config <- show_all_config() # handle download /retrieval of config info + if(is.null(x)){ + x <- build_auth_client(auth_config) + } + # if still can't get a client, you might be offline + if(is.null(x)){ + cli::cli_abort(c("Unable to generate an authentication client", + i = "You might be offline"), + call = error_call) + } + list(config = auth_config, + client = x) +} + +#' create a client object +#' @noRd +#' @keywords Internal +build_auth_client <- function(config){ + result <- httr2::oauth_client( + id = dplyr::pull(config, "client_id"), + token_url = dplyr::pull(config, "token_url")) + # consider caching this as well? Perhaps rebuilding is triggering new client id or something? + update_cache(client = result) + result +} #' Interactive menu function #' @description @@ -89,7 +152,7 @@ cli_menu <- function(header, ) repeat { - selected <- cli::cli_readline("Selection: ") + selected <- cli_readline("Selection: ") if (selected %in% c("0", seq_along(choices))) { break } @@ -110,4 +173,47 @@ cli_menu <- function(header, } selected -} \ No newline at end of file +} + +#' Interactive readLines +#' @description +#' Allows for interactive testing of `cli_menu()` selection. +#' Originally proposed by Hadley here: https://github.com/r-lib/cli/issues/228#issuecomment-1453614104. +#' Full code from gargle here: https://github.com/r-lib/gargle/blob/main/R/utils-ui.R +#' @noRd +#' @keywords Internal +cli_readline <- function(prompt) { + local_input <- getOption("cli_input", character()) + + # not convinced that we need to plan for multiple mocked inputs, but leaving + # this feature in for now + if (length(local_input) > 0) { + input <- local_input[[1]] + cli::cli_inform(paste0(prompt, input)) + options(cli_input = local_input[-1]) + input + } else { + readline(prompt) + } +} + +## -- testing -- ## + +#' Mimic supplying user input to a menu +#' @noRd +#' @keywords Internal +local_user_input <- function(x, env = rlang::caller_env()) { + withr::local_options( + rlang_interactive = TRUE, + # trailing 0 prevents infinite loop if x only contains invalid choices + cli_input = c(x, "0"), + .local_envir = env + ) +} + +#' Check whether function is being called by testthat +#' @noRd +#' @keywords Internal +is_testing <- function() { + identical(Sys.getenv("TESTTHAT"), "true") +} diff --git a/R/check.R b/R/check.R index 92c6c7e0..6aaeca2b 100644 --- a/R/check.R +++ b/R/check.R @@ -12,16 +12,6 @@ check_atlas_inputs <- function(args){ } } -#' Internal function to pass authentication information forward -#' @noRd -#' @keywords Internal -check_authentication <- function(x, source){ - if(!is.null(source$authenticate)){ - x$authenticate <- source$authenticate - } - x -} - #' Internal function to check for `data_request`s #' @noRd #' @keywords Internal @@ -99,11 +89,28 @@ check_email <- function(.query, abort_email_missing(error_call = call) } }else{ - email_text <- httr2::url_parse(.query$url)$query$email - if(is.null(email_text)) { - abort_email_missing(error_call = call) + # use purrr::pluck() to search for named slots + # base parsing captures `email_notify` and is therefore unrelable + email_text <- httr2::url_parse(.query$url) |> + purrr::pluck("query", "email") + # set criteria for missingness + email_text_missing <- if(is.null(email_text)){ + TRUE }else if(email_text == ""){ - abort_email_missing(error_call = call) + TRUE + }else{ + FALSE + } + # authentication only acceptable alternative to email for ALA + if(is_ala()){ + authentication_missing <- is.null(.query$authenticate) + if(email_text_missing & authentication_missing){ + abort_email_missing(error_call = call) + } + }else{ + if(email_text_missing){ + abort_email_missing(error_call = call) + } } } .query @@ -219,15 +226,14 @@ check_field_identities <- function(df, names(missing_fields) <- rep("*", length(missing_fields)) c("The following fields, requested in your query, were not downloaded:", missing_fields) |> - cli::cli_warn(bullets, - call = error_call) + cli::cli_warn(call = error_call) } # check for additions added_check <- !(field_names %in% .query$fields) if(any(added_check)){ added_fields <- field_names[added_check] # if authentication has occurred, remove `sensitive_` fields - if(potions::pour("package", "authenticate", .pkg = "galah")){ + if(!is.null(.query$authenticate)){ added_fields <- added_fields[!stringr::str_detect(added_fields, "^sensitive")] } # then, if any remain, warn diff --git a/R/collect.R b/R/collect.R index 591af91f..7e71e2c1 100644 --- a/R/collect.R +++ b/R/collect.R @@ -57,6 +57,16 @@ collect.query <- function(x, ..., wait = TRUE, file = NULL){ #' @rdname collect.data_request #' @order 5 #' @export +collect.query_set <- function(x, ...){ + x |> + collapse(, ...) |> + compute() |> + collect() +} + +#' @rdname collect.data_request +#' @order 6 +#' @export collect.computed_query <- function(x, ..., wait = TRUE, diff --git a/R/compute.R b/R/compute.R index 50cf2d43..fe88cedf 100644 --- a/R/compute.R +++ b/R/compute.R @@ -59,6 +59,15 @@ compute.query <- function(x, ...){ ) } +#' @rdname compute.data_request +#' @order 6 +#' @export +compute.query_set <- function(x, ...){ + x |> + collapse() |> + compute() +} + #' Internal function to convert class `query` to `computed_query` #' @noRd #' @keywords Internal diff --git a/R/compute_occurrences.R b/R/compute_occurrences.R index 412e059b..3cb945a4 100644 --- a/R/compute_occurrences.R +++ b/R/compute_occurrences.R @@ -45,13 +45,18 @@ compute_occurrences_la <- function(.query){ check_occurrence_response() if(potions::pour("package", "verbose")){ n_records <- status_code$total_records - cli::cli_inform("Request for {n_records} occurrences placed in queue") + cli::cli_par() + if(!is.null(.query$authenticate)){ + cli::cli_text("Query sent including JWT token") + } + cli::cli_text("Request for {n_records} occurrences placed in queue") + cli::cli_end() } # return a useful object c(list(type = "data/occurrences"), status_code, list(fields = extract_fields(.query))) |> - check_authentication(source = .query) |> + retain_authentication(source = .query) |> structure(class = "computed_query") } diff --git a/R/query_API.R b/R/query_API.R index 244c51ff..514140e7 100644 --- a/R/query_API.R +++ b/R/query_API.R @@ -76,19 +76,14 @@ query_API_internal <- function(.query, ){ # check whether config data is available - auth_config <- retrieve_cache("config") - if(is.null(auth_config)){ - cli::cli_abort(c("`authenticate` is set to `TRUE`, but `config` data is not available", - i = "Call `show_all_config`, then try again"), - call = error_call) - }else{ - query <- query |> - httr2::req_oauth_auth_code(client = build_auth_client(auth_config), - auth_url = dplyr::pull(auth_config, "authorize_url"), - scope = dplyr::pull(auth_config, "scopes"), - pkce = TRUE, - cache_disk = purrr::pluck(.query, "authenticate", "cache_disk")) - } + auth_info <- get_auth_info() + query <- query |> + httr2::req_oauth_auth_code( + client = auth_info$client, + auth_url = dplyr::pull(auth_info$config, "authorize_url"), + scope = dplyr::pull(auth_info$config, "scopes"), + pkce = TRUE, + cache_disk = purrr::pluck(.query, "authenticate", "cache_disk")) } # then handle downloads @@ -120,15 +115,6 @@ query_API_internal <- function(.query, } } -#' create a client object -#' @noRd -#' @keywords Internal -build_auth_client <- function(config){ - httr2::oauth_client( - id = dplyr::pull(config, "client_id"), - token_url = dplyr::pull(config, "token_url")) -} - #' If supplied, add `headers` arg to a `request()` #' @noRd #' @keywords Internal diff --git a/R/utilities_internal.R b/R/utilities_internal.R index 26a91660..410771ed 100644 --- a/R/utilities_internal.R +++ b/R/utilities_internal.R @@ -276,15 +276,45 @@ source_type_id_lookup <- function(region){ "2004") # ALA default for galah } +##---------------------------------------------------------------- +## Functions to add information to occurrence queries -- +##---------------------------------------------------------------- +## Note these now follow `tidyverse` convention of accepting and +## returning same object type + +#' Add a logical flag re: whether user should receive an email +#' @param x a list #' @noRd #' @keywords Internal -email_notify <- function() { +add_email_notify <- function(x) { notify <- as.logical(potions::pour("package", "send_email")) - if (is.na(notify)) { + if(is.na(notify)) { notify <- FALSE } # ala api requires lowercase - ifelse(notify, "true", "false") + x$email_notify <- ifelse(notify, "true", "false") + x +} + +#' Add an email address, but *only* when JWT tokens are not given +#' @noRd +#' @keywords Internal +add_email_address <- function(x, query){ + if(is.null(query$authenticate)){ + x$email <- potions::pour("user", "email") + } + x +} + +#' Add a DOI request +#' @noRd +#' @keywords Internal +add_doi_request <- function(x, query){ + if(isTRUE(query$mint_doi) & + potions::pour("atlas", "region") == "Australia"){ + x$mintDoi <- TRUE + } + x } ##---------------------------------------------------------------- @@ -298,6 +328,13 @@ is_gbif <- function(){ potions::pour("atlas", "region") == "Global" } +#' Internal function for determining if we should call ALA or not +#' @noRd +#' @keywords Internal +is_ala <- function(){ + potions::pour("atlas", "region") == "Australia" +} + #' Internal function to populate `groups` arg in `select()` #' @noRd #' @keywords Internal diff --git a/man/collect.data_request.Rd b/man/collect.data_request.Rd index 83794709..2fec366d 100644 --- a/man/collect.data_request.Rd +++ b/man/collect.data_request.Rd @@ -5,6 +5,7 @@ \alias{collect.metadata_request} \alias{collect.files_request} \alias{collect.query} +\alias{collect.query_set} \alias{collect.computed_query} \title{Retrieve a database query} \usage{ @@ -16,6 +17,8 @@ \method{collect}{query}(x, ..., wait = TRUE, file = NULL) +\method{collect}{query_set}(x, ...) + \method{collect}{computed_query}(x, ..., wait = TRUE, file = NULL) } \arguments{ diff --git a/man/compute.data_request.Rd b/man/compute.data_request.Rd index 2e6dc0bb..94b70032 100644 --- a/man/compute.data_request.Rd +++ b/man/compute.data_request.Rd @@ -5,6 +5,7 @@ \alias{compute.metadata_request} \alias{compute.files_request} \alias{compute.query} +\alias{compute.query_set} \title{Compute a query} \usage{ \method{compute}{data_request}(x, ...) @@ -14,6 +15,8 @@ \method{compute}{files_request}(x, ...) \method{compute}{query}(x, ...) + +\method{compute}{query_set}(x, ...) } \arguments{ \item{x}{An object of class \code{data_request}, \code{metadata_request} or diff --git a/tests/testthat/test-authentication.R b/tests/testthat/test-authentication.R index c2fb8a1e..0c03eb90 100644 --- a/tests/testthat/test-authentication.R +++ b/tests/testthat/test-authentication.R @@ -87,22 +87,52 @@ test_that("`use_authentication()` works in-pipe for occurrences", { }) test_that("setting `authentication` to `TRUE` changes data returned", { - skip_on_ci(); skip_on_cran() - skip_if(!file.exists(".secure-credentials"), - "Secret information not provided") - - # load credentials, set authenticate to TRIE - config <- c( - jsonlite::fromJSON(".secure-credentials"), - list(directory = "TEST-SENSITIVE-DATA", - authenticate = TRUE)) |> - quiet_config() - + skip("authentication requires interactivity") + + # skip_if(!file.exists(".secure-credentials"), + # "Secret information not provided") + # + # # load credentials, set authenticate to TRIE + # config <- c( + # jsonlite::fromJSON(".secure-credentials"), + # list(directory = "TEST-SENSITIVE-DATA", + # authenticate = TRUE)) |> + # quiet_config() + + # httr2::oauth_cache_clear() # wipe content - requires a client + + token <- authenticate() + # NOTE: saving this out is optional - token is currently returned invisibly + # there is an argument that this isn't very safe and should be removed, + # but is here for debugging rn. + # These credentials *should* give access to sensitive data for Tasmania *only* # subset to species on Tasmania's sensitive species list - result <- galah_call() |> + + # convert to query set first + x_queryset <- galah_call() |> filter(species_list_uid == "dr491") |> - collect() |> + coalesce() + expect_equal(length(x_queryset), 5) + expect_equal(x_queryset[[1]]$type, + "metadata/config") + is.null(x_queryset[[5]]$authenticate) |> + expect_false() + # unclear whether it is _critical_ for coalesce() to source `show_all_config()` here + # but some use cases it probably is necessary, and for the others it is + # 'free' because of caching, so probably best to leave it for now + + # then collapse + x_query <- collapse(x_queryset) + stringr::str_detect(x_query$url, "&email=") |> + expect_false() + # TODO add `authenticate` to `print.query()` + + # compute + y <- compute(x_query) # note: triggers authentication a second time?! + # check for messages + + result <- collect(y) |> expect_no_error() # check for exception to `check_field_identities()` # check sensitive columns exist @@ -138,6 +168,7 @@ test_that("setting `authentication` to `TRUE` changes data returned", { # Downloading from a DOI fails # galah_call() |> # filter(doi == "ala.3d0e08ac-d0ec-420d-a1f7-8cde778e82f6") |> +# use_authentication() |> # collect() # May be same problem as previously documented diff --git a/vignettes/accessing_sensitive_data.Rmd.orig b/vignettes/accessing_sensitive_data.Rmd.orig index 64be6ecc..4d247ac1 100644 --- a/vignettes/accessing_sensitive_data.Rmd.orig +++ b/vignettes/accessing_sensitive_data.Rmd.orig @@ -50,9 +50,9 @@ these calls the versions below it. The ideal way to set authentication is to call `authenticate()`. This checks for and caches `show_all_config()`, which contains the information necessary to -run an authentication pipeline. It then triggers a dummy query which, in turn, -opens a browser window to open for you to enter credentials. Once authentication -is achieved, default behaviour is applied, namely caching only for: +run an authentication pipeline. It then opens a browser window to open for you +to enter credentials. Once authentication is achieved, default behaviour is +applied, namely calling authentication only for: - occurrence downloads - query uploads From eabc741401806b1451d328aaad3bb34f91e76a99 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Fri, 7 Nov 2025 10:39:47 +1100 Subject: [PATCH 44/94] Updates to messages move from `cli_inform()` to cli_text() and cli_bullets() for prettier rendering --- R/authenticate.R | 27 +++++++++++++---------- R/check.R | 33 ++++++++++++++-------------- R/check_queue.R | 13 ++++++----- R/collapse_occurrences_count_atlas.R | 12 +++++----- R/collect-files.R | 2 +- R/collect_media.R | 3 ++- R/collect_occurrences.R | 21 ++++++++++++------ R/collect_taxa.R | 2 +- R/galah_bbox.R | 10 +++++---- R/galah_config.R | 15 ++++++++----- R/galah_radius.R | 8 ++++--- R/galah_select.R | 4 ++-- R/messages.R | 2 +- R/show_values.R | 6 ++--- 14 files changed, 93 insertions(+), 65 deletions(-) diff --git a/R/authenticate.R b/R/authenticate.R index 3466dbf8..4cbcbed3 100644 --- a/R/authenticate.R +++ b/R/authenticate.R @@ -18,7 +18,7 @@ authenticate <- function(){ choice <- cli_menu( c(" ", - "This function will open an authentication screen in your browser", + "This function will open an authentication screen in your browser.", " "), "Do you want to continue? (0 to exit)", choices = c("Yes", "No") @@ -38,7 +38,7 @@ authenticate <- function(){ galah_config(authenticate = TRUE) # cache authentication behaviour return(invisible(result)) } else { - cli::cli_inform(c( + cli::cli_bullets(c( i = "Exiting..." )) # exits process quietly @@ -116,8 +116,9 @@ get_auth_info <- function(error_call = rlang::caller_env()){ build_auth_client <- function(config){ result <- httr2::oauth_client( id = dplyr::pull(config, "client_id"), - token_url = dplyr::pull(config, "token_url")) - # consider caching this as well? Perhaps rebuilding is triggering new client id or something? + token_url = dplyr::pull(config, "token_url"), + auth = "body", + name = "galah") update_cache(client = result) result } @@ -127,7 +128,7 @@ build_auth_client <- function(config){ #' Built on top of utils::menu(). #' Originally proposed by Hadley here: https://github.com/r-lib/cli/issues/228#issuecomment-1453614104 #' Full code from gargle here: https://github.com/r-lib/gargle/blob/main/R/utils-ui.R -#' This version borrowed verbatim from `galaxias` v. 0.1.0 +#' This version updated from `galaxias` v. 0.1.0 #' @noRd #' @keywords Internal cli_menu <- function(header, @@ -146,17 +147,21 @@ cli_menu <- function(header, } choices <- paste0(cli::style_bold(seq_along(choices)), ": ", choices) - cli::cli_inform( - c(header, prompt, choices), - .envir = .envir - ) + + cli::cli({ + cli::cli_text(header, .envir = .envir) + cli::cli_text("", .envir = .envir) + cli::cli_text(prompt, .envir = .envir) + cli::cli_text("", .envir = .envir) + cli::cli_bullets(choices, .envir = .envir) + }) repeat { selected <- cli_readline("Selection: ") if (selected %in% c("0", seq_along(choices))) { break } - cli::cli_inform( + cli::cli_text( "Enter a number between 1 and {length(choices)}, or enter 0 to exit." ) } @@ -189,7 +194,7 @@ cli_readline <- function(prompt) { # this feature in for now if (length(local_input) > 0) { input <- local_input[[1]] - cli::cli_inform(paste0(prompt, input)) + cli::cli_text(paste0(prompt, input)) options(cli_input = local_input[-1]) input } else { diff --git a/R/check.R b/R/check.R index 6aaeca2b..6b74ecf6 100644 --- a/R/check.R +++ b/R/check.R @@ -18,11 +18,10 @@ check_atlas_inputs <- function(args){ check_data_request <- function(request, error_call = rlang::caller_env()){ if(!inherits(request, "data_request")){ - cli::cli_abort(c( - "Argument `.query` requires an object of type `data_request`.", + c("Argument `.query` requires an object of type `data_request`.", i = "You can create this object using `galah_call()`.", - i = "Did you specify the incorrect argument?"), - call = error_call) + i = "Did you specify the incorrect argument?") |> + cli::cli_abort(call = error_call) } } @@ -127,14 +126,14 @@ check_files_filter <- function(x, call = error_call) } if(is.null(x$data)){ - cli::cli_abort(c("rhs must be a `tibble` containing media information", - i = "at least, this tibble should contain `media_id` and `mime_type` columns"), - call = error_call) + c("rhs must be a `tibble` containing media information.", + i = "at least, this tibble should contain `media_id` and `mime_type` columns.") |> + cli::cli_abort(call = error_call) } if(!inherits(x$data, "data.frame")){ - cli::cli_abort(c("rhs must be a `tibble` containing media information", - i = "at least, this tibble should contain `media_id` and `mime_type` columns"), - call = error_call) + c("rhs must be a `tibble` containing media information.", + i = "at least, this tibble should contain `media_id` and `mime_type` columns.") |> + cli::cli_abort(call = error_call) } } @@ -517,10 +516,10 @@ check_media_cols_present <- function(.query, purrr::pluck(1) fields_check <- image_fields() %in% fields if(!any(fields_check)){ - cli::cli_abort(c("No media fields requested.", - i = "Use `select()` to specify which media fields are required.", - i = "Valid fields are 'images', 'videos' and 'sounds'."), - call = error_call) + c("No media fields requested.", + i = "Use `select()` to specify which media fields are required.", + i = "Valid fields are 'images', 'videos' and 'sounds'.") |> + cli::cli_abort(call = error_call) }else{ image_fields()[fields_check] } @@ -724,8 +723,10 @@ check_select <- function(.query, error_call = rlang::caller_env()){ if(any(names(.query) == "select")){ if(is_gbif() & stringr::str_detect(.query$type, "^data")){ - cli::cli_inform(c("skipping `select()`:", - i = "This function is not supported by the GBIF API v1")) + cli::cli({ + cli::cli_text("Skipping `select()`.") + cli::cli_bullets(c(i = "This function is not supported by the GBIF occurrences downloads API v1.")) + }) }else{ # 1. build df to `select` from valid_fields <- .query[["metadata/fields"]]$id diff --git a/R/check_queue.R b/R/check_queue.R index e0d59851..3b94d8f9 100644 --- a/R/check_queue.R +++ b/R/check_queue.R @@ -29,7 +29,7 @@ check_queue_loop <- function(.query){ iter <- 1 verbose <- potions::pour("package", "verbose", .pkg = "galah") if(verbose){ - cli::cli_inform("Current queue length: {current_queue}") + cli::cli_text("Current queue length: {current_queue}") } while(continue == TRUE){ .query <- check_occurrence_status(.query) @@ -37,9 +37,12 @@ check_queue_loop <- function(.query){ if(continue){ iter <- iter + 1 if(iter > 99){ - cli::cli_inform(c("No data were returned after 100 tries.", - i = "If you have saved this output using e.g. `x <- collect(.query)`,", - i = "you can try again later using `collect(x)`")) + cli::cli({ + cli::cli_text("No data were returned after 100 tries.") + c(i = "If you have saved this output using e.g. `x <- collect(.query)`,", + i = "you can try again later using `collect(x)`") |> + cli::cli_bullets() + }) return(.query) }else{ current_queue <- check_queue_size(.query, current_queue) @@ -69,7 +72,7 @@ check_queue_size <- function(.query, current_queue){ if(.query$queue_size < current_queue & .query$queue_size > 0){ current_queue <- .query$queue_size if(verbose){ - cli::cli_inform("Queue length: {current_queue}") + cli::cli_text("Queue length: {current_queue}") } }else{ if(verbose){cat("-")} diff --git a/R/collapse_occurrences_count_atlas.R b/R/collapse_occurrences_count_atlas.R index a200a4a7..0e78ad82 100644 --- a/R/collapse_occurrences_count_atlas.R +++ b/R/collapse_occurrences_count_atlas.R @@ -29,11 +29,10 @@ collapse_occurrences_count_atlas_basic <- function(.query){ prettyNum(big.mark = ",", preserve.width = "none") - c( - cli::cli_text(cli::col_yellow("Limiting to first {limit} of {n_total_facets} rows.")), + cli::cli({ + cli::cli_text(cli::col_yellow("Limiting to first {limit} of {n_total_facets} rows.")) cli::cli_text(cli::col_magenta("Use `atlas_counts(limit = )` to return more rows.")) - ) |> - cli::cli_inform() + }) } # .query$url <- url_build(url) .query @@ -82,7 +81,10 @@ collapse_occurrences_count_atlas_groupby_crossed <- function(.query, # run query to get list of count tibbles result <- query_API(.query) - if(is.null(result)){system_down_message("count")} + if(is.null(result)){ + system_down_message("count", + error_call = error_call) + } result <- purrr::map(result, \(a){a$fieldResult |> dplyr::bind_rows()}) diff --git a/R/collect-files.R b/R/collect-files.R index 1b4935b2..ecf7aac7 100644 --- a/R/collect-files.R +++ b/R/collect-files.R @@ -34,6 +34,6 @@ collect_media_files <- function(.query){ bullets <- c(bullets, "x" = "Failed {n_failed} downloads due to missing images (status 403)") } - cli::cli_inform(bullets) + cli::cli_bullets(bullets) invisible(result_summary) } \ No newline at end of file diff --git a/R/collect_media.R b/R/collect_media.R index 214a3831..bb349700 100644 --- a/R/collect_media.R +++ b/R/collect_media.R @@ -63,7 +63,8 @@ collect_media <- function(df, # suggest option to set directory in galah_config() user_directory <- potions::pour("package", "directory") if (stringr::str_detect(user_directory, "Temp")) { - cli::cli_inform("{cli::col_magenta('To change which file directory media files are saved to, use `galah_config(directory = )`.')}") + cli::col_magenta("To change which file directory media files are saved to, use `galah_config(directory = )`.") |> + cli::cli_text() } request_files() |> diff --git a/R/collect_occurrences.R b/R/collect_occurrences.R index 45f2122e..fd1ff145 100644 --- a/R/collect_occurrences.R +++ b/R/collect_occurrences.R @@ -34,8 +34,7 @@ collect_occurrences_direct <- function(.query, file, call){ query_API(.query) result <- read_zip(.query$file) if(is.null(result)){ - cli::cli_inform("Download failed", call = call) - return(tibble::tibble()) + download_failed_message(call = call) }else{ result } @@ -54,7 +53,7 @@ collect_occurrences_default <- function(.query, wait, file, call){ # get data if(potions::pour("package", "verbose", .pkg = "galah") & download_response$status == "complete") { - cli::cli_inform("Downloading") + cli::cli_text("Downloading") } # sometimes lookup info critical, but not others - unclear when/why! if(any(names(download_response) == "download_url")){ @@ -72,8 +71,7 @@ collect_occurrences_default <- function(.query, wait, file, call){ } # handle result if(is.null(result)){ - cli::cli_inform("Download failed", call = call) - return(tibble::tibble()) + download_failed_message(call = call) }else{ result <- result |> check_field_identities(.query, error_call = call) |> @@ -103,9 +101,18 @@ collect_occurrences_doi <- function(.query, query_API(.query) result <- read_zip(.query$file) if(is.null(result)){ - cli::cli_inform("Download failed.", call = call) - tibble::tibble() + download_failed_message(call = call) }else{ result } +} + +#' Download failed message +#' @noRd +#' @keywords Internal +download_failed_message <- function(call){ + c("Download failed.", + i = "This usually suggests a problem with the download itself, rather than the API.", + i = "Consider checking that a file has been created in the expected location.") |> + cli::cli_abort(call = call) } \ No newline at end of file diff --git a/R/collect_taxa.R b/R/collect_taxa.R index 5efbd111..ec8277b5 100644 --- a/R/collect_taxa.R +++ b/R/collect_taxa.R @@ -245,7 +245,7 @@ check_search_terms <- function(result, atlas) { rlang::format_error_bullets() |> cli::cli_text()) - cli::cli_inform(bullets) + cli::cli_text(bullets) cli::cli_end(d) } } diff --git a/R/galah_bbox.R b/R/galah_bbox.R index 1b19db72..e3ff76e0 100644 --- a/R/galah_bbox.R +++ b/R/galah_bbox.R @@ -98,10 +98,12 @@ galah_bbox <- function(...) { # currently a bug where the ALA doesn't accept some polygons # to avoid any issues, any polygons are converted to multipolygons if (inherits(query, "sf") || inherits(query, "sfc")) { - cli::cli_inform(" - Data returned for bounding box: - xmin = {bbox_coords$xmin} xmax = {bbox_coords$xmax} \\ - ymin = {bbox_coords$ymin} ymax = {bbox_coords$ymax}") + cli::cli({ + cli::cli_text("Data returned for bounding box:") + c("xmin = {bbox_coords$xmin} xmax = {bbox_coords$xmax}", + "ymin = {bbox_coords$ymin} ymax = {bbox_coords$ymax}") |> + cli::cli_bullets() + }) out_query <- build_wkt(query) } diff --git a/R/galah_config.R b/R/galah_config.R index 899f8235..970b60ce 100644 --- a/R/galah_config.R +++ b/R/galah_config.R @@ -275,8 +275,7 @@ enforce_download_reason <- function(value, dplyr::filter(valid_reasons$name == value) |> dplyr::select("id") |> dplyr::pull("id") - c("v" = "Matched \"{value}\" to valid download reason ID {value_id}.") |> - cli::cli_inform(call = error_call) + cli::cli_bullets(c("v" = "Matched \"{value}\" to valid download reason ID {value_id}.")) value_id }else{ value @@ -334,8 +333,14 @@ configure_atlas <- function(query, #' @keywords Internal check_atlas <- function(current_data, new_data){ if(new_data$region != current_data$region){ - cli::cli_inform( - "Atlas selected: {new_data$organisation} ({new_data$acronym}) [{new_data$region}]") + current_url <- show_all_atlases() |> + dplyr::filter(.data$region == new_data$region) |> + dplyr::pull("url") + cli::cli({ + cli::cli_text("New organisation selected: {new_data$organisation} ({new_data$acronym})") + cli::col_magenta(current_url) |> + cli::cli_text() + }) } new_data } @@ -351,7 +356,7 @@ check_authentication_argument <- function(x){ if(isTRUE(purrr::pluck(x, "package", "authenticate")) & # value set to TRUE by user is.null(retrieve_cache("config")) # not already cached ){ - cli::cli_inform("Caching `config` information to support authentication") + cli::cli_text("Caching `config` information to support authentication") config <- request_metadata(type = "config") |> collect() |> try(silent = TRUE) diff --git a/R/galah_radius.R b/R/galah_radius.R index 173c4fc9..928ec166 100644 --- a/R/galah_radius.R +++ b/R/galah_radius.R @@ -133,9 +133,11 @@ parse_point_radius <- function(..., # Should this be an error? A message? if(radius > 1565) { - c("Radius is larger than the area of Australia.", - i = "Try reducing the radius to narrow your query.") |> - cli::cli_inform() + cli::cli({ + cli::cli_text("Supplied radius is larger than the area of Australia.") + c(i = "Try reducing the radius to narrow your query.") |> + cli::cli_bullets() + }) } out_query <- list(lat = lat, diff --git a/R/galah_select.R b/R/galah_select.R index ca2eaca0..863000d3 100644 --- a/R/galah_select.R +++ b/R/galah_select.R @@ -130,7 +130,7 @@ #' @export select.data_request <- function(.data, ..., group){ if(is_gbif()){ - cli::cli_inform("`select()` is not supported for GBIF: skipping") + cli::cli_text("`select()` is not supported for GBIF occurrence downloads API v1: skipping") .data }else{ dots <- rlang::enquos(..., .ignore_empty = "all") @@ -158,7 +158,7 @@ galah_select <- function(..., group){ detect_request_object() |> as.list() if(is_gbif()){ - cli::cli_inform("`select()` is not supported for GBIF: skipping") + cli::cli_text("`select()` is not supported for GBIF occurrence downloads API v1: skipping") if(inherits(dots[[1]], "data_request")){ dots[[1]] }else{ diff --git a/R/messages.R b/R/messages.R index 7518a90b..5ebc40b3 100644 --- a/R/messages.R +++ b/R/messages.R @@ -32,5 +32,5 @@ system_down_message <- function(function_name, i = "This might mean that the API is down, or that you are not connected to the internet.", i = "Double check that your query is correct, or try again later." ) |> - cli::cli_inform(call = error_call) + cli::cli_abort(call = error_call) } \ No newline at end of file diff --git a/R/show_values.R b/R/show_values.R index 6de1df34..0c39229c 100644 --- a/R/show_values.R +++ b/R/show_values.R @@ -84,15 +84,15 @@ show_values <- function(df, df <- df[1, ] c("!" = "Search returned {n_matches} matched {type}.", "*" = "Showing values for '{match_name}'.") |> - cli::cli_inform() + cli::cli_bullets() } else { if (is.na(match_name)) { cli::col_yellow("`search_all()` returned no matched `{type}`.") |> - cli::cli_inform() + cli::cli_text() tibble::tibble() } else { c("*" = "Showing values for '{match_name}'.") |> - cli::cli_inform() + cli::cli_bullets() } } From ec18152964c60cc194b922bf3eabc4f82d0737a2 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Fri, 7 Nov 2025 11:26:58 +1100 Subject: [PATCH 45/94] Restructure /R directory to group `dplyr` extentions No changes to code, just rearrranging --- NAMESPACE | 1 + R/{collect-files.R => collect_files.R} | 0 R/{arrange.R => dplyr-arrange.R} | 0 R/{collapse.R => dplyr-collapse.R} | 0 R/{collect.R => dplyr-collect.R} | 0 R/{compute.R => dplyr-compute.R} | 0 R/{count-data_request.R => dplyr-count.R} | 0 R/dplyr-desc.R | 23 +++ R/dplyr-filter.R | 177 ++++++++++++++++++++ R/dplyr-group_by.R | 72 ++++++++ R/dplyr-select.R | 193 ++++++++++++++++++++++ R/{slice_head.R => dplyr-slice_head.R} | 0 R/galah_filter.R | 178 -------------------- R/galah_group_by.R | 73 -------- R/galah_select.R | 193 ---------------------- R/tidyr-unnest.R | 56 +++++++ R/tidyverse.R | 91 ---------- man/arrange.data_request.Rd | 2 +- man/collapse.data_request.Rd | 2 +- man/collect.data_request.Rd | 2 +- man/compute.data_request.Rd | 2 +- man/count.data_request.Rd | 2 +- man/desc.Rd | 32 ++++ man/filter.data_request.Rd | 2 +- man/group_by.data_request.Rd | 2 +- man/select.data_request.Rd | 2 +- man/slice_head.data_request.Rd | 2 +- man/tidyverse_functions.Rd | 73 -------- man/unnest.Rd | 45 +++++ 29 files changed, 608 insertions(+), 617 deletions(-) rename R/{collect-files.R => collect_files.R} (100%) rename R/{arrange.R => dplyr-arrange.R} (100%) rename R/{collapse.R => dplyr-collapse.R} (100%) rename R/{collect.R => dplyr-collect.R} (100%) rename R/{compute.R => dplyr-compute.R} (100%) rename R/{count-data_request.R => dplyr-count.R} (100%) create mode 100644 R/dplyr-desc.R create mode 100644 R/dplyr-filter.R create mode 100644 R/dplyr-group_by.R create mode 100644 R/dplyr-select.R rename R/{slice_head.R => dplyr-slice_head.R} (100%) create mode 100644 R/tidyr-unnest.R delete mode 100644 R/tidyverse.R create mode 100644 man/desc.Rd delete mode 100644 man/tidyverse_functions.Rd create mode 100644 man/unnest.Rd diff --git a/NAMESPACE b/NAMESPACE index 57426c43..ae63c810 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -124,6 +124,7 @@ export(show_values) export(slice_head) export(st_crop) export(unnest) +export(use_authentication) importFrom(dplyr,arrange) importFrom(dplyr,collapse) importFrom(dplyr,collect) diff --git a/R/collect-files.R b/R/collect_files.R similarity index 100% rename from R/collect-files.R rename to R/collect_files.R diff --git a/R/arrange.R b/R/dplyr-arrange.R similarity index 100% rename from R/arrange.R rename to R/dplyr-arrange.R diff --git a/R/collapse.R b/R/dplyr-collapse.R similarity index 100% rename from R/collapse.R rename to R/dplyr-collapse.R diff --git a/R/collect.R b/R/dplyr-collect.R similarity index 100% rename from R/collect.R rename to R/dplyr-collect.R diff --git a/R/compute.R b/R/dplyr-compute.R similarity index 100% rename from R/compute.R rename to R/dplyr-compute.R diff --git a/R/count-data_request.R b/R/dplyr-count.R similarity index 100% rename from R/count-data_request.R rename to R/dplyr-count.R diff --git a/R/dplyr-desc.R b/R/dplyr-desc.R new file mode 100644 index 00000000..93d31dca --- /dev/null +++ b/R/dplyr-desc.R @@ -0,0 +1,23 @@ +#' Descending order +#' +#' @param ... column to order by +#' @returns A `tibble` used by `arrange.data_request()` to arrange rows of a +#' query. +#' @seealso \code{\link[=arrange.data_request]{arrange()}}, [galah_call()] +#' @examples \dontrun{ +#' # Arrange grouped record counts by descending year +#' galah_call() |> +#' identify("perameles") |> +#' filter(year > 2019) |> +#' count() |> +#' arrange(galah::desc(year)) |> +#' collect() +#' } +#' @export +desc <- function(...){ + dots <- rlang::enquos(..., .ignore_empty = "all") + parsed_dots <- parse_quosures_basic(dots) + tibble::tibble(variable = parsed_dots, + direction = "descending") +} + diff --git a/R/dplyr-filter.R b/R/dplyr-filter.R new file mode 100644 index 00000000..bc292882 --- /dev/null +++ b/R/dplyr-filter.R @@ -0,0 +1,177 @@ +#' Keep rows that match a condition +#' +#' The `filter()` function is used to subset a data, retaining all rows that +#' satisfy your conditions. To be retained, the row must produce a value of +#' `TRUE` for all conditions. Unlike 'local' filters that act on a `tibble`, +#' the galah implementations work by amending a query which is then enacted +#' by `collect()` or one of the `atlas_` family of functions (such as +#' `atlas_counts()` or `atlas_occurrences()`). +#' @name filter.data_request +#' @order 1 +#' @param .data An object of class `data_request`, `metadata_request` +#' or `files_request`, created using [galah_call()] or related functions. +#' @param ... Expressions that return a logical value, and are defined in terms +#' of the variables in the selected atlas (and checked using `show_all(fields)`. +#' If multiple expressions are included, they are combined with the & operator. +#' Only rows for which all conditions evaluate to `TRUE` are kept. +#' @param profile +#' `r lifecycle::badge("deprecated")` +#' Use `galah_apply_profile` instead. +#' @return A tibble containing filter values. +#' @seealso \code{\link[=select.data_request]{select()}}, +#' \code{\link[=group_by.data_request]{group_by()}} and [geolocate()] for +#' other ways to amend the information returned by [atlas_()] functions. Use +#' `search_all(fields)` to find fields that you can filter by, and +#' [show_values()] to find what values of those filters are available. +#' @details +#' +#' *Syntax* +#' +#' `filter.data_request()` and `galah_filter()` uses non-standard evaluation +#' (NSE), and are designed to be as compatible as possible with +#' `dplyr::filter()` syntax. Permissible examples include: +#' +#' * `==` (e.g. `year = 2020`) but not `=` (for consistency with `dplyr`) +#' * `!=`, e.g. `year != 2020`) +#' * `>` or `>=` (e.g. `year >= 2020`) +#' * `<` or `<=` (e.g. `year <= 2020`) +#' * `OR` statements (e.g. `year == 2018 | year == 2020`) +#' * `AND` statements (e.g. `year >= 2000 & year <= 2020`) +#' * Field names can be parsed from objects using `{{}}` syntax, e.g. `field <- "year"; value <- "2025"; galah_filter({{field}} == value)` +#' +#' Some general tips: +#' * Separating statements with a comma is equivalent to an `AND` statement; +#' Ergo `filter(year >= 2010 & year < 2020)` is the same as +#' `_filter(year >= 2010, year < 2020)`. +#' * All statements must include the field name; so +#' `filter(year == 2010 | year == 2021)` works, as does +#' `filter(year == c(2010, 2021))`, but `filter(year == 2010 | 2021)` +#' fails. +#' * It is possible to use an object to specify required values, e.g. +#' `year_value <- 2010; filter(year > year_value)`. +#' * `solr` supports range queries on text as well as numbers; so +#' `filter(cl22 >= "Tasmania")` is valid. +#' * It is possible to filter by 'assertions', which are statements about data +#' validity, such as `filter(assertions != c("INVALID_SCIENTIFIC_NAME", "COORDINATE_INVALID")`. +#' Valid assertions can be found using `show_all(assertions)`. +#' +#' *Exceptions* +#' +#' When querying occurrences, species, or their respective counts (i.e. all of +#' the above examples), field names are checked internally against +#' `show_all(fields)`. There are some cases where bespoke field names are +#' required, as follows. +#' +#' When requesting a data download from a DOI, the field `doi` is valid, i.e.: +#' \preformatted{galah_call() |> +#' filter(doi = "a-long-doi-string") |> +#' collect()} +#' +#' For taxonomic metadata, the `taxa` field is valid: +#' \preformatted{request_metadata() |> +#' filter(taxa == "Chordata") |> +#' unnest()} +#' +#' For building taxonomic trees, the `rank` field is valid: +#' \preformatted{request_data() |> +#' identify("Chordata") |> +#' filter(rank == "class") |> +#' atlas_taxonomy()} +#' +#' Media queries are more involved, but break two rules: they accept the `media` +#' field, and they accept a tibble on the rhs of the equation. For example, +#' users wishing to break down media queries into their respective API calls +#' should begin with an occurrence query: +#' +#' \preformatted{occurrences <- galah_call() |> +#' identify("Litoria peronii) |> +#' select(group = c("basic", "media") |> +#' collect()} +#' +#' They can then use the `media` field to request media metadata: +#' \preformatted{media_metadata <- galah_call("metadata") |> +#' filter(media == occurrences) |> +#' collect()} +#' +#' And finally, the metadata tibble can be used to request files: +#' \preformatted{galah_call("files") |> +#' filter(media == media_metadata) |> +#' collect()} +#' +#' @examples \dontrun{ +#' galah_call() |> +#' filter(year >= 2019, +#' basisOfRecord == "HumanObservation") |> +#' count() |> +#' collect() +#' } +#' @export +filter.data_request <- function(.data, ...){ + dots <- rlang::enquos(..., .ignore_empty = "all") + check_named_input(dots) + if(is_gbif()){ + filters <- parse_quosures_data_gbif(dots) # `handle_quosures_GBIF.R` + }else{ + filters <- parse_quosures_data(dots) # `handle_quosures.R` + } + update_request_object(.data, + filter = filters) +} +# usually filters as previously for ALA, but some exceptions: +# doi == "x" in `atlas_occurrences()` +# rank == "class" in `atlas_taxonomy()` replacement for `galah_down_to()` + +#' @rdname filter.data_request +#' @order 2 +#' @export +filter.metadata_request <- function(.data, ...){ + dots <- rlang::enquos(..., .ignore_empty = "all") + check_named_input(dots) + parse_quosures_metadata(.data, dots) +} +# Note: the intended purpose of this function is to pass `filter()` +# within the API call in the same was as `filter.data_request()`. +# In theory this would power `search_all()`; but in practice many +# APIs do not support a `q` argument that allows server-side filtering. +# The exception is GBIF. +# +# An unusual distinction is that when `unnest()` is also called, `filter()` is +# used to set the thing that is unnested; this is a different kind of search +# e.g. `request_metadata() |> filter(taxa == "Chordata") |> unnest()` + +#' simple parser for metadata +#' @noRd +#' @keywords Internal +parse_quosures_metadata <- function(request, dots){ + dots_parsed <- parse_quosures_files(dots) + names(dots_parsed)[2] <- "value" + request$filter <- as_metadata_filter(dots_parsed) + # The `filter` argument sets `type` when specified + initial_type <- request$type + supplied_type <- dots_parsed$variable[1] + if(!(supplied_type %in% c("taxa", "media")) & + !grepl("s$", supplied_type)){ + filter_type <- glue::glue("{supplied_type}s") + }else{ + filter_type <- supplied_type + } + if(grepl("-unnest$", initial_type)){ + request$type <- glue::glue("{filter_type}-unnest") + }else{ + request$type <- filter_type + } + request +} + +#' @rdname filter.data_request +#' @order 3 +#' @export +filter.files_request <- function(.data, ...){ + dots <- rlang::enquos(..., .ignore_empty = "all") + check_named_input(dots) + dots_parsed <- parse_quosures_files(dots) + check_files_filter(dots_parsed) + .data$type <- dots_parsed$variable[1] + .data$filter <- dots_parsed + .data +} \ No newline at end of file diff --git a/R/dplyr-group_by.R b/R/dplyr-group_by.R new file mode 100644 index 00000000..4511a0c9 --- /dev/null +++ b/R/dplyr-group_by.R @@ -0,0 +1,72 @@ +#' Group by one or more variables +#' +#' Most data operations are done on groups defined by variables. `group_by()` +#' takes a field name (unquoted) and performs a grouping operation. The default +#' behaviour is to use it in combination with +#' \code{\link[=count.data_request]{count()}} to give information on number +#' of occurrences per level of that field. Alternatively, you can use it +#' without count to get a download of occurrences grouped by that variable. This +#' is particularly useful when used with a taxonomic `ID` field (`speciesID`, +#' `genusID` etc.) as it allows further information to be appended to the result. +#' This is how [atlas_species()] works, for example. See +#' \code{\link[=select.data_request]{select()}} for details. +#' @param .data An object of class `data_request` +#' @param ... Zero or more individual column names to include +#' @return If any arguments are provided, returns a `data.frame` with +#' columns `name` and `type`, as per [select.data_request()]. +#' @examples \dontrun{ +#' # default usage is for grouping counts +#' galah_call() |> +#' group_by(basisOfRecord) |> +#' counts() |> +#' collect() +#' +#' # Alternatively, we can use this with an occurrence search +#' galah_call() |> +#' filter(year == 2024, +#' genus = "Crinia") |> +#' group_by(speciesID) |> +#' collect() +#' # note that this example is equivalent to `atlas_species()`; +#' # but using `group_by()` is more flexible. +#' } +#' @export +group_by.data_request <- function(.data, ...){ + parsed_dots <- rlang::enquos(..., .ignore_empty = "all") |> + parse_quosures_basic() + df <- parse_group_by(parsed_dots) + update_request_object(.data, + group_by = df) +} + +#' Internal parsing of `group_by` args +#' @noRd +#' @keywords Internal +parse_group_by <- function(dot_names, + error_call = rlang::caller_env()){ + if(length(dot_names) > 0){ + if(length(dot_names) > 3){ + c( + "Too many fields supplied.", + i = "`group_by.data_request` accepts a maximum of 3 fields.") |> + cli::cli_abort(call = error_call) + } + if(length(dot_names) > 0){ + names(dot_names) <- NULL # needed to avoid empty strings added as names + df <- tibble::tibble(name = dot_names) + df$type <- ifelse(stringr::str_detect(df$name, "[[:lower:]]"), + "field", + "assertions") + }else{ + df <- tibble::tibble(name = "name", + type = "type", + .rows = 0) + } + }else{ + df <- tibble::tibble(name = "name", + type = "type", + .rows = 0) + } + + return(df) +} \ No newline at end of file diff --git a/R/dplyr-select.R b/R/dplyr-select.R new file mode 100644 index 00000000..71cd5111 --- /dev/null +++ b/R/dplyr-select.R @@ -0,0 +1,193 @@ +#' @title Keep or drop columns using their names +#' +#' @description Select (and optionally rename) variables in a data frame, using +#' a concise mini-language that makes it easy to refer to variables based on +#' their name. Note that unlike calling `select()` on a local tibble, this +#' implementation is only evaluated at the +#' \code{\link[=collapse.data_request]{collapse()}} stage, meaning any errors +#' or messages will be triggered at the end of the pipe. +#' +#' `select()` supports `dplyr` **selection helpers**, including: +#' +#' * \code{\link[dplyr]{everything}}: Matches all variables. This is treated +#' unusually in `galah`; see `details`. +#' * \code{\link[dplyr]{last_col}}: Select last variable, possibly with an +#' offset. +#' +#' Other helpers select variables by matching patterns in their names: +#' +#' * \code{\link[dplyr]{starts_with}}: Starts with a prefix. +#' * \code{\link[dplyr]{ends_with}}: Ends with a suffix. +#' * \code{\link[dplyr]{contains}}: Contains a literal string. +#' * \code{\link[dplyr]{matches}}: Matches a regular expression. +#' * \code{\link[dplyr]{num_range}}: Matches a numerical range like x01, +#' x02, x03. +#' +#' Or from variables stored in a character vector: +#' +#' * \code{\link[dplyr]{all_of}}: Matches variable names in a character +#' vector. All names must be present, otherwise an out-of-bounds error is +#' thrown. +#' * \code{\link[dplyr]{any_of}}: Same as `all_of()`, except that no error +#' is thrown for names that don't exist. +#' +#' Or using a predicate function: +#' +#' * \code{\link[dplyr]{where}}: Applies a function to all variables and selects those for which the function returns `TRUE`. +#' @name select.data_request +#' @param .data An object of class `data_request`, created using [galah_call()]. +#' @param ... Zero or more individual column names to include. +#' @param group `string`: (optional) name of one or more column groups to +#' include. Valid options are `"basic"`, `"event"` `"taxonomy"`, `"media"` and +#' `"assertions"`. +#' @return A tibble +#' specifying the name and type of each column to include in the +#' call to `atlas_counts()` or `atlas_occurrences()`. +#' @details +#' GBIF nodes store content in hundreds of different fields, and users often +#' require thousands or millions of records at a time. To reduce time taken to +#' download data, and limit complexity of the resulting `tibble`, it is sensible +#' to restrict the fields returned by occurrence queries. The full list of +#' available fields can be viewed with `show_all(fields)`. Note that `select()` +#' and `galah_select()` are supported for all atlases that allow downloads, with +#' the exception of GBIF, for which all columns are returned. +#' +#' Calling the argument `group = "basic"` returns the following columns: +#' +#' * `recordID` +#' * `scientificName` +#' * `taxonConceptID` +#' * `decimalLatitude` +#' * `decimalLongitude` +#' * `eventDate` +#' * `basisOfRecord` +#' * `occurrenceStatus` +#' * `dataResourceName` +#' +#' Using `group = "event"` returns the following columns: +#' +#' * `eventRemarks` +#' * `eventTime` +#' * `eventID` +#' * `eventDate` +#' * `samplingEffort` +#' * `samplingProtocol` +#' +#' Using `group = "media"` returns the following columns: +#' +#' * `multimedia` +#' * `multimediaLicence` +#' * `images` +#' * `videos` +#' * `sounds` +#' +#' Using `group = "taxonomy"` returns higher taxonomic information for a given +#' query. It is the only `group` that is accepted by `atlas_species()` as well +#' as `atlas_occurrences()`. +#' +#' Using `group = "assertions"` returns all quality assertion-related +#' columns. The list of assertions is shown by `show_all_assertions()`. +#' +#' For `atlas_occurrences()`, arguments passed to `...` should be valid field +#' names, which you can check using `show_all(fields)`. For `atlas_species()`, +#' it should be one or more of: +#' +#' * `counts` to include counts of occurrences per species. +#' * `synonyms` to include any synonymous names. +#' * `lists` to include authoritative lists that each species is included on. +#' +#' For metadata queries - as generated using [request_metadata()] or +#' [galah_call()] - `select()` can now be used to return only the requested +#' columns. Unlike data queries, this works by capturing the user's query +#' and applying it user-side, rather than amending the query. +#' +#' @seealso \code{\link[=filter.data_request]{filter()}}, +#' \code{\link[=st_crop.data_request]{st_crop()}} and +#' \code{\link[=identify.data_request]{identify()}} for other ways to restrict +#' the information returned; `show_all(fields)` to list available fields. +#' @examples \dontrun{ +#' # Download occurrence records of *Perameles*, +#' # Only return scientificName and eventDate columns +#' galah_config(email = "your-email@email.com") +#' galah_call() |> +#' identify("perameles")|> +#' select(scientificName, eventDate) |> +#' collect() +#' +#' # Only return the "basic" group of columns and the basisOfRecord column +#' galah_call() |> +#' identify("perameles") |> +#' select(basisOfRecord, group = "basic") |> +#' collect() +#' +#' # When used in a pipe, `galah_select()` and `select()` are synonymous. +#' # Hence the previous example can be rewritten as: +#' galah_call() |> +#' galah_identify("perameles") |> +#' galah_select(basisOfRecord, group = "basic") |> +#' collect() +#' } +#' @export +select.data_request <- function(.data, ..., group){ + if(is_gbif()){ + cli::cli_text("`select()` is not supported for GBIF occurrence downloads API v1: skipping") + .data + }else{ + dots <- rlang::enquos(..., .ignore_empty = "all") + list(quosure = dots, + summary = generate_summary(dots)) |> + add_group(group) |> + update_request_object(.data, select = _) + } +} + +#' @rdname select.data_request +#' @export +select.metadata_request <- function(.data, ...){ + dots <- rlang::enquos(..., + .ignore_empty = "all") + list(quosure = dots, + summary = generate_summary(dots)) |> + update_request_object(.data, select = _) +} + + +#' internal function to summarise select function (to support `print()`) +#' @noRd +#' @keywords Internal +generate_summary <- function(dots){ + labels <- purrr::map(dots, rlang::expr_text) |> + unlist() |> + glue::glue_collapse(sep = " | ") + labels[labels != ""] +} + +#' internal function to add `group` arg to the end of a list +#' @noRd +#' @keywords Internal +add_group <- function(dots, group){ + group <- check_groups(group, n = length(dots)) + summary_length <- nchar(dots$summary) + if(is.null(group)){ + if(summary_length < 1){ + group <- "basic" + dots$group <- group + }else{ + dots$group <- vector(mode = "character", length = 0L) + } + }else{ + dots$group <- group + } + if(length(dots$group) > 0){ + if(summary_length < 1){ + separator <- "" + }else{ + separator <- " | " + } + dots$summary <- paste0(dots$summary, + separator, + "group = ", + paste(group, collapse = ", ")) + } + dots +} \ No newline at end of file diff --git a/R/slice_head.R b/R/dplyr-slice_head.R similarity index 100% rename from R/slice_head.R rename to R/dplyr-slice_head.R diff --git a/R/galah_filter.R b/R/galah_filter.R index aa78e50c..4127f80c 100644 --- a/R/galah_filter.R +++ b/R/galah_filter.R @@ -1,181 +1,3 @@ -#' Keep rows that match a condition -#' -#' The `filter()` function is used to subset a data, retaining all rows that -#' satisfy your conditions. To be retained, the row must produce a value of -#' `TRUE` for all conditions. Unlike 'local' filters that act on a `tibble`, -#' the galah implementations work by amending a query which is then enacted -#' by `collect()` or one of the `atlas_` family of functions (such as -#' `atlas_counts()` or `atlas_occurrences()`). -#' @name filter.data_request -#' @order 1 -#' @param .data An object of class `data_request`, `metadata_request` -#' or `files_request`, created using [galah_call()] or related functions. -#' @param ... Expressions that return a logical value, and are defined in terms -#' of the variables in the selected atlas (and checked using `show_all(fields)`. -#' If multiple expressions are included, they are combined with the & operator. -#' Only rows for which all conditions evaluate to `TRUE` are kept. -#' @param profile -#' `r lifecycle::badge("deprecated")` -#' Use `galah_apply_profile` instead. -#' @return A tibble containing filter values. -#' @seealso \code{\link[=select.data_request]{select()}}, -#' \code{\link[=group_by.data_request]{group_by()}} and [geolocate()] for -#' other ways to amend the information returned by [atlas_()] functions. Use -#' `search_all(fields)` to find fields that you can filter by, and -#' [show_values()] to find what values of those filters are available. -#' @details -#' -#' *Syntax* -#' -#' `filter.data_request()` and `galah_filter()` uses non-standard evaluation -#' (NSE), and are designed to be as compatible as possible with -#' `dplyr::filter()` syntax. Permissible examples include: -#' -#' * `==` (e.g. `year = 2020`) but not `=` (for consistency with `dplyr`) -#' * `!=`, e.g. `year != 2020`) -#' * `>` or `>=` (e.g. `year >= 2020`) -#' * `<` or `<=` (e.g. `year <= 2020`) -#' * `OR` statements (e.g. `year == 2018 | year == 2020`) -#' * `AND` statements (e.g. `year >= 2000 & year <= 2020`) -#' * Field names can be parsed from objects using `{{}}` syntax, e.g. `field <- "year"; value <- "2025"; galah_filter({{field}} == value)` -#' -#' Some general tips: -#' * Separating statements with a comma is equivalent to an `AND` statement; -#' Ergo `filter(year >= 2010 & year < 2020)` is the same as -#' `_filter(year >= 2010, year < 2020)`. -#' * All statements must include the field name; so -#' `filter(year == 2010 | year == 2021)` works, as does -#' `filter(year == c(2010, 2021))`, but `filter(year == 2010 | 2021)` -#' fails. -#' * It is possible to use an object to specify required values, e.g. -#' `year_value <- 2010; filter(year > year_value)`. -#' * `solr` supports range queries on text as well as numbers; so -#' `filter(cl22 >= "Tasmania")` is valid. -#' * It is possible to filter by 'assertions', which are statements about data -#' validity, such as `filter(assertions != c("INVALID_SCIENTIFIC_NAME", "COORDINATE_INVALID")`. -#' Valid assertions can be found using `show_all(assertions)`. -#' -#' *Exceptions* -#' -#' When querying occurrences, species, or their respective counts (i.e. all of -#' the above examples), field names are checked internally against -#' `show_all(fields)`. There are some cases where bespoke field names are -#' required, as follows. -#' -#' When requesting a data download from a DOI, the field `doi` is valid, i.e.: -#' \preformatted{galah_call() |> -#' filter(doi = "a-long-doi-string") |> -#' collect()} -#' -#' For taxonomic metadata, the `taxa` field is valid: -#' \preformatted{request_metadata() |> -#' filter(taxa == "Chordata") |> -#' unnest()} -#' -#' For building taxonomic trees, the `rank` field is valid: -#' \preformatted{request_data() |> -#' identify("Chordata") |> -#' filter(rank == "class") |> -#' atlas_taxonomy()} -#' -#' Media queries are more involved, but break two rules: they accept the `media` -#' field, and they accept a tibble on the rhs of the equation. For example, -#' users wishing to break down media queries into their respective API calls -#' should begin with an occurrence query: -#' -#' \preformatted{occurrences <- galah_call() |> -#' identify("Litoria peronii) |> -#' select(group = c("basic", "media") |> -#' collect()} -#' -#' They can then use the `media` field to request media metadata: -#' \preformatted{media_metadata <- galah_call("metadata") |> -#' filter(media == occurrences) |> -#' collect()} -#' -#' And finally, the metadata tibble can be used to request files: -#' \preformatted{galah_call("files") |> -#' filter(media == media_metadata) |> -#' collect()} -#' -#' @examples \dontrun{ -#' galah_call() |> -#' filter(year >= 2019, -#' basisOfRecord == "HumanObservation") |> -#' count() |> -#' collect() -#' } -#' @export -filter.data_request <- function(.data, ...){ - dots <- rlang::enquos(..., .ignore_empty = "all") - check_named_input(dots) - if(is_gbif()){ - filters <- parse_quosures_data_gbif(dots) # `handle_quosures_GBIF.R` - }else{ - filters <- parse_quosures_data(dots) # `handle_quosures.R` - } - update_request_object(.data, - filter = filters) -} -# usually filters as previously for ALA, but some exceptions: -# doi == "x" in `atlas_occurrences()` -# rank == "class" in `atlas_taxonomy()` replacement for `galah_down_to()` - -#' @rdname filter.data_request -#' @order 2 -#' @export -filter.metadata_request <- function(.data, ...){ - dots <- rlang::enquos(..., .ignore_empty = "all") - check_named_input(dots) - parse_quosures_metadata(.data, dots) -} -# Note: the intended purpose of this function is to pass `filter()` -# within the API call in the same was as `filter.data_request()`. -# In theory this would power `search_all()`; but in practice many -# APIs do not support a `q` argument that allows server-side filtering. -# The exception is GBIF. -# -# An unusual distinction is that when `unnest()` is also called, `filter()` is -# used to set the thing that is unnested; this is a different kind of search -# e.g. `request_metadata() |> filter(taxa == "Chordata") |> unnest()` - -#' simple parser for metadata -#' @noRd -#' @keywords Internal -parse_quosures_metadata <- function(request, dots){ - dots_parsed <- parse_quosures_files(dots) - names(dots_parsed)[2] <- "value" - request$filter <- as_metadata_filter(dots_parsed) - # The `filter` argument sets `type` when specified - initial_type <- request$type - supplied_type <- dots_parsed$variable[1] - if(!(supplied_type %in% c("taxa", "media")) & - !grepl("s$", supplied_type)){ - filter_type <- glue::glue("{supplied_type}s") - }else{ - filter_type <- supplied_type - } - if(grepl("-unnest$", initial_type)){ - request$type <- glue::glue("{filter_type}-unnest") - }else{ - request$type <- filter_type - } - request -} - -#' @rdname filter.data_request -#' @order 3 -#' @export -filter.files_request <- function(.data, ...){ - dots <- rlang::enquos(..., .ignore_empty = "all") - check_named_input(dots) - dots_parsed <- parse_quosures_files(dots) - check_files_filter(dots_parsed) - .data$type <- dots_parsed$variable[1] - .data$filter <- dots_parsed - .data -} - #' @rdname filter.data_request #' @order 4 #' @export diff --git a/R/galah_group_by.R b/R/galah_group_by.R index 16719a5d..b2a5878a 100644 --- a/R/galah_group_by.R +++ b/R/galah_group_by.R @@ -1,44 +1,3 @@ -#' Group by one or more variables -#' -#' Most data operations are done on groups defined by variables. `group_by()` -#' takes a field name (unquoted) and performs a grouping operation. The default -#' behaviour is to use it in combination with -#' \code{\link[=count.data_request]{count()}} to give information on number -#' of occurrences per level of that field. Alternatively, you can use it -#' without count to get a download of occurrences grouped by that variable. This -#' is particularly useful when used with a taxonomic `ID` field (`speciesID`, -#' `genusID` etc.) as it allows further information to be appended to the result. -#' This is how [atlas_species()] works, for example. See -#' \code{\link[=select.data_request]{select()}} for details. -#' @param .data An object of class `data_request` -#' @param ... Zero or more individual column names to include -#' @return If any arguments are provided, returns a `data.frame` with -#' columns `name` and `type`, as per [select.data_request()]. -#' @examples \dontrun{ -#' # default usage is for grouping counts -#' galah_call() |> -#' group_by(basisOfRecord) |> -#' counts() |> -#' collect() -#' -#' # Alternatively, we can use this with an occurrence search -#' galah_call() |> -#' filter(year == 2024, -#' genus = "Crinia") |> -#' group_by(speciesID) |> -#' collect() -#' # note that this example is equivalent to `atlas_species()`; -#' # but using `group_by()` is more flexible. -#' } -#' @export -group_by.data_request <- function(.data, ...){ -parsed_dots <- rlang::enquos(..., .ignore_empty = "all") |> - parse_quosures_basic() -df <- parse_group_by(parsed_dots) -update_request_object(.data, - group_by = df) -} - #' @rdname group_by.data_request #' @export galah_group_by <- function(...){ @@ -55,36 +14,4 @@ galah_group_by <- function(...){ parse_quosures_basic(dots) |> parse_group_by() }) -} - -#' Internal parsing of `group_by` args -#' @noRd -#' @keywords Internal -parse_group_by <- function(dot_names, - error_call = rlang::caller_env()){ - if(length(dot_names) > 0){ - if(length(dot_names) > 3){ - c( - "Too many fields supplied.", - i = "`group_by.data_request` accepts a maximum of 3 fields.") |> - cli::cli_abort(call = error_call) - } - if(length(dot_names) > 0){ - names(dot_names) <- NULL # needed to avoid empty strings added as names - df <- tibble::tibble(name = dot_names) - df$type <- ifelse(stringr::str_detect(df$name, "[[:lower:]]"), - "field", - "assertions") - }else{ - df <- tibble::tibble(name = "name", - type = "type", - .rows = 0) - } - }else{ - df <- tibble::tibble(name = "name", - type = "type", - .rows = 0) - } - - return(df) } \ No newline at end of file diff --git a/R/galah_select.R b/R/galah_select.R index 863000d3..221d6a72 100644 --- a/R/galah_select.R +++ b/R/galah_select.R @@ -1,156 +1,3 @@ -#' @title Keep or drop columns using their names -#' -#' @description Select (and optionally rename) variables in a data frame, using -#' a concise mini-language that makes it easy to refer to variables based on -#' their name. Note that unlike calling `select()` on a local tibble, this -#' implementation is only evaluated at the -#' \code{\link[=collapse.data_request]{collapse()}} stage, meaning any errors -#' or messages will be triggered at the end of the pipe. -#' -#' `select()` supports `dplyr` **selection helpers**, including: -#' -#' * \code{\link[dplyr]{everything}}: Matches all variables. This is treated -#' unusually in `galah`; see `details`. -#' * \code{\link[dplyr]{last_col}}: Select last variable, possibly with an -#' offset. -#' -#' Other helpers select variables by matching patterns in their names: -#' -#' * \code{\link[dplyr]{starts_with}}: Starts with a prefix. -#' * \code{\link[dplyr]{ends_with}}: Ends with a suffix. -#' * \code{\link[dplyr]{contains}}: Contains a literal string. -#' * \code{\link[dplyr]{matches}}: Matches a regular expression. -#' * \code{\link[dplyr]{num_range}}: Matches a numerical range like x01, -#' x02, x03. -#' -#' Or from variables stored in a character vector: -#' -#' * \code{\link[dplyr]{all_of}}: Matches variable names in a character -#' vector. All names must be present, otherwise an out-of-bounds error is -#' thrown. -#' * \code{\link[dplyr]{any_of}}: Same as `all_of()`, except that no error -#' is thrown for names that don't exist. -#' -#' Or using a predicate function: -#' -#' * \code{\link[dplyr]{where}}: Applies a function to all variables and selects those for which the function returns `TRUE`. -#' @name select.data_request -#' @param .data An object of class `data_request`, created using [galah_call()]. -#' @param ... Zero or more individual column names to include. -#' @param group `string`: (optional) name of one or more column groups to -#' include. Valid options are `"basic"`, `"event"` `"taxonomy"`, `"media"` and -#' `"assertions"`. -#' @return A tibble -#' specifying the name and type of each column to include in the -#' call to `atlas_counts()` or `atlas_occurrences()`. -#' @details -#' GBIF nodes store content in hundreds of different fields, and users often -#' require thousands or millions of records at a time. To reduce time taken to -#' download data, and limit complexity of the resulting `tibble`, it is sensible -#' to restrict the fields returned by occurrence queries. The full list of -#' available fields can be viewed with `show_all(fields)`. Note that `select()` -#' and `galah_select()` are supported for all atlases that allow downloads, with -#' the exception of GBIF, for which all columns are returned. -#' -#' Calling the argument `group = "basic"` returns the following columns: -#' -#' * `recordID` -#' * `scientificName` -#' * `taxonConceptID` -#' * `decimalLatitude` -#' * `decimalLongitude` -#' * `eventDate` -#' * `basisOfRecord` -#' * `occurrenceStatus` -#' * `dataResourceName` -#' -#' Using `group = "event"` returns the following columns: -#' -#' * `eventRemarks` -#' * `eventTime` -#' * `eventID` -#' * `eventDate` -#' * `samplingEffort` -#' * `samplingProtocol` -#' -#' Using `group = "media"` returns the following columns: -#' -#' * `multimedia` -#' * `multimediaLicence` -#' * `images` -#' * `videos` -#' * `sounds` -#' -#' Using `group = "taxonomy"` returns higher taxonomic information for a given -#' query. It is the only `group` that is accepted by `atlas_species()` as well -#' as `atlas_occurrences()`. -#' -#' Using `group = "assertions"` returns all quality assertion-related -#' columns. The list of assertions is shown by `show_all_assertions()`. -#' -#' For `atlas_occurrences()`, arguments passed to `...` should be valid field -#' names, which you can check using `show_all(fields)`. For `atlas_species()`, -#' it should be one or more of: -#' -#' * `counts` to include counts of occurrences per species. -#' * `synonyms` to include any synonymous names. -#' * `lists` to include authoritative lists that each species is included on. -#' -#' For metadata queries - as generated using [request_metadata()] or -#' [galah_call()] - `select()` can now be used to return only the requested -#' columns. Unlike data queries, this works by capturing the user's query -#' and applying it user-side, rather than amending the query. -#' -#' @seealso \code{\link[=filter.data_request]{filter()}}, -#' \code{\link[=st_crop.data_request]{st_crop()}} and -#' \code{\link[=identify.data_request]{identify()}} for other ways to restrict -#' the information returned; `show_all(fields)` to list available fields. -#' @examples \dontrun{ -#' # Download occurrence records of *Perameles*, -#' # Only return scientificName and eventDate columns -#' galah_config(email = "your-email@email.com") -#' galah_call() |> -#' identify("perameles")|> -#' select(scientificName, eventDate) |> -#' collect() -#' -#' # Only return the "basic" group of columns and the basisOfRecord column -#' galah_call() |> -#' identify("perameles") |> -#' select(basisOfRecord, group = "basic") |> -#' collect() -#' -#' # When used in a pipe, `galah_select()` and `select()` are synonymous. -#' # Hence the previous example can be rewritten as: -#' galah_call() |> -#' galah_identify("perameles") |> -#' galah_select(basisOfRecord, group = "basic") |> -#' collect() -#' } -#' @export -select.data_request <- function(.data, ..., group){ - if(is_gbif()){ - cli::cli_text("`select()` is not supported for GBIF occurrence downloads API v1: skipping") - .data - }else{ - dots <- rlang::enquos(..., .ignore_empty = "all") - list(quosure = dots, - summary = generate_summary(dots)) |> - add_group(group) |> - update_request_object(.data, select = _) - } -} - -#' @rdname select.data_request -#' @export -select.metadata_request <- function(.data, ...){ - dots <- rlang::enquos(..., - .ignore_empty = "all") - list(quosure = dots, - summary = generate_summary(dots)) |> - update_request_object(.data, select = _) -} - #' @rdname select.data_request #' @export galah_select <- function(..., group){ @@ -177,44 +24,4 @@ galah_select <- function(..., group){ add_group(group) } } -} - -#' internal function to summarise select function (to support `print()`) -#' @noRd -#' @keywords Internal -generate_summary <- function(dots){ - labels <- purrr::map(dots, rlang::expr_text) |> - unlist() |> - glue::glue_collapse(sep = " | ") - labels[labels != ""] -} - -#' internal function to add `group` arg to the end of a list -#' @noRd -#' @keywords Internal -add_group <- function(dots, group){ - group <- check_groups(group, n = length(dots)) - summary_length <- nchar(dots$summary) - if(is.null(group)){ - if(summary_length < 1){ - group <- "basic" - dots$group <- group - }else{ - dots$group <- vector(mode = "character", length = 0L) - } - }else{ - dots$group <- group - } - if(length(dots$group) > 0){ - if(summary_length < 1){ - separator <- "" - }else{ - separator <- " | " - } - dots$summary <- paste0(dots$summary, - separator, - "group = ", - paste(group, collapse = ", ")) - } - dots } \ No newline at end of file diff --git a/R/tidyr-unnest.R b/R/tidyr-unnest.R new file mode 100644 index 00000000..ed068c11 --- /dev/null +++ b/R/tidyr-unnest.R @@ -0,0 +1,56 @@ +#' Unnest a query +#' +#' This syntax is borrowed from `tidyr`, and is conceptually used in the same +#' way here, but in galah unnest amends the query to unnest information +#' server-side, rather than on your machine. It powers all of the +#' [show_values()] functions in galah. +#' @details +#' Re-implementing existing functions has the consequence of supporting +#' consistent syntax with tidyverse, at the cost of potentially introducing +#' conflicts. This can be avoided by using the `::` operator where required. +#' @param .query An object of class `metadata_request` +#' @returns An object of class `metadata_request` +#' @examples \dontrun{ +#' # Return values of field `basisOfRecord` +#' request_metadata() |> +#' unnest() |> +#' filter(field == basisOfRecord) |> +#' collect() +#' +#' # Using `galah::unnest()` in this way is equivalent to: +#' show_all(fields, "basisOfRecord") |> +#' show_values() +#' +#' # to add information to a species list: +#' request_metadata() |> +#' filter(list == "dr650") |> +#' select(everything()) |> +#' unnest() |> +#' collect() +#' } +#' @export +unnest <- function(.query){ + if(!inherits(.query, "metadata_request")){ + cli::cli_abort("`galah::unnest()` can only be used with objects of class `metadata_request`.") + } + if(!is.null(.query$filter)){ + # check whether `type` is supplied as singular (i.e. `field` not `fields`) + supplied_type <- .query$filter$variable[1] + if(supplied_type != "taxa" & !grepl("s$", supplied_type)){ + supplied_type <- glue::glue("{supplied_type}s") + } + }else if(!is.null(.query$identify)){ + supplied_type <- "taxa" + }else{ + supplied_type <- .query$type + } + # ensure only used with certain query types + valid_types <- c("fields", "lists", "profiles", "taxa") + if(!(supplied_type %in% valid_types)){ + c("Invalid `type` supplied to `unnest()`", + i = "Valid types are `fields`, `lists`, `profiles` or `taxa`") |> + cli::cli_abort() + } + .query$type <- glue::glue("{supplied_type}-unnest") + .query +} diff --git a/R/tidyverse.R b/R/tidyverse.R deleted file mode 100644 index 5c7e94d6..00000000 --- a/R/tidyverse.R +++ /dev/null @@ -1,91 +0,0 @@ -#' Non-generic tidyverse functions -#' -#' Several useful functions from tidyverse packages are `generic`, meaning -#' that we can define class-specific versions of those functions and implement -#' them in galah; examples include `filter()`, `select()` and `group_by()`. -#' However, there are also functions that are only defined within tidyverse -#' packages and are not generic. In a few cases we have re-implemented these -#' functions in galah. This has the consequence of supporting consistent -#' syntax with tidyverse, at the cost of potentially introducing conflicts. -#' This can be avoided by using the `::` operator where required (see examples). -#' -#' The following functions are included: -#' - `desc()` (`dplyr`): Use within `arrange()` to specify arrangement should be descending -#' - `unnest()` (`tidyr`): Use to 'drill down' into nested information on `fields`, `lists`, `profiles`, or `taxa` -#' -#' These galah versions all use lazy evaluation. -#' @returns -#' - `galah::desc()` returns a `tibble` used by `arrange.data_request()` to arrange rows of a query. -#' - `galah::unnest()` returns an object of class `metadata_request`. -#' @seealso \code{\link[=arrange.data_request]{arrange()}}, [galah_call()] -#' @examples \dontrun{ -#' # Arrange grouped record counts by descending year -#' galah_call() |> -#' identify("perameles") |> -#' filter(year > 2019) |> -#' count() |> -#' arrange(galah::desc(year)) |> -#' collect() -#' -#' # Return values of field `basisOfRecord` -#' request_metadata() |> -#' unnest() |> -#' filter(field == basisOfRecord) |> -#' collect() -#' -#' # Using `galah::unnest()` in this way is equivalent to: -#' show_all(fields, "basisOfRecord") |> -#' show_values() -#' -#' # to add information to a species list: -#' request_metadata() |> -#' filter(list == "dr650") |> -#' select(everything()) |> -#' unnest() |> -#' collect() -#' } -#' @name tidyverse_functions -NULL - -# @rdname tidyverse_functions -# @export -# between <- function(){} - -#' @rdname tidyverse_functions -#' @param ... column to order by -#' @export -desc <- function(...){ - dots <- rlang::enquos(..., .ignore_empty = "all") - parsed_dots <- parse_quosures_basic(dots) - tibble::tibble(variable = parsed_dots, - direction = "descending") -} - -#' @rdname tidyverse_functions -#' @param .query An object of class `metadata_request` -#' @export -unnest <- function(.query){ - if(!inherits(.query, "metadata_request")){ - cli::cli_abort("`galah::unnest()` can only be used with objects of class `metadata_request`.") - } - if(!is.null(.query$filter)){ - # check whether `type` is supplied as singular (i.e. `field` not `fields`) - supplied_type <- .query$filter$variable[1] - if(supplied_type != "taxa" & !grepl("s$", supplied_type)){ - supplied_type <- glue::glue("{supplied_type}s") - } - }else if(!is.null(.query$identify)){ - supplied_type <- "taxa" - }else{ - supplied_type <- .query$type - } - # ensure only used with certain query types - valid_types <- c("fields", "lists", "profiles", "taxa") - if(!(supplied_type %in% valid_types)){ - c("Invalid `type` supplied to `unnest()`", - i = "Valid types are `fields`, `lists`, `profiles` or `taxa`") |> - cli::cli_abort() - } - .query$type <- glue::glue("{supplied_type}-unnest") - .query -} diff --git a/man/arrange.data_request.Rd b/man/arrange.data_request.Rd index f93a6c86..e332fefe 100644 --- a/man/arrange.data_request.Rd +++ b/man/arrange.data_request.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/arrange.R +% Please edit documentation in R/dplyr-arrange.R \name{arrange.data_request} \alias{arrange.data_request} \alias{arrange.metadata_request} diff --git a/man/collapse.data_request.Rd b/man/collapse.data_request.Rd index a486e465..3da1fe88 100644 --- a/man/collapse.data_request.Rd +++ b/man/collapse.data_request.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/collapse.R +% Please edit documentation in R/dplyr-collapse.R \name{collapse.data_request} \alias{collapse.data_request} \alias{collapse.metadata_request} diff --git a/man/collect.data_request.Rd b/man/collect.data_request.Rd index 2fec366d..2a6bccfc 100644 --- a/man/collect.data_request.Rd +++ b/man/collect.data_request.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/collect.R +% Please edit documentation in R/dplyr-collect.R \name{collect.data_request} \alias{collect.data_request} \alias{collect.metadata_request} diff --git a/man/compute.data_request.Rd b/man/compute.data_request.Rd index 94b70032..45283793 100644 --- a/man/compute.data_request.Rd +++ b/man/compute.data_request.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/compute.R +% Please edit documentation in R/dplyr-compute.R \name{compute.data_request} \alias{compute.data_request} \alias{compute.metadata_request} diff --git a/man/count.data_request.Rd b/man/count.data_request.Rd index f6ae9705..ef69f4ab 100644 --- a/man/count.data_request.Rd +++ b/man/count.data_request.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/count-data_request.R +% Please edit documentation in R/dplyr-count.R \name{count.data_request} \alias{count.data_request} \title{Count the observations in each group} diff --git a/man/desc.Rd b/man/desc.Rd new file mode 100644 index 00000000..cbae90bf --- /dev/null +++ b/man/desc.Rd @@ -0,0 +1,32 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/dplyr-desc.R +\name{desc} +\alias{desc} +\title{Descending order} +\usage{ +desc(...) +} +\arguments{ +\item{...}{column to order by} +} +\value{ +A \code{tibble} used by \code{arrange.data_request()} to arrange rows of a +query. +} +\description{ +Descending order +} +\examples{ +\dontrun{ +# Arrange grouped record counts by descending year +galah_call() |> + identify("perameles") |> + filter(year > 2019) |> + count() |> + arrange(galah::desc(year)) |> + collect() +} +} +\seealso{ +\code{\link[=arrange.data_request]{arrange()}}, \code{\link[=galah_call]{galah_call()}} +} diff --git a/man/filter.data_request.Rd b/man/filter.data_request.Rd index 279ba14c..db022755 100644 --- a/man/filter.data_request.Rd +++ b/man/filter.data_request.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/galah_filter.R +% Please edit documentation in R/dplyr-filter.R, R/galah_filter.R \name{filter.data_request} \alias{filter.data_request} \alias{filter.metadata_request} diff --git a/man/group_by.data_request.Rd b/man/group_by.data_request.Rd index 84e44446..3b9aa8e9 100644 --- a/man/group_by.data_request.Rd +++ b/man/group_by.data_request.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/galah_group_by.R +% Please edit documentation in R/dplyr-group_by.R, R/galah_group_by.R \name{group_by.data_request} \alias{group_by.data_request} \alias{galah_group_by} diff --git a/man/select.data_request.Rd b/man/select.data_request.Rd index 047320ed..fc66fcd1 100644 --- a/man/select.data_request.Rd +++ b/man/select.data_request.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/galah_select.R +% Please edit documentation in R/dplyr-select.R, R/galah_select.R \name{select.data_request} \alias{select.data_request} \alias{select.metadata_request} diff --git a/man/slice_head.data_request.Rd b/man/slice_head.data_request.Rd index 440e4150..7e210423 100644 --- a/man/slice_head.data_request.Rd +++ b/man/slice_head.data_request.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/slice_head.R +% Please edit documentation in R/dplyr-slice_head.R \name{slice_head.data_request} \alias{slice_head.data_request} \alias{slice_head.metadata_request} diff --git a/man/tidyverse_functions.Rd b/man/tidyverse_functions.Rd deleted file mode 100644 index 23b4a9d9..00000000 --- a/man/tidyverse_functions.Rd +++ /dev/null @@ -1,73 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/tidyverse.R -\name{tidyverse_functions} -\alias{tidyverse_functions} -\alias{desc} -\alias{unnest} -\title{Non-generic tidyverse functions} -\usage{ -desc(...) - -unnest(.query) -} -\arguments{ -\item{...}{column to order by} - -\item{.query}{An object of class \code{metadata_request}} -} -\value{ -\itemize{ -\item \code{galah::desc()} returns a \code{tibble} used by \code{arrange.data_request()} to arrange rows of a query. -\item \code{galah::unnest()} returns an object of class \code{metadata_request}. -} -} -\description{ -Several useful functions from tidyverse packages are \code{generic}, meaning -that we can define class-specific versions of those functions and implement -them in galah; examples include \code{filter()}, \code{select()} and \code{group_by()}. -However, there are also functions that are only defined within tidyverse -packages and are not generic. In a few cases we have re-implemented these -functions in galah. This has the consequence of supporting consistent -syntax with tidyverse, at the cost of potentially introducing conflicts. -This can be avoided by using the \code{::} operator where required (see examples). -} -\details{ -The following functions are included: -\itemize{ -\item \code{desc()} (\code{dplyr}): Use within \code{arrange()} to specify arrangement should be descending -\item \code{unnest()} (\code{tidyr}): Use to 'drill down' into nested information on \code{fields}, \code{lists}, \code{profiles}, or \code{taxa} -} - -These galah versions all use lazy evaluation. -} -\examples{ -\dontrun{ -# Arrange grouped record counts by descending year -galah_call() |> - identify("perameles") |> - filter(year > 2019) |> - count() |> - arrange(galah::desc(year)) |> - collect() - -# Return values of field `basisOfRecord` -request_metadata() |> - unnest() |> - filter(field == basisOfRecord) |> - collect() - -# Using `galah::unnest()` in this way is equivalent to: -show_all(fields, "basisOfRecord") |> - show_values() - -# to add information to a species list: -request_metadata() |> - filter(list == "dr650") |> - select(everything()) |> - unnest() |> - collect() -} -} -\seealso{ -\code{\link[=arrange.data_request]{arrange()}}, \code{\link[=galah_call]{galah_call()}} -} diff --git a/man/unnest.Rd b/man/unnest.Rd new file mode 100644 index 00000000..a19dfa94 --- /dev/null +++ b/man/unnest.Rd @@ -0,0 +1,45 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/tidyr-unnest.R +\name{unnest} +\alias{unnest} +\title{Unnest a query} +\usage{ +unnest(.query) +} +\arguments{ +\item{.query}{An object of class \code{metadata_request}} +} +\value{ +An object of class \code{metadata_request} +} +\description{ +This syntax is borrowed from \code{tidyr}, and is conceptually used in the same +way here, but in galah unnest amends the query to unnest information +server-side, rather than on your machine. It powers all of the +\code{\link[=show_values]{show_values()}} functions in galah. +} +\details{ +Re-implementing existing functions has the consequence of supporting +consistent syntax with tidyverse, at the cost of potentially introducing +conflicts. This can be avoided by using the \code{::} operator where required. +} +\examples{ +\dontrun{ +# Return values of field `basisOfRecord` +request_metadata() |> + unnest() |> + filter(field == basisOfRecord) |> + collect() + +# Using `galah::unnest()` in this way is equivalent to: +show_all(fields, "basisOfRecord") |> + show_values() + +# to add information to a species list: +request_metadata() |> + filter(list == "dr650") |> + select(everything()) |> + unnest() |> + collect() +} +} From cbccf6091d88134f71c19fa67fb7997a6f6de468 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Mon, 10 Nov 2025 09:25:36 +1100 Subject: [PATCH 46/94] Fix minor bugs in lists and select functions, associated tests --- R/as_query-species.R | 4 ++-- R/check.R | 4 +++- R/collect_metadata.R | 10 ++++++---- R/dplyr-select.R | 4 ++-- R/galah_select.R | 8 ++++++-- tests/testthat/test-galah_select.R | 6 ++++-- tests/testthat/test-international-Flanders.R | 4 ++-- tests/testthat/test-international-Spain.R | 18 +++++++++--------- tests/testthat/test-international-Sweden.R | 4 ++-- 9 files changed, 36 insertions(+), 26 deletions(-) diff --git a/R/as_query-species.R b/R/as_query-species.R index ab34a637..eb998a3a 100644 --- a/R/as_query-species.R +++ b/R/as_query-species.R @@ -33,12 +33,12 @@ as_query_species_atlas <- function(.query){ .query$filter, .query$geolocate, .query$data_profile), - emailNotify = email_notify(), sourceTypeId = 2004, reasonTypeId = potions::pour("user", "download_reason_id"), facets = .query$group_by$name, parse_select_species(.query$select)) |> - add_email_address(query = .query) + add_email_address(query = .query) |> + add_email_notify() # build url url <- url_lookup("data/species") |> diff --git a/R/check.R b/R/check.R index 6b74ecf6..c9749902 100644 --- a/R/check.R +++ b/R/check.R @@ -371,10 +371,12 @@ check_fields_la <- function(.query){ # If no args are supplied, set default columns returned as group = "basic" +#' @param group supplied group names +#' @param n number of non-group arguments given #' @noRd #' @keywords Internal check_groups <- function(group, n){ - if(missing(group)){ + if(is.null(group)){ if(n < 1){ "basic" }else{ diff --git a/R/collect_metadata.R b/R/collect_metadata.R index 48fff72e..cb3ad548 100644 --- a/R/collect_metadata.R +++ b/R/collect_metadata.R @@ -315,19 +315,21 @@ collect_lists <- function(.query){ # NOTE: this function has some quite versatile behaviour, so we need to # explicitly control when caching does (and does not) happen. should_update_cache <- FALSE - + # requests for cached data use the `data` slot; check this first if(!is.null(.query$data)){ result_df <- retrieve_internal_data(.query) }else{ # here we run and parse an API call result <- query_API(.query) # this when `url` is a single value or a tibble - # pagination returns long lists - if(length(result) > 1){ + # pagination returns long lists - handle using `map()` + # if(length(result) > 1 & is.null(names(result))){ # this old code is risky + # test for pagination requests in .query instead + if(inherits(.query$url, "data.frame")){ result_df <- purrr::map(query_API(.query), \(a){a$lists}) |> dplyr::bind_rows() - should_update_cache <- TRUE + should_update_cache <- TRUE }else{ lists_slot <- purrr::pluck(result, "lists") # single list queries that use `filter()` don't have a `lists` slot diff --git a/R/dplyr-select.R b/R/dplyr-select.R index 71cd5111..d3e0c705 100644 --- a/R/dplyr-select.R +++ b/R/dplyr-select.R @@ -128,7 +128,7 @@ #' collect() #' } #' @export -select.data_request <- function(.data, ..., group){ +select.data_request <- function(.data, ..., group = NULL){ if(is_gbif()){ cli::cli_text("`select()` is not supported for GBIF occurrence downloads API v1: skipping") .data @@ -166,7 +166,7 @@ generate_summary <- function(dots){ #' @noRd #' @keywords Internal add_group <- function(dots, group){ - group <- check_groups(group, n = length(dots)) + group <- check_groups(group, n = length(dots$quosure)) summary_length <- nchar(dots$summary) if(is.null(group)){ if(summary_length < 1){ diff --git a/R/galah_select.R b/R/galah_select.R index 221d6a72..0c08e0ea 100644 --- a/R/galah_select.R +++ b/R/galah_select.R @@ -1,6 +1,6 @@ #' @rdname select.data_request #' @export -galah_select <- function(..., group){ +galah_select <- function(..., group = NULL){ dots <- rlang::enquos(..., .ignore_empty = "all") |> detect_request_object() |> as.list() @@ -12,7 +12,11 @@ galah_select <- function(..., group){ NULL } }else{ - if(inherits(dots[[1]], "data_request")){ + if(length(dots) < 1){ + list(quosure = c(), summary = "") |> + add_group(group) + } + else if(inherits(dots[[1]], "data_request")){ list(quosure = dots[-1], summary = generate_summary(dots[-1])) |> add_group(group) |> diff --git a/tests/testthat/test-galah_select.R b/tests/testthat/test-galah_select.R index e9024f2b..a8ccb050 100644 --- a/tests/testthat/test-galah_select.R +++ b/tests/testthat/test-galah_select.R @@ -17,8 +17,10 @@ test_that("`select.data_request()` adds content to a `data_request` object", { }) test_that("`galah_select()` doesn't return error when columns don't exist", { - expect_no_error(galah_select(basisOfRecord)) - expect_no_error(galah_select(year, basisOfRecord, eventdate)) + galah_select(basisOfRecord) |> + expect_no_error() + galah_select(year, basisOfRecord, eventdate) |> + expect_no_error() }) test_that("`select()` triggers error during `collapse()` when columns don't exist", { diff --git a/tests/testthat/test-international-Flanders.R b/tests/testthat/test-international-Flanders.R index e3b0f529..d43257ac 100644 --- a/tests/testthat/test-international-Flanders.R +++ b/tests/testthat/test-international-Flanders.R @@ -83,7 +83,7 @@ test_that("show_all(profiles) works for Flanders", { # and values y <- request_metadata() |> - filter(profiles == x$shortName[1]) |> + filter(profiles == x$short_name[1]) |> unnest() |> collect() |> try(silent = TRUE) @@ -96,7 +96,7 @@ test_that("show_all(profiles) works for Flanders", { count() |> collect() records_clean <- galah_call() |> - apply_profile(x$shortName[1]) |> + apply_profile(x$short_name[1]) |> count() |> collect() expect_lt(records_clean$count, records_all$count) diff --git a/tests/testthat/test-international-Spain.R b/tests/testthat/test-international-Spain.R index 84d61285..f7648e27 100644 --- a/tests/testthat/test-international-Spain.R +++ b/tests/testthat/test-international-Spain.R @@ -81,7 +81,7 @@ test_that("show_all(profiles) works for Flanders", { # and values y <- request_metadata() |> - filter(profiles == x$shortName[1]) |> + filter(profiles == x$short_name[1]) |> unnest() |> collect() |> try(silent = TRUE) @@ -94,7 +94,7 @@ test_that("show_all(profiles) works for Flanders", { count() |> collect() records_clean <- galah_call() |> - apply_profile(x$shortName[1]) |> + apply_profile(x$short_name[1]) |> count() |> collect() expect_lt(records_clean$count, records_all$count) @@ -160,7 +160,7 @@ test_that("show_values works for fields for Spain", { test_that("atlas_counts works for Spain", { skip_if_offline(); skip_on_ci() x <- atlas_counts() |> - pull(count) |> + dplyr::pull(count) |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") expect_gt(x, 0) @@ -169,7 +169,7 @@ test_that("atlas_counts works for Spain", { test_that("atlas_counts works with type = 'species' for Spain", { skip_if_offline(); skip_on_ci() x <- atlas_counts(type = "species") |> - pull(count) |> + dplyr::pull(count) |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") expect_gt(x, 0) @@ -243,7 +243,7 @@ test_that("atlas_species works for Spain", { email = "test@ala.org.au", send_email = FALSE) spp <- galah_call() |> - galah_identify("Carnivora") |> + identify("Carnivora") |> atlas_species() |> try(silent = TRUE) skip_if(inherits(spp, "try-error"), message = "API not available") @@ -256,15 +256,15 @@ test_that("galah_select works for Spain", { skip_if_offline(); skip_on_ci() x <- galah_select() y <- galah_select(basisOfRecord) - expect_equal(length(x), 2) + expect_equal(length(x), 3) expect_equal(x$summary, "group = basic") expect_equal(x$group, "basic") expect_true(inherits(x, c("list"))) expect_equal(length(y), 3) - expect_equal(y$summary, "basisOfRecord") + expect_equal(y$summary, "~basisOfRecord") expect_equal(y$group, character(0)) expect_true(inherits(y, c("list"))) - expect_true(inherits(y[[1]], c("quosure", "formula"))) + expect_true(inherits(y$quosure[[1]], c("quosure", "formula"))) }) test_that("atlas_occurrences works for Spain", { @@ -303,7 +303,7 @@ test_that("atlas_media() works for Spain", { # collect() atlas_media() |> try(silent = TRUE) - skip_if(inherits(x, "try-error"), message = "API not available") + skip_if(inherits(x, "try-error"), message = "API not available") # FIXME: failing here expect_s3_class(x, c("tbl_df", "tbl", "data.frame")) expect_gte(nrow(x), 1) expect_equal(colnames(x)[1:2], diff --git a/tests/testthat/test-international-Sweden.R b/tests/testthat/test-international-Sweden.R index adbe49ef..7ef71990 100644 --- a/tests/testthat/test-international-Sweden.R +++ b/tests/testthat/test-international-Sweden.R @@ -88,7 +88,7 @@ test_that("show_all(profiles) works for Sweden", { # and values y <- request_metadata() |> - filter(profiles == x$shortName[1]) |> + filter(profiles == x$short_name[1]) |> unnest() |> collect() |> try(silent = TRUE) @@ -101,7 +101,7 @@ test_that("show_all(profiles) works for Sweden", { count() |> collect() records_clean <- galah_call() |> - apply_profile(x$shortName[1]) |> + apply_profile(x$short_name[1]) |> count() |> collect() expect_lt(records_clean$count, records_all$count) From 8dc6c19ee69232a0adef5a201200742fdb7f3697 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Mon, 10 Nov 2025 17:29:31 +1100 Subject: [PATCH 47/94] Support supplying field names to `count.data_request()` (#284) --- R/dplyr-count.R | 2 +- R/dplyr-group_by.R | 25 +++++++++++------------ tests/testthat/test-count_arrange_slice.R | 13 ++++++++++++ 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/R/dplyr-count.R b/R/dplyr-count.R index c10f5280..3d6967e6 100644 --- a/R/dplyr-count.R +++ b/R/dplyr-count.R @@ -19,5 +19,5 @@ count.data_request <- function(x, "species" = "species-count", "media" = cli::cli_abort("type = 'media' is not supported by `count()`"), cli::cli_abort("`count()` only supports `type = 'occurrences' or` `'species'`")) - x + group_by(x, ...) } \ No newline at end of file diff --git a/R/dplyr-group_by.R b/R/dplyr-group_by.R index 4511a0c9..c069994e 100644 --- a/R/dplyr-group_by.R +++ b/R/dplyr-group_by.R @@ -32,11 +32,15 @@ #' } #' @export group_by.data_request <- function(.data, ...){ - parsed_dots <- rlang::enquos(..., .ignore_empty = "all") |> - parse_quosures_basic() - df <- parse_group_by(parsed_dots) - update_request_object(.data, - group_by = df) + parsed_dots <- rlang::enquos(..., + .ignore_empty = "all") |> + parse_quosures_basic() |> + parse_group_by() + if(!is.null(parsed_dots)){ + update_request_object(.data, group_by = parsed_dots) + }else{ + .data + } } #' Internal parsing of `group_by` args @@ -57,16 +61,11 @@ parse_group_by <- function(dot_names, df$type <- ifelse(stringr::str_detect(df$name, "[[:lower:]]"), "field", "assertions") + df }else{ - df <- tibble::tibble(name = "name", - type = "type", - .rows = 0) + NULL } }else{ - df <- tibble::tibble(name = "name", - type = "type", - .rows = 0) + NULL } - - return(df) } \ No newline at end of file diff --git a/tests/testthat/test-count_arrange_slice.R b/tests/testthat/test-count_arrange_slice.R index cc0a69fa..7b31005f 100644 --- a/tests/testthat/test-count_arrange_slice.R +++ b/tests/testthat/test-count_arrange_slice.R @@ -17,6 +17,19 @@ test_that("default is to arrange by decending order of count", { expect_equal(colnames(result), c("year", "count")) }) +test_that("`count(year)` groups by `year`", { + skip_if_offline(); skip_on_ci() + result <- galah_call() |> + filter(year >= 2015) |> + count(year) |> + quiet_collect() + expect_true(all(diff(result$count) < 0)) + expect_true(nrow(result) > 7) + expect_true(all(result$year >= 2015)) + expect_equal(ncol(result), 2) + expect_equal(colnames(result), c("year", "count")) +}) + test_that("arrange in increasing order of count", { skip_if_offline(); skip_on_ci() result <- galah_call() |> From 4602c588ef05515094d07974c64c26692972589b Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Tue, 9 Dec 2025 15:38:06 +1100 Subject: [PATCH 48/94] initial commit for `summarize()` function + minor amendments to authentication code --- NAMESPACE | 2 ++ R/authenticate.R | 6 ++---- R/dplyr-summarise.R | 28 ++++++++++++++++++++++++++ man/summarise.data_request.Rd | 30 ++++++++++++++++++++++++++++ tests/testthat/test-authentication.R | 2 ++ 5 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 R/dplyr-summarise.R create mode 100644 man/summarise.data_request.Rd diff --git a/NAMESPACE b/NAMESPACE index ae63c810..d310e404 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -123,6 +123,8 @@ export(show_all_reasons) export(show_values) export(slice_head) export(st_crop) +export(summarise.data_request) +export(summarize.data_request) export(unnest) export(use_authentication) importFrom(dplyr,arrange) diff --git a/R/authenticate.R b/R/authenticate.R index 4cbcbed3..bef0e7c0 100644 --- a/R/authenticate.R +++ b/R/authenticate.R @@ -26,9 +26,6 @@ authenticate <- function(){ if (choice == 1) { # authenticate now - # NOTE: I'm trialling the use of `oauth_flow_auth_code()` here. - # This *should* generate a request to the browser without requiring a - # dummy API call; but hasn't been found to work yet. auth_info <- get_auth_info() result <- httr2::oauth_flow_auth_code( client = auth_info$client, @@ -36,7 +33,8 @@ authenticate <- function(){ scope = dplyr::pull(auth_info$config, "scopes"), pkce = TRUE) galah_config(authenticate = TRUE) # cache authentication behaviour - return(invisible(result)) + return(invisible(result)) # invisibly return token + } else { cli::cli_bullets(c( i = "Exiting..." diff --git a/R/dplyr-summarise.R b/R/dplyr-summarise.R new file mode 100644 index 00000000..e7258b97 --- /dev/null +++ b/R/dplyr-summarise.R @@ -0,0 +1,28 @@ +#' Summarise each group down to one row +#' +#' `r lifecycle::badge("experimental")` +#' `summarise()` creates a new data frame. It returns one row for each combination +#' of grouping variables; if there are no grouping variables, the output will +#' have a single row summarising all observations in the input. It will contain +#' one column for each grouping variable and one column for each of the summary +#' statistics that you have specified. +#' +#' Like all `dplyr` extensions in `galah`, this function amends a `data_request`, +#' `metadata_request` or `files_request`, and is evaluated lazily. +#' +#' `summarise()` and `summarize()` are synonyms. +#' @name summarise.data_request +#' @order 1 +#' @param ... Name-value pairs of summary functions. The name will be the +#' name of the variable in the result. The value can be a single function +#' such as `min(x)`, `n()`, or `sum()` +#' @export +summarise.data_request <- function(.data, ...){ + cli::cli_abort("`summarise()` is not yet supported in `galah`. Please try again later.") +} + + +#' @name summarise.data_request +#' @order 2 +#' @export +summarize.data_request <- summarise.data_request \ No newline at end of file diff --git a/man/summarise.data_request.Rd b/man/summarise.data_request.Rd new file mode 100644 index 00000000..99eb839d --- /dev/null +++ b/man/summarise.data_request.Rd @@ -0,0 +1,30 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/dplyr-summarise.R +\name{summarise.data_request} +\alias{summarise.data_request} +\alias{summarize.data_request} +\title{Summarise each group down to one row} +\usage{ +summarise.data_request(.data, ...) + +summarize.data_request(.data, ...) +} +\arguments{ +\item{...}{Name-value pairs of summary functions. The name will be the +name of the variable in the result. The value can be a single function +such as \code{min(x)}, \code{n()}, or \code{sum()}} +} +\description{ +\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}} +\code{summarise()} creates a new data frame. It returns one row for each combination +of grouping variables; if there are no grouping variables, the output will +have a single row summarising all observations in the input. It will contain +one column for each grouping variable and one column for each of the summary +statistics that you have specified. +} +\details{ +Like all \code{dplyr} extensions in \code{galah}, this function amends a \code{data_request}, +\code{metadata_request} or \code{files_request}, and is evaluated lazily. + +\code{summarise()} and \code{summarize()} are synonyms. +} diff --git a/tests/testthat/test-authentication.R b/tests/testthat/test-authentication.R index 0c03eb90..a80ebf13 100644 --- a/tests/testthat/test-authentication.R +++ b/tests/testthat/test-authentication.R @@ -36,6 +36,7 @@ test_that("`request_metadata()` caches type `config` correctly", { }) test_that("`use_authentication()` works in-pipe for metadata", { + skip("authentication requires interactivity") query <- request_metadata(type = "reasons") |> use_authentication() @@ -58,6 +59,7 @@ test_that("`use_authentication()` works in-pipe for metadata", { }) test_that("`use_authentication()` works in-pipe for occurrences", { + skip("authentication requires interactivity") galah_config(email = "ala4r@ala.org.au") query <- galah_call() |> From 19c4f3b06d922bb739fd38e19f9a0f135fb2d1b2 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Tue, 9 Dec 2025 15:39:26 +1100 Subject: [PATCH 49/94] Update NEWS.md with changes made so far for v2.2.0 --- NEWS.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/NEWS.md b/NEWS.md index 1f9ca50b..3abd527d 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,25 @@ +# galah 2.2.0 + +### Improved organisational support +* `filter()` now builds predicate queries natively when atlas is set to `GBIF` +* DOIs now supported for `GBIF` +* Kew gardens and Flanders living atlases added + +### Major changes +* authentication supported for ALA users +* new functions `as_query()` and `coalesce()` as prequels to `collapse()` +* media functions have been updated and have their own vignette; fields returned have changed +* all metadata requests now accept `select()`; all `show_all()` and `search_all()` functions gain an `all_fields` argument + +### Minor and internal changes, bug fixes +* Move to `testthat` 3rd edition for improved test functionality +* move to `{cli}` for `print()` calls, not `cat()` +* reduce usage of `@importFrom` in favour of `pkg::fun()` syntax, as per R style guide +* Object-oriented workflow for handling `filter()` requests and printing +* metadata now supports list-columns where the API returns nested data +* functions now return columns names in `snake_case` rather than `camelCase` +* `basisOfRecord` now included as default field (i.e. with `select(group = "basic")`) (#281) + # galah 2.1.2 ### Minor improvements From 7dc222ef43820973ba4301eb4badb24f6be11e87 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Fri, 12 Dec 2025 11:15:42 +1100 Subject: [PATCH 50/94] Simplify authentication to only use `galah_config()` (#189) `use_authentication()` is retained as an internal function; `authenticate()` is removed. caching control re-introduced to `galah_config()` to enable 'forcing' evaluation of API calls --- NAMESPACE | 2 - NEWS.md | 1 + R/authenticate.R | 158 ++------------------------- R/coalesce.R | 2 +- R/galah_config.R | 30 +++-- R/print.R | 13 ++- R/utilities_caching.R | 4 +- man/authenticate.Rd | 23 ---- man/galah_config.Rd | 9 +- tests/testthat/test-authentication.R | 58 ++++------ 10 files changed, 65 insertions(+), 235 deletions(-) delete mode 100644 man/authenticate.Rd diff --git a/NAMESPACE b/NAMESPACE index d310e404..005d2793 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -61,7 +61,6 @@ export(atlas_media) export(atlas_occurrences) export(atlas_species) export(atlas_taxonomy) -export(authenticate) export(coalesce) export(collapse) export(collect) @@ -126,7 +125,6 @@ export(st_crop) export(summarise.data_request) export(summarize.data_request) export(unnest) -export(use_authentication) importFrom(dplyr,arrange) importFrom(dplyr,collapse) importFrom(dplyr,collect) diff --git a/NEWS.md b/NEWS.md index 3abd527d..36769930 100644 --- a/NEWS.md +++ b/NEWS.md @@ -19,6 +19,7 @@ * metadata now supports list-columns where the API returns nested data * functions now return columns names in `snake_case` rather than `camelCase` * `basisOfRecord` now included as default field (i.e. with `select(group = "basic")`) (#281) +* all metadata functions support caching, and are affected by re-introduced `caching` argument in `galah_config()` (set to `TRUE` by default) # galah 2.1.2 diff --git a/R/authenticate.R b/R/authenticate.R index bef0e7c0..95be17d4 100644 --- a/R/authenticate.R +++ b/R/authenticate.R @@ -1,54 +1,10 @@ #' Set up authentication #' -#' This is early-stage code. It's purpose is to trigger the browser to generate -#' a JWT token. [authenticate()] sets `galah_config(authenticate = TRUE)`, but -#' differs in triggering authentication on execution (which `galah_config()` -#' does not). [use_authentication()] is an in-pipe method for setting -#' authentication and associated behaviour. It is primarily intended for -#' internal use, but is exported for completeness and debugging purposes. -#' @name authenticate -#' @details -#' By default, authentication is only triggered during occurrence downloads -#' and query uploads. This can be overrided by calling [use_authentication()]. -#' @export -authenticate <- function(){ - - # offer user menu to confirm if not in batch run (testthat or knitr) - if(rlang::is_interactive()){ - - choice <- cli_menu( - c(" ", - "This function will open an authentication screen in your browser.", - " "), - "Do you want to continue? (0 to exit)", - choices = c("Yes", "No") - ) - - if (choice == 1) { - # authenticate now - auth_info <- get_auth_info() - result <- httr2::oauth_flow_auth_code( - client = auth_info$client, - auth_url = dplyr::pull(auth_info$config, "authorize_url"), - scope = dplyr::pull(auth_info$config, "scopes"), - pkce = TRUE) - galah_config(authenticate = TRUE) # cache authentication behaviour - return(invisible(result)) # invisibly return token - - } else { - cli::cli_bullets(c( - i = "Exiting..." - )) - # exits process quietly - invokeRestart("abort") - } - invisible() - - } -} - -#' @rdname authenticate -#' @export +#' This is early-stage code. It is triggered from +#' `galah_config(authenticate = TRUE)`, but given the package-wide importance +#' of authentication it seemed wise to collect all functions in one place. +#' @noRd +#' @keywords Internal use_authentication <- function(.data, cache_disk = FALSE){ .data$authenticate <- list( @@ -65,7 +21,7 @@ use_authentication <- function(.data, #' @keywords Internal check_authentication <- function(x){ if( - isTRUE(potions::pour("package", + isTRUE(potions::pour("user", "authenticate", .pkg = "galah")) & x$type %in% c("occurrences") # possible to add other allowed queries @@ -119,104 +75,4 @@ build_auth_client <- function(config){ name = "galah") update_cache(client = result) result -} - -#' Interactive menu function -#' @description -#' Built on top of utils::menu(). -#' Originally proposed by Hadley here: https://github.com/r-lib/cli/issues/228#issuecomment-1453614104 -#' Full code from gargle here: https://github.com/r-lib/gargle/blob/main/R/utils-ui.R -#' This version updated from `galaxias` v. 0.1.0 -#' @noRd -#' @keywords Internal -cli_menu <- function(header, - prompt, - choices, - not_interactive = choices, - exit = integer(), - .envir = rlang::caller_env(), - error_call = rlang::caller_env()) { - if (!rlang::is_interactive()) { - cli::cli_abort( - c(header, not_interactive), - .envir = .envir, - call = error_call - ) - } - - choices <- paste0(cli::style_bold(seq_along(choices)), ": ", choices) - - cli::cli({ - cli::cli_text(header, .envir = .envir) - cli::cli_text("", .envir = .envir) - cli::cli_text(prompt, .envir = .envir) - cli::cli_text("", .envir = .envir) - cli::cli_bullets(choices, .envir = .envir) - }) - - repeat { - selected <- cli_readline("Selection: ") - if (selected %in% c("0", seq_along(choices))) { - break - } - cli::cli_text( - "Enter a number between 1 and {length(choices)}, or enter 0 to exit." - ) - } - - selected <- as.integer(selected) - if (selected %in% c(0, exit)) { - if (is_testing()) { - cli::cli_abort("Exiting...", call = NULL) - } else { - cli::cli_alert_danger("Exiting...") - # simulate user pressing Ctrl + C - invokeRestart("abort") - } - } - - selected -} - -#' Interactive readLines -#' @description -#' Allows for interactive testing of `cli_menu()` selection. -#' Originally proposed by Hadley here: https://github.com/r-lib/cli/issues/228#issuecomment-1453614104. -#' Full code from gargle here: https://github.com/r-lib/gargle/blob/main/R/utils-ui.R -#' @noRd -#' @keywords Internal -cli_readline <- function(prompt) { - local_input <- getOption("cli_input", character()) - - # not convinced that we need to plan for multiple mocked inputs, but leaving - # this feature in for now - if (length(local_input) > 0) { - input <- local_input[[1]] - cli::cli_text(paste0(prompt, input)) - options(cli_input = local_input[-1]) - input - } else { - readline(prompt) - } -} - -## -- testing -- ## - -#' Mimic supplying user input to a menu -#' @noRd -#' @keywords Internal -local_user_input <- function(x, env = rlang::caller_env()) { - withr::local_options( - rlang_interactive = TRUE, - # trailing 0 prevents infinite loop if x only contains invalid choices - cli_input = c(x, "0"), - .local_envir = env - ) -} - -#' Check whether function is being called by testthat -#' @noRd -#' @keywords Internal -is_testing <- function() { - identical(Sys.getenv("TESTTHAT"), "true") -} +} \ No newline at end of file diff --git a/R/coalesce.R b/R/coalesce.R index 146152f2..6e860ae3 100644 --- a/R/coalesce.R +++ b/R/coalesce.R @@ -143,7 +143,7 @@ build_query_set_data <- function(x, mint_doi, ...){ result <- list() # handle authentication - if(isTRUE(potions::pour("package", "authenticate", .pkg = "galah")) | + if(isTRUE(potions::pour("user", "authenticate", .pkg = "galah")) | !is.null(x$authenticate) ){ result <- append(result, diff --git a/R/galah_config.R b/R/galah_config.R index 970b60ce..1738e214 100644 --- a/R/galah_config.R +++ b/R/galah_config.R @@ -23,11 +23,12 @@ #' admissible values) #' * `authenticate` logical: should `galah` authenticate your queries using #' JWT tokens? Defaults to `FALSE`. -#' * `directory` string: the directory to use for the cache. +#' * `caching` logical: should metadata query results be cached in `options()`? +#' Defaults to `TRUE` for improved stability and speed. +#' * `directory` string: the directory to use for the disk cache. #' By default this is a temporary directory, which means that results will -#' only be cached -#' within an R session and cleared automatically when the user exits R. -#' The user may wish to set this to a non-temporary directory for +#' only be cached within an R session and cleared automatically when the user +#' exits R. The user may wish to set this to a non-temporary directory for #' caching across sessions. The directory must exist on the file system. #' * `download_reason_id` numeric or string: the "download reason" required. #' by some ALA services, either as a numeric ID (currently 0--13) @@ -133,19 +134,25 @@ galah_config <- function(...) { # invisibly return x <- potions::pour() # check_authentication_argument(x) - structure(x, - class = c("galah_config", "list")) |> + as_galah_config(x) |> invisible() }else{ # visibly return x <- potions::pour() # check_authentication_argument(x) - structure(x, - class = c("galah_config", "list")) + as_galah_config(x) } } +#' Internal function to convert lists to class `galah_config` +#' @noRd +#' @keywords Internal +as_galah_config <- function(x){ + structure(x, + class = c("galah_config", "list")) +} + #' Set a 'default' object for storing config in `galah` #' @noRd #' @keywords Internal @@ -153,16 +160,17 @@ default_config <- function(){ list(package = list(verbose = TRUE, run_checks = TRUE, send_email = FALSE, - authenticate = FALSE, + caching = TRUE, directory = tempdir()), - user = list(username = "", + user = list(authenticate = FALSE, + username = "", email = "", password = "", download_reason_id = 4), atlas = list(organisation = "Atlas of Living Australia", acronym = "ALA", region = "Australia")) |> - structure(class = c("galah_config", "list")) + as_galah_config() } #' Place new options into correctly nested structure diff --git a/R/print.R b/R/print.R index 2dd4a49b..49769105 100644 --- a/R/print.R +++ b/R/print.R @@ -278,7 +278,7 @@ print.query_set <- function(x, ...){ #' @export print.galah_config <- function(x, ...){ cli::cli_par() - cli::cli_text("`galah` package configuration:") + cli::cli_text("`galah` package configuration") cli::cli_end() cli::cli_par() # print package settings @@ -298,11 +298,16 @@ print.galah_config <- function(x, ...){ # print user settings cli::cli_par() cli::cli_text("{galah_pink(\"User\")}") - c("{galah_green('username')} {galah_grey(hide_secrets(x$user$username))}", + user_settings <- c( + "{galah_green('authentication')}", + "{galah_green('username')} {galah_grey(hide_secrets(x$user$username))}", "{galah_green('email')} {galah_grey(x$user$email)}", "{galah_green('password')} {galah_grey(hide_secrets(x$user$password))}", - "{galah_green('download_reason_id')} {galah_grey(x$user$download_reason_id)}") |> - cli::cli_bullets() + "{galah_green('download_reason_id')} {galah_grey(x$user$download_reason_id)}") + names(user_settings)[1] <- purrr::pluck(x, "user", "authenticate") |> + isTRUE() |> + ifelse("v", "x") + cli::cli_bullets(user_settings) cli::cli_end() cli::cli_par() cli::cli_text("{galah_pink(\"Atlas\")}") diff --git a/R/utilities_caching.R b/R/utilities_caching.R index 3e5bdf1b..ab5d6aa5 100644 --- a/R/utilities_caching.R +++ b/R/utilities_caching.R @@ -58,11 +58,13 @@ check_if_cache_update_needed <- function(function_name){ df <- retrieve_cache(function_name) current_atlas <- potions::pour("atlas", "region", .pkg = "galah") # build some checks + caching_disabled <- !potions::pour("package", "caching", .pkg = "galah") is_local <- !is.null(attr(df, "ARCHIVED")) is_wrong_atlas <- attr(df, "region") != current_atlas is_too_short <- nrow(df) < 1 # somewhat arbitrary, but catches empty tibbles # evaluate those checks - result <- is_local | is_wrong_atlas | is_too_short # if any, update is needed + # if any are TRUE, update is needed + result <- is_local | is_wrong_atlas | is_too_short | caching_disabled if(length(result) < 1){ # bug catcher TRUE }else{ diff --git a/man/authenticate.Rd b/man/authenticate.Rd deleted file mode 100644 index 9fb9105e..00000000 --- a/man/authenticate.Rd +++ /dev/null @@ -1,23 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/authenticate.R -\name{authenticate} -\alias{authenticate} -\alias{use_authentication} -\title{Set up authentication} -\usage{ -authenticate() - -use_authentication(.data, cache_disk = FALSE) -} -\description{ -This is early-stage code. It's purpose is to trigger the browser to generate -a JWT token. \code{\link[=authenticate]{authenticate()}} sets \code{galah_config(authenticate = TRUE)}, but -differs in triggering authentication on execution (which \code{galah_config()} -does not). \code{\link[=use_authentication]{use_authentication()}} is an in-pipe method for setting -authentication and associated behaviour. It is primarily intended for -internal use, but is exported for completeness and debugging purposes. -} -\details{ -By default, authentication is only triggered during occurrence downloads -and query uploads. This can be overrided by calling \code{\link[=use_authentication]{use_authentication()}}. -} diff --git a/man/galah_config.Rd b/man/galah_config.Rd index 17fd55e7..8cdbcb50 100644 --- a/man/galah_config.Rd +++ b/man/galah_config.Rd @@ -37,11 +37,12 @@ an organisation name, acronym, or region (see \code{\link[=show_all_atlases]{sho admissible values) \item \code{authenticate} logical: should \code{galah} authenticate your queries using JWT tokens? Defaults to \code{FALSE}. -\item \code{directory} string: the directory to use for the cache. +\item \code{caching} logical: should metadata query results be cached in \code{options()}? +Defaults to \code{TRUE} for improved stability and speed. +\item \code{directory} string: the directory to use for the disk cache. By default this is a temporary directory, which means that results will -only be cached -within an R session and cleared automatically when the user exits R. -The user may wish to set this to a non-temporary directory for +only be cached within an R session and cleared automatically when the user +exits R. The user may wish to set this to a non-temporary directory for caching across sessions. The directory must exist on the file system. \item \code{download_reason_id} numeric or string: the "download reason" required. by some ALA services, either as a numeric ID (currently 0--13) diff --git a/tests/testthat/test-authentication.R b/tests/testthat/test-authentication.R index a80ebf13..82656848 100644 --- a/tests/testthat/test-authentication.R +++ b/tests/testthat/test-authentication.R @@ -3,14 +3,14 @@ quiet_config <- purrr::quietly(galah_config) test_that("`galah_config()` caches config info when `authenticate` is set to `TRUE`", { skip_on_ci(); skip_on_cran() x <- galah_config() - expect_false(x$package$authenticate) + expect_false(x$user$authenticate) expect_true(is.null(retrieve_cache("config"))) y <- quiet_config(authenticate = TRUE) stringr::str_detect(y$messages, "Caching `config` information to support authentication") |> any() |> expect_true() - expect_true(y$result$package$authenticate) + expect_true(y$result$user$authenticate) cached_config <- retrieve_cache("config") expect_false(is.null(cached_config)) expect_equal(nrow(cached_config), 1) @@ -30,6 +30,8 @@ test_that("`request_metadata()` works for type = `config`", { test_that("`request_metadata()` caches type `config` correctly", { skip_on_ci(); skip_on_cran() reset_cache() + x <- request_metadata(type = "config") |> + collect() result <- request_metadata(type = "config") |> as_query() expect_true(!is.null(result$data)) @@ -37,6 +39,7 @@ test_that("`request_metadata()` caches type `config` correctly", { test_that("`use_authentication()` works in-pipe for metadata", { skip("authentication requires interactivity") + galah_config(caching = FALSE) # turn off caching to force galah to call an API query <- request_metadata(type = "reasons") |> use_authentication() @@ -47,69 +50,45 @@ test_that("`use_authentication()` works in-pipe for metadata", { result2 <- coalesce(result) expect_equal(length(result2), 2) - # NOTE: this shows both datasets are set to `data` (not `url`) - # so `use_authentication()` won't do anything - # perhaps a solution is to have a `force` argument to ensure query happens - # this would be logical to put in `collect()` and `show_all()`; - # but would be evaluated in `collapse()` so would go there too - - # in which case, setting `use_authentication()` should set `force = TRUE` - - # NOTE: `use_credentials()` could be a good counterpoint for setting email etc + galah_config(caching = TRUE) }) test_that("`use_authentication()` works in-pipe for occurrences", { skip("authentication requires interactivity") - galah_config(email = "ala4r@ala.org.au") + galah_config(authenticate = TRUE) query <- galah_call() |> identify("Litoria dentata") |> filter(year == 2025) |> - use_authentication() |> coalesce() expect_equal(length(query), 6) is.null(query[[6]]$authenticate) |> expect_false() - x <- collapse(query) # FIXME: errors with no email address found - # note that this shouldn't happen if authentication has worked; - # BUT we haven't tested that yet + x <- collapse(query) x |> purrr::pluck("authenticate") |> is.null() |> expect_false() y <- compute(x) - # failing here - - # once auth works, this should still contain authentication metadata + inherits(y, "computed_query") |> + expect_true() + any(names(y) == "authenticate") |> + expect_true() z <- collect(y) - + stringr::str_detect(names(z), "^sensitive_") |> + any() |> + expect_true() }) test_that("setting `authentication` to `TRUE` changes data returned", { skip("authentication requires interactivity") - # skip_if(!file.exists(".secure-credentials"), - # "Secret information not provided") - # - # # load credentials, set authenticate to TRIE - # config <- c( - # jsonlite::fromJSON(".secure-credentials"), - # list(directory = "TEST-SENSITIVE-DATA", - # authenticate = TRUE)) |> - # quiet_config() - - # httr2::oauth_cache_clear() # wipe content - requires a client - - token <- authenticate() - # NOTE: saving this out is optional - token is currently returned invisibly - # there is an argument that this isn't very safe and should be removed, - # but is here for debugging rn. - - # These credentials *should* give access to sensitive data for Tasmania *only* + # NOTE: credentials *should* give access to sensitive data for Tasmania *only* # subset to species on Tasmania's sensitive species list + galah_config(authenticate = TRUE) # convert to query set first x_queryset <- galah_call() |> @@ -175,3 +154,6 @@ test_that("setting `authentication` to `TRUE` changes data returned", { # May be same problem as previously documented rm(quiet_config) +galah_config(caching = TRUE, + authenticate = FALSE, + email = "ala4r@ala.org.au") From 17421548f5e4e0477bf96c52af64675be4af8443 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Fri, 12 Dec 2025 14:27:38 +1100 Subject: [PATCH 51/94] Reformat `arrange()` to natively support `dplyr::desc()` (#284) Previous version required a galah-specific implementation of `desc()`, which is undesirable due to potential for package conflicts. --- NAMESPACE | 5 ++--- R/dplyr-arrange.R | 28 +++++++++++++++++++--------- R/dplyr-desc.R | 23 ----------------------- R/galah-package.R | 17 ++++++++--------- _pkgdown.yml | 5 ++--- man/desc.Rd | 32 -------------------------------- man/galah.Rd | 17 ++++++++--------- man/select.data_request.Rd | 4 ++-- man/summarise.data_request.Rd | 4 ++-- 9 files changed, 43 insertions(+), 92 deletions(-) delete mode 100644 R/dplyr-desc.R delete mode 100644 man/desc.Rd diff --git a/NAMESPACE b/NAMESPACE index 005d2793..80bc3086 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -48,6 +48,8 @@ S3method(select,metadata_request) S3method(slice_head,data_request) S3method(slice_head,metadata_request) S3method(st_crop,data_request) +S3method(summarise,data_request) +S3method(summarize,data_request) export(apply_profile) export(arrange) export(as_data_filter) @@ -67,7 +69,6 @@ export(collect) export(collect_media) export(compute) export(count) -export(desc) export(filter) export(galah_apply_profile) export(galah_bbox) @@ -122,8 +123,6 @@ export(show_all_reasons) export(show_values) export(slice_head) export(st_crop) -export(summarise.data_request) -export(summarize.data_request) export(unnest) importFrom(dplyr,arrange) importFrom(dplyr,collapse) diff --git a/R/dplyr-arrange.R b/R/dplyr-arrange.R index 6278da4c..bec51170 100644 --- a/R/dplyr-arrange.R +++ b/R/dplyr-arrange.R @@ -48,17 +48,27 @@ #' @export arrange.data_request <- function(.data, ...){ dots <- rlang::enquos(..., .ignore_empty = "all") - parsed_dots <- parse_quosures_basic(dots) - if(length(parsed_dots) == 2 & - all(names(parsed_dots) %in% c("variable", "direction"))){ - .data$arrange <- as.list(parsed_dots) |> - as.data.frame() |> - tibble::tibble() + if(length(dots) < 1){ + return(.data) }else{ - .data$arrange <- tibble::tibble(variable = parsed_dots, - direction = "ascending") + parsed_dots <- purrr::map(dots, \(a){ + switch(expr_type(a), + "symbol" = {rlang::as_label(a)}, + "call" = {purrr::map(rlang::quo_get_expr(a), as_string)}, + "literal" = {rlang::quo_get_expr(a)}, + cli::cli_abort("Quosure type not recognised.", + call = rlang::caller_env()))}) |> + unlist() + if(length(parsed_dots) > 1){ + result <- tibble::tibble(variable = parsed_dots[[2]], + direction = "descending") + }else{ + result <- tibble::tibble(variable = parsed_dots, + direction = "ascending") + } + .data$arrange <- result + return(.data) } - return(.data) } #' @rdname arrange.data_request diff --git a/R/dplyr-desc.R b/R/dplyr-desc.R deleted file mode 100644 index 93d31dca..00000000 --- a/R/dplyr-desc.R +++ /dev/null @@ -1,23 +0,0 @@ -#' Descending order -#' -#' @param ... column to order by -#' @returns A `tibble` used by `arrange.data_request()` to arrange rows of a -#' query. -#' @seealso \code{\link[=arrange.data_request]{arrange()}}, [galah_call()] -#' @examples \dontrun{ -#' # Arrange grouped record counts by descending year -#' galah_call() |> -#' identify("perameles") |> -#' filter(year > 2019) |> -#' count() |> -#' arrange(galah::desc(year)) |> -#' collect() -#' } -#' @export -desc <- function(...){ - dots <- rlang::enquos(..., .ignore_empty = "all") - parsed_dots <- parse_quosures_basic(dots) - tibble::tibble(variable = parsed_dots, - direction = "descending") -} - diff --git a/R/galah-package.R b/R/galah-package.R index 2507fa4a..d96c64c5 100644 --- a/R/galah-package.R +++ b/R/galah-package.R @@ -29,21 +29,20 @@ #' * [show_values()] & [search_values()] Show or search for values _within_ #' `fields`, `profiles`, `lists`, `collections`, `datasets` or `providers` #' -#' **Amend a query** +#' **Update a data request** #' -#' * [apply_profile()]/[galah_apply_profile()] Restrict to data that pass predefined checks (ALA only) +#' * [apply_profile()] Restrict to data that pass predefined checks (ALA only) #' * \code{\link[=arrange.data_request]{arrange()}} Arrange rows of a query on the server side #' * \code{\link[=count.data_request]{count()}} Request counts of the specified data type -#' * [desc()] Arrange counts in descending order (when combined with \code{\link[=arrange.data_request]{arrange()}}) -#' * \code{\link[=filter.data_request]{filter()}}/[galah_filter()] Filter records (see also \code{\link[=filter_object_classes]{filter_object_classes}})) -#' * [geolocate()]/[galah_geolocate()] Spatial filtering of a query -#' * \code{\link[=group_by.data_request]{group_by()}}/[galah_group_by()] Group counts by one or more fields -#' * \code{\link[=identify.data_request]{identify()}}/[galah_identify()] Search for taxonomic identifiers (see also \code{\link[=taxonomic_searches]{taxonomic_searches}}) -#' * \code{\link[=select.data_request]{select()}}/[galah_select()] Fields to report information for +#' * \code{\link[=filter.data_request]{filter()}} Filter records (see also \code{\link[=filter_object_classes]{filter_object_classes}})) +#' * [geolocate()] Spatial filtering of a query +#' * \code{\link[=group_by.data_request]{group_by()}} Group counts by one or more fields +#' * \code{\link[=identify.data_request]{identify()}} Search for taxonomic identifiers (see also \code{\link[=taxonomic_searches]{taxonomic_searches}}) +#' * \code{\link[=select.data_request]{select()}} Fields to report information for #' * \code{\link[=slice_head.data_request]{slice_head()}} Choose the first n rows of a download #' * [unnest()] Expand metadata for `fields`, `lists`, `profiles` or `taxa` #' -#' **Object-oriented processes** +#' **Create and execute a query** #' #' * [as_query()] Represent a `data_request` as a `query` object #' * [coalesce()] Convert a `data_request` or `query` into a `query_set` showing all calls needed for evaluation diff --git a/_pkgdown.yml b/_pkgdown.yml index 2cc52b01..6cbe5c13 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -73,12 +73,11 @@ reference: - show_all_reasons - show_all_ranks - show_values -- title: Amend a query +- title: Update a data request contents: - apply_profile - arrange.data_request - count.data_request - - desc - filter.data_request - geolocate - group_by.data_request @@ -86,7 +85,7 @@ reference: - select.data_request - slice_head.data_request - reexports -- title: Object-oriented processes +- title: Create and execute a query contents: - as_query.data_request - coalesce diff --git a/man/desc.Rd b/man/desc.Rd deleted file mode 100644 index cbae90bf..00000000 --- a/man/desc.Rd +++ /dev/null @@ -1,32 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/dplyr-desc.R -\name{desc} -\alias{desc} -\title{Descending order} -\usage{ -desc(...) -} -\arguments{ -\item{...}{column to order by} -} -\value{ -A \code{tibble} used by \code{arrange.data_request()} to arrange rows of a -query. -} -\description{ -Descending order -} -\examples{ -\dontrun{ -# Arrange grouped record counts by descending year -galah_call() |> - identify("perameles") |> - filter(year > 2019) |> - count() |> - arrange(galah::desc(year)) |> - collect() -} -} -\seealso{ -\code{\link[=arrange.data_request]{arrange()}}, \code{\link[=galah_call]{galah_call()}} -} diff --git a/man/galah.Rd b/man/galah.Rd index d296b200..9708b1d2 100644 --- a/man/galah.Rd +++ b/man/galah.Rd @@ -35,22 +35,21 @@ For those outside Australia, 'galah' is the common name of \code{fields}, \code{profiles}, \code{lists}, \code{collections}, \code{datasets} or \code{providers} } -\strong{Amend a query} +\strong{Update a data request} \itemize{ -\item \code{\link[=apply_profile]{apply_profile()}}/\code{\link[=galah_apply_profile]{galah_apply_profile()}} Restrict to data that pass predefined checks (ALA only) +\item \code{\link[=apply_profile]{apply_profile()}} Restrict to data that pass predefined checks (ALA only) \item \code{\link[=arrange.data_request]{arrange()}} Arrange rows of a query on the server side \item \code{\link[=count.data_request]{count()}} Request counts of the specified data type -\item \code{\link[=desc]{desc()}} Arrange counts in descending order (when combined with \code{\link[=arrange.data_request]{arrange()}}) -\item \code{\link[=filter.data_request]{filter()}}/\code{\link[=galah_filter]{galah_filter()}} Filter records (see also \code{\link[=filter_object_classes]{filter_object_classes}})) -\item \code{\link[=geolocate]{geolocate()}}/\code{\link[=galah_geolocate]{galah_geolocate()}} Spatial filtering of a query -\item \code{\link[=group_by.data_request]{group_by()}}/\code{\link[=galah_group_by]{galah_group_by()}} Group counts by one or more fields -\item \code{\link[=identify.data_request]{identify()}}/\code{\link[=galah_identify]{galah_identify()}} Search for taxonomic identifiers (see also \code{\link[=taxonomic_searches]{taxonomic_searches}}) -\item \code{\link[=select.data_request]{select()}}/\code{\link[=galah_select]{galah_select()}} Fields to report information for +\item \code{\link[=filter.data_request]{filter()}} Filter records (see also \code{\link[=filter_object_classes]{filter_object_classes}})) +\item \code{\link[=geolocate]{geolocate()}} Spatial filtering of a query +\item \code{\link[=group_by.data_request]{group_by()}} Group counts by one or more fields +\item \code{\link[=identify.data_request]{identify()}} Search for taxonomic identifiers (see also \code{\link[=taxonomic_searches]{taxonomic_searches}}) +\item \code{\link[=select.data_request]{select()}} Fields to report information for \item \code{\link[=slice_head.data_request]{slice_head()}} Choose the first n rows of a download \item \code{\link[=unnest]{unnest()}} Expand metadata for \code{fields}, \code{lists}, \code{profiles} or \code{taxa} } -\strong{Object-oriented processes} +\strong{Create and execute a query} \itemize{ \item \code{\link[=as_query]{as_query()}} Represent a \code{data_request} as a \code{query} object \item \code{\link[=coalesce]{coalesce()}} Convert a \code{data_request} or \code{query} into a \code{query_set} showing all calls needed for evaluation diff --git a/man/select.data_request.Rd b/man/select.data_request.Rd index fc66fcd1..1fb94815 100644 --- a/man/select.data_request.Rd +++ b/man/select.data_request.Rd @@ -6,11 +6,11 @@ \alias{galah_select} \title{Keep or drop columns using their names} \usage{ -\method{select}{data_request}(.data, ..., group) +\method{select}{data_request}(.data, ..., group = NULL) \method{select}{metadata_request}(.data, ...) -galah_select(..., group) +galah_select(..., group = NULL) } \arguments{ \item{.data}{An object of class \code{data_request}, created using \code{\link[=galah_call]{galah_call()}}.} diff --git a/man/summarise.data_request.Rd b/man/summarise.data_request.Rd index 99eb839d..baaf0b8d 100644 --- a/man/summarise.data_request.Rd +++ b/man/summarise.data_request.Rd @@ -5,9 +5,9 @@ \alias{summarize.data_request} \title{Summarise each group down to one row} \usage{ -summarise.data_request(.data, ...) +\method{summarise}{data_request}(.data, ...) -summarize.data_request(.data, ...) +\method{summarize}{data_request}(.data, ...) } \arguments{ \item{...}{Name-value pairs of summary functions. The name will be the From 608bee69dee2994af43cd615178405733790c36f Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Mon, 15 Dec 2025 11:10:09 +1100 Subject: [PATCH 52/94] caching recent changes ahead of laptop swap --- SPECIES_EXAMPLE.R | 57 +++++++ galah.Rproj | 1 - tests/testthat/test-galah_filter-GBIF.R | 155 +++++++++++--------- vignettes/accessing_sensitive_data.Rmd.orig | 27 ---- 4 files changed, 141 insertions(+), 99 deletions(-) create mode 100644 SPECIES_EXAMPLE.R diff --git a/SPECIES_EXAMPLE.R b/SPECIES_EXAMPLE.R new file mode 100644 index 00000000..df3885ed --- /dev/null +++ b/SPECIES_EXAMPLE.R @@ -0,0 +1,57 @@ +# tests of dplyr syntax for converting occurrences to useful species-level +# summaries within a single pipe. + +# load libraries +library(galah) +library(dplyr) + +# get example occurrence dataset +galah_config(email = "martinjwestgate@gmail.com") +testdata <- galah_call() |> + identify("perameles") |> + select(speciesID, group = "basic") |> + filter(year == 2020) |> + collect() + +# number of records +testdata |> + count() + +# number of records per species +testdata |> + count(speciesID) + +# number of species (aka `atlas_counts(type = "species")`) +testdata |> + summarize(count = n_distinct(speciesID)) + +# one row per species (aka `atlas_species()`) +testdata |> + distinct(speciesID, .keep_all = TRUE) +# Noting that this requires care with `select()` to return sensible results + +# show only which species are present in the query (aka `show_values()`) +testdata |> + distinct(speciesID, .keep_all = FALSE) + +# get species (and optionally, record) counts grouped by a second factor +testdata |> + group_by(basisOfRecord) |> + summarize(n_records = n(), + n_spp = n_distinct(speciesID)) + +# get one row per species, showing number of records +testdata |> +testdata |> + group_by(speciesID) |> + mutate(count = n()) |> + distinct(.keep_all = TRUE) + + +# example species download for comparison purposes +# noting syntax may change +testspecies <- galah_call() |> + identify("perameles") |> + filter(year == 2020) |> + group_by(speciesID) |> + collect() \ No newline at end of file diff --git a/galah.Rproj b/galah.Rproj index 322289aa..3a76475c 100644 --- a/galah.Rproj +++ b/galah.Rproj @@ -1,5 +1,4 @@ Version: 1.0 -ProjectId: c8647342-9948-46f7-9a70-64f6631d1e2c RestoreWorkspace: Default SaveWorkspace: Default diff --git a/tests/testthat/test-galah_filter-GBIF.R b/tests/testthat/test-galah_filter-GBIF.R index 1d7f9d59..ea157de3 100644 --- a/tests/testthat/test-galah_filter-GBIF.R +++ b/tests/testthat/test-galah_filter-GBIF.R @@ -1,81 +1,94 @@ -galah_config(atlas = "GBIF") +quiet_config <- purrr::quietly(galah_config) +x <- quiet_config(atlas = "GBIF") test_that("galah_filter() returns predicates for GBIF", { x <- galah_filter(year == 2024) - inherits(x, "galah_filter_predicate") |> + inherits(x, "list") |> expect_true() expect_equal(names(x[[1]]), c("type", "key", "value")) - values <- unlist(x[[1]]) - names(values) <- NULL - expect_equal(values, c("equals", "YEAR", "2024")) + expect_equal(x[[1]], + list(type = "equals", + key = "YEAR", + value = "2024")) }) -# only the above test contains information rn - -test_that("filter() handles multiple queries including != for GBIF", { - result <- galah_call() |> - filter(year == 2024, countryCode != "AU") - - str(result) -}) - -test_that("filter() handles AND for GBIF", { - result <- galah_call() |> - filter(year == 2024 & countryCode != "AU") - - str(result) -}) - -# assertions? - -test_that("filter() handles `between()` for GBIF", { - galah_call() |> - filter(dplyr::between(year, 2010, 2020)) |> - str() -}) - - -test_that("filter() handles %in% for GBIF", { - galah_call() |> - filter(year %in% c(2010, 2020)) |> - str() -}) +# FIXME: `check_fields()` not tested for GBIF - try sending invalid fields to `filter()` -test_that("filter() handles !() for GBIF", { - galah_call() |> - filter(!(year %in% c(2010, 2020))) |> - str() -}) - -test_that("filter() handles is.na() for GBIF", { - galah_call() |> - filter(is.na(country)) |> - str() -}) - -test_that("filter() handles !is.na() for GBIF", { - galah_call() |> - filter(!is.na(country)) |> - str() -}) - -test_that("filter() handles c() for GBIF", { - # check when supplied directly - galah_call() |> - filter(country == c("AU", "UK", "AZ")) |> - str() - - # and as a vector - country_vector <- c("AU", "UK", "AZ") - galah_call() |> - filter(country == country_vector) |> - str() - # effectively parses this as 'in' as per GBIF instructions -}) - -# missing `within` (galah_geolocate()) - -# missing `geoDistance` (galah_radius()) +# only the above test contains information rn +# +# test_that("filter() handles multiple queries for GBIF", { +# result <- galah_call() |> +# filter(year == 2024, basisOfRecord == "HUMAN_OBSERVATION") |> +# collapse() +# +# str(result) +# }) +# +# test_that("filter() handles multiple queries including != for GBIF", { +# result <- galah_call() |> +# filter(year == 2024, countryCode != "AU") +# +# str(result) +# }) +# +# test_that("filter() handles AND for GBIF", { +# result <- galah_call() |> +# filter(year == 2024 & countryCode != "AU") +# +# str(result) +# }) +# +# # assertions? +# +# test_that("filter() handles `between()` for GBIF", { +# galah_call() |> +# filter(dplyr::between(year, 2010, 2020)) |> +# str() +# }) +# +# +# test_that("filter() handles %in% for GBIF", { +# galah_call() |> +# filter(year %in% c(2010, 2020)) |> +# str() +# }) +# +# test_that("filter() handles !() for GBIF", { +# galah_call() |> +# filter(!(year %in% c(2010, 2020))) |> +# str() +# }) +# +# test_that("filter() handles is.na() for GBIF", { +# galah_call() |> +# filter(is.na(country)) |> +# str() +# }) +# +# test_that("filter() handles !is.na() for GBIF", { +# galah_call() |> +# filter(!is.na(country)) |> +# str() +# }) +# +# test_that("filter() handles c() for GBIF", { +# # check when supplied directly +# galah_call() |> +# filter(country == c("AU", "UK", "AZ")) |> +# str() +# +# # and as a vector +# country_vector <- c("AU", "UK", "AZ") +# galah_call() |> +# filter(country == country_vector) |> +# str() +# # effectively parses this as 'in' as per GBIF instructions +# }) +# +# # missing `within` (galah_geolocate()) +# +# # missing `geoDistance` (galah_radius()) -galah_config(atlas = "ALA") \ No newline at end of file +x <- quiet_config(atlas = "ALA") +rm(x, quiet_config) \ No newline at end of file diff --git a/vignettes/accessing_sensitive_data.Rmd.orig b/vignettes/accessing_sensitive_data.Rmd.orig index 4d247ac1..e1934e4d 100644 --- a/vignettes/accessing_sensitive_data.Rmd.orig +++ b/vignettes/accessing_sensitive_data.Rmd.orig @@ -43,33 +43,6 @@ simply known as the 'RASD framework' (https://www.rasd.org.au). If your access to sensitive data is approved by the provider(s) in question, from version 2.2.0 you can use 'galah' to access that sensitive data. -# Switching on authentication - -You have three options for setting authentication in galah. In practice, each of -these calls the versions below it. - -The ideal way to set authentication is to call `authenticate()`. This checks for -and caches `show_all_config()`, which contains the information necessary to -run an authentication pipeline. It then opens a browser window to open for you -to enter credentials. Once authentication is achieved, default behaviour is -applied, namely calling authentication only for: - -- occurrence downloads -- query uploads -- (species list uploads?) - -An alternative is to call `galah_config(authenticate = TRUE)`. This will -establish default authentication behaviour, but won't open a browser until -a query is run that requires authentication. - -The *final* way to set up authentication is to call it in-pipe with -`use_authentication()`. This is called internally by the above functions, and -amends a `_request` object to add a `authenticate` slot. -When this slot exists, a query to `show_all_config()` is included by -`coalesce()` and executed by `collapse()`. The benefit of this function is it -allows overruling of package defaults for authentication. - - # Keeping emails and passwords secure First, you will need to add your email address and password, as usual for From 1a61f80cd0d4e6c29111311110bd25fefa54d6dd Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Fri, 19 Dec 2025 17:26:37 +1100 Subject: [PATCH 53/94] First pass supporting `distinct()` #284 Current status: query building logic is implemented in as_query() and in test-dplyr-distinct.R. New method add_request() generalises problem of storing queries and could be rolled out more (e.g. select() for metadata). collect() not yet implemented for distinct(). --- DESCRIPTION | 2 +- NAMESPACE | 8 +- NEWS.md | 3 +- R/as_query.R | 66 +++- R/authenticate.R | 13 - R/collect_occurrences_count.R | 2 +- R/collect_species.R | 3 - R/compute_occurrences.R | 4 +- R/dplyr-count.R | 33 +- R/dplyr-distinct.R | 54 +++ R/dplyr-group_by.R | 2 + R/dplyr-summarise.R | 28 -- R/onload.R | 4 +- R/reexports.R | 28 +- R/utilities_internal.R | 2 +- man/count.data_request.Rd | 6 +- man/distinct.data_request.Rd | 24 ++ man/reexports.Rd | 10 +- man/summarise.data_request.Rd | 30 -- tests/testthat/test-atlas_counts.R | 318 +--------------- tests/testthat/test-atlas_species.R | 23 -- ...nge_slice.R => test-dplyr-arrange_slice.R} | 13 - tests/testthat/test-dplyr-count.R | 350 ++++++++++++++++++ tests/testthat/test-dplyr-distinct.R | 138 +++++++ ...filter-GBIF.R => test-dplyr-filter-GBIF.R} | 0 ...est-galah_filter.R => test-dplyr-filter.R} | 0 ...est-galah_select.R => test-dplyr-select.R} | 0 tests/testthat/test-international-UK.R | 4 +- 28 files changed, 698 insertions(+), 470 deletions(-) create mode 100644 R/dplyr-distinct.R delete mode 100644 R/dplyr-summarise.R create mode 100644 man/distinct.data_request.Rd delete mode 100644 man/summarise.data_request.Rd rename tests/testthat/{test-count_arrange_slice.R => test-dplyr-arrange_slice.R} (92%) create mode 100644 tests/testthat/test-dplyr-count.R create mode 100644 tests/testthat/test-dplyr-distinct.R rename tests/testthat/{test-galah_filter-GBIF.R => test-dplyr-filter-GBIF.R} (100%) rename tests/testthat/{test-galah_filter.R => test-dplyr-filter.R} (100%) rename tests/testthat/{test-galah_select.R => test-dplyr-select.R} (100%) diff --git a/DESCRIPTION b/DESCRIPTION index 694a283d..2800f3cd 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -62,7 +62,7 @@ BugReports: https://github.com/AtlasOfLivingAustralia/galah-R/issues Maintainer: Martin Westgate LazyLoad: yes VignetteBuilder: knitr -RoxygenNote: 7.3.2 +RoxygenNote: 7.3.3 Encoding: UTF-8 Roxygen: list(markdown = TRUE) Config/testthat/edition: 3 diff --git a/NAMESPACE b/NAMESPACE index 80bc3086..d55e2594 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,5 +1,6 @@ # Generated by roxygen2: do not edit by hand +S3method(add_count,data_request) S3method(arrange,data_request) S3method(arrange,metadata_request) S3method(as_query,data_request) @@ -26,6 +27,7 @@ S3method(compute,metadata_request) S3method(compute,query) S3method(compute,query_set) S3method(count,data_request) +S3method(distinct,data_request) S3method(filter,data_request) S3method(filter,files_request) S3method(filter,metadata_request) @@ -48,8 +50,7 @@ S3method(select,metadata_request) S3method(slice_head,data_request) S3method(slice_head,metadata_request) S3method(st_crop,data_request) -S3method(summarise,data_request) -S3method(summarize,data_request) +export(add_count) export(apply_profile) export(arrange) export(as_data_filter) @@ -69,6 +70,7 @@ export(collect) export(collect_media) export(compute) export(count) +export(distinct) export(filter) export(galah_apply_profile) export(galah_bbox) @@ -124,11 +126,13 @@ export(show_values) export(slice_head) export(st_crop) export(unnest) +importFrom(dplyr,add_count) importFrom(dplyr,arrange) importFrom(dplyr,collapse) importFrom(dplyr,collect) importFrom(dplyr,compute) importFrom(dplyr,count) +importFrom(dplyr,distinct) importFrom(dplyr,filter) importFrom(dplyr,group_by) importFrom(dplyr,select) diff --git a/NEWS.md b/NEWS.md index 36769930..b1418bb8 100644 --- a/NEWS.md +++ b/NEWS.md @@ -17,9 +17,10 @@ * reduce usage of `@importFrom` in favour of `pkg::fun()` syntax, as per R style guide * Object-oriented workflow for handling `filter()` requests and printing * metadata now supports list-columns where the API returns nested data -* functions now return columns names in `snake_case` rather than `camelCase` +* metadata functions now return columns names in `snake_case` rather than `camelCase` * `basisOfRecord` now included as default field (i.e. with `select(group = "basic")`) (#281) * all metadata functions support caching, and are affected by re-introduced `caching` argument in `galah_config()` (set to `TRUE` by default) +* `query` objects now have a `request` slot showing the request that generated them # galah 2.1.2 diff --git a/R/as_query.R b/R/as_query.R index f81d6dd5..2fe15fcb 100644 --- a/R/as_query.R +++ b/R/as_query.R @@ -59,24 +59,60 @@ as_query <- function(x, ...){ as_query.data_request <- function(x, mint_doi = FALSE, ...){ - x <- check_authentication(x) + x <- x |> + check_authentication() |> + check_distinct() switch(x$type, - "occurrences" = { - if(is.null(x$group_by)){ - as_query_occurrences(x, mint_doi = mint_doi) - }else{ - as_query_species(x) - } - }, + "occurrences" = as_query_occurrences(x, mint_doi = mint_doi), "occurrences-count" = as_query_occurrences_count(x), "occurrences-doi" = as_query_occurrences_doi(x), "species" = as_query_species(x), "species-count" = as_query_species_count(x), "distributions" = as_query_distributions_data(x), cli::cli_abort("Unrecognised 'type'")) |> - retain_authentication(source = x) + add_request(x) +} + + +#' Internal function to check behaviour of `distinct()`, `group_by()` etc. +#' called by `as_query()` +#' @noRd +#' @keywords Internal +check_distinct <- function(x){ + + # 1. no distinct() call = no changes (regardless of group_by) + if(is.null(x$distinct)){ + x + # 2. no args to group_by(), no args to distinct() = no changes + }else if(is.na(x$distinct$name) & is.null(x$group_by)){ + x$distinct <- NULL + x + # 3. args to group_by() but not to distinct(), keep_all is FALSE = switch to counts + }else if(!is.null(x$group_by) & is.na(x$distinct$name) & isFALSE(x$distinct$keep_all)){ + count_switch(x) + # 4. args to group_by() but not distinct(), keep_all is TRUE = switch to species + }else if(!is.null(x$group_by) & is.na(x$distinct$name) & isTRUE(x$distinct$keep_all)){ + x$type <- "species" + x + # 5. no args to group_by(), args to distinct(), keep_all is FALSE = switch to counts + }else if(is.null(x$group_by) & !is.na(x$distinct$name) & isFALSE(x$distinct$keep_all)){ + count_switch(x) + # 6. no args to group_by, args to distinct, keep_all is TRUE = switch to species + }else if(is.null(x$group_by) & !is.na(x$distinct$name) & isTRUE(x$distinct$keep_all)){ + x$type <- "species" + x + # 7. args to group_by AND distinct, keep_all is FALSE = switch to species counts + }else if(!is.null(x$group_by) & !is.na(x$distinct$name) & isTRUE(x$distinct$keep_all)){ + x$type <- "species-count" + x + # 8. args to both group_by AND distinct, keep_all is TRUE = switch to species, prioritizing distinct() + }else if(!is.null(x$group_by) & !is.na(x$distinct$name) & isTRUE(x$distinct$keep_all)){ + x$type <- "species" + x + } } + #' @rdname as_query.data_request #' @order 3 #' @export @@ -106,7 +142,15 @@ as_query.metadata_request <- function(x, ...){ "identifiers" = as_query_identifiers(x), cli::cli_abort("Unrecognised 'type'") ) |> - retain_authentication(source = x) + add_request(x) +} + +#' @noRd +#' @keywords Internal +add_request <- function(new_obj, source_obj){ + new_class <- class(new_obj) + new_obj$request <- source_obj + structure(new_obj, class = new_class) } #' @rdname as_query.data_request @@ -125,7 +169,7 @@ as_query.files_request <- function(x, "media" = as_query_media_files(x, thumbnail = thumbnail), cli::cli_abort("Unrecognised 'type'")) |> - retain_authentication(source = x) + add_request(x) } #' @rdname as_query.data_request diff --git a/R/authenticate.R b/R/authenticate.R index 95be17d4..aa5c4312 100644 --- a/R/authenticate.R +++ b/R/authenticate.R @@ -32,19 +32,6 @@ check_authentication <- function(x){ } } -#' Internal function to pass authentication information forward -#' @noRd -#' @keywords Internal -retain_authentication <- function(source, x){ - if( - !is.null(source$authenticate) & # i.e.. authenticate was supplied - is.null(x$authenticate) # but was then lost - ){ - x$authenticate <- source$authenticate - } - x -} - #' get a client, and if it doesn't exist, make one #' @noRd #' @keywords Internal diff --git a/R/collect_occurrences_count.R b/R/collect_occurrences_count.R index d3731a15..14075259 100644 --- a/R/collect_occurrences_count.R +++ b/R/collect_occurrences_count.R @@ -114,7 +114,7 @@ clean_labels <- function(df){ stringr::str_extract("\\.([:graph:]|\\s)+$") |> stringr::str_replace("^\\.", "") variable <- df$i18nCode[1] |> - stringr::str_extract("^[:graph:]+\\.") |> + stringr::str_extract("^[:alnum:]+\\.") |> stringr::str_replace("\\.$", "") df[[variable]] <- values df |> diff --git a/R/collect_species.R b/R/collect_species.R index e2cbd533..045bd107 100644 --- a/R/collect_species.R +++ b/R/collect_species.R @@ -26,9 +26,6 @@ get_clean_colnames <- function(file, facet){ quiet = TRUE) if(length(column_names) > 0){ column_names <- camel_to_snake_case(column_names) - if(grepl("ID$", facet)){ - column_names[1] <- "taxon_concept_id" - } column_names[column_names %in% c("counts", "number_of_records")] <- "count" column_names }else{ diff --git a/R/compute_occurrences.R b/R/compute_occurrences.R index 3cb945a4..ceab8dc3 100644 --- a/R/compute_occurrences.R +++ b/R/compute_occurrences.R @@ -56,8 +56,8 @@ compute_occurrences_la <- function(.query){ c(list(type = "data/occurrences"), status_code, list(fields = extract_fields(.query))) |> - retain_authentication(source = .query) |> - structure(class = "computed_query") + add_request(.query) |> + structure(class = "computed_query") } #' Internal function to get the `fields` vector from a url diff --git a/R/dplyr-count.R b/R/dplyr-count.R index 3d6967e6..7912bad0 100644 --- a/R/dplyr-count.R +++ b/R/dplyr-count.R @@ -1,7 +1,8 @@ #' Count the observations in each group #' #' `count()` lets you quickly count the unique values of one or more variables. -#' It is evaluated lazily. +#' It is evaluated lazily. `add_count()` is an equivalent that uses `mutate()` +#' to add a new column with group-wise counts. #' @name count.data_request #' @param x An object of class `data_request`, created using [galah_call()] #' @param wt currently ignored @@ -14,10 +15,36 @@ count.data_request <- function(x, wt, sort, name){ + count_switch(x) |> + group_by(...) |> + update_request_object(count = TRUE) +} + +#' Internal function called by `count.data_request()` and `distinct.data_request()` +#' @noRd +#' @keywords Internal +count_switch <- function(x){ x$type <- switch(x$type, "occurrences" = "occurrences-count", + "occurrences-count" = "occurrences-count", "species" = "species-count", + "species-count" = "species-count", "media" = cli::cli_abort("type = 'media' is not supported by `count()`"), cli::cli_abort("`count()` only supports `type = 'occurrences' or` `'species'`")) - group_by(x, ...) -} \ No newline at end of file + x +} + +#' @rdname count.data_request +#' @export +add_count.data_request <- function(x, + ..., + wt = NULL, + sort = FALSE, + name = NULL){ + # note: this function effectively is only used by `atlas_species()`/`distinct()` + # unclear whether this error message will be evaluated properly at this point + if(x$type != "species"){ + cli::cli_abort("`add_count()` is only supported for `type = 'species'`") + } + update_request_object(x, count = TRUE) +} diff --git a/R/dplyr-distinct.R b/R/dplyr-distinct.R new file mode 100644 index 00000000..64b1e215 --- /dev/null +++ b/R/dplyr-distinct.R @@ -0,0 +1,54 @@ +#' Keep distinct/unique rows +#' +#' Keep only unique/distinct rows from a data frame. This is similar to +#' [unique.data.frame()] but considerably faster. It is evaluated lazily. +#' @param .data A data frame, data frame extension (e.g. a tibble), or a +#' lazy data frame (e.g. from dbplyr or dtplyr). See Methods, below, +#' for more details. +#' @param ... Optional variables to use when determining uniqueness. If there +#' are multiple rows for a given combination of inputs, only the first row +#' will be preserved. If omitted, will use all variables in the data frame. +#' @param .keep_all If `TRUE`, keep all variables in .data. If a combination +#' of `...` is not distinct, this keeps the first row of values. +#' @export +distinct.data_request <- function(.data, + ..., + .keep_all = FALSE){ + # NOTE: internally this is based on `group_by.data_request` + # BUT there are cases where we need to distinguish between `group_by()` and `distinct()`, + # hence the separate slots + parsed_dots <- rlang::enquos(..., + .ignore_empty = "all") |> + parse_quosures_basic() |> + parse_distinct(keep_all = .keep_all) + + update_request_object(.data, + distinct = parsed_dots) +} + +#' Internal parsing of `distinct` args +#' @noRd +#' @keywords Internal +parse_distinct <- function(dot_names, + keep_all = FALSE, + error_call = rlang::caller_env()){ + if(length(dot_names) > 0){ + if(length(dot_names) > 1){ + c( + "Too many fields supplied.", + i = "`distinct.data_request` only accepts one field.") |> + cli::cli_abort(call = error_call) + } + if(length(dot_names) > 0){ + names(dot_names) <- NULL # needed to avoid empty strings added as names + tibble::tibble(name = dot_names, + keep_all = keep_all) + }else{ + tibble::tibble(name = NA, + keep_all = keep_all) + } + }else{ + tibble::tibble(name = NA, + keep_all = keep_all) + } +} \ No newline at end of file diff --git a/R/dplyr-group_by.R b/R/dplyr-group_by.R index c069994e..f816e01d 100644 --- a/R/dplyr-group_by.R +++ b/R/dplyr-group_by.R @@ -38,6 +38,8 @@ group_by.data_request <- function(.data, ...){ parse_group_by() if(!is.null(parsed_dots)){ update_request_object(.data, group_by = parsed_dots) + # TODO: add warning when using >1 dots with `type = 'occurrences'` + # as this is not supported by the API }else{ .data } diff --git a/R/dplyr-summarise.R b/R/dplyr-summarise.R deleted file mode 100644 index e7258b97..00000000 --- a/R/dplyr-summarise.R +++ /dev/null @@ -1,28 +0,0 @@ -#' Summarise each group down to one row -#' -#' `r lifecycle::badge("experimental")` -#' `summarise()` creates a new data frame. It returns one row for each combination -#' of grouping variables; if there are no grouping variables, the output will -#' have a single row summarising all observations in the input. It will contain -#' one column for each grouping variable and one column for each of the summary -#' statistics that you have specified. -#' -#' Like all `dplyr` extensions in `galah`, this function amends a `data_request`, -#' `metadata_request` or `files_request`, and is evaluated lazily. -#' -#' `summarise()` and `summarize()` are synonyms. -#' @name summarise.data_request -#' @order 1 -#' @param ... Name-value pairs of summary functions. The name will be the -#' name of the variable in the result. The value can be a single function -#' such as `min(x)`, `n()`, or `sum()` -#' @export -summarise.data_request <- function(.data, ...){ - cli::cli_abort("`summarise()` is not yet supported in `galah`. Please try again later.") -} - - -#' @name summarise.data_request -#' @order 2 -#' @export -summarize.data_request <- summarise.data_request \ No newline at end of file diff --git a/R/onload.R b/R/onload.R index 9628f7b8..ec501895 100644 --- a/R/onload.R +++ b/R/onload.R @@ -3,7 +3,7 @@ #' @keywords Internal .onLoad <- function(libname, pkgname) { if (pkgname == "galah") { - + # set up storage of standard information via {potions} potions::brew(.pkg = "galah") # set up caching of behaviour quiet_config <- purrr::quietly(galah_config) @@ -15,7 +15,7 @@ suppressWarnings( try(galah_version <- utils::packageDescription("galah")[["Version"]], silent = TRUE)) - + # show currently-selected atlas current_node <- potions::pour("atlas", .pkg = "galah") |> purrr::pluck("acronym") diff --git a/R/reexports.R b/R/reexports.R index 69868a4c..1d7ca44b 100644 --- a/R/reexports.R +++ b/R/reexports.R @@ -1,3 +1,7 @@ +#' @importFrom dplyr add_count +#' @export +dplyr::add_count + #' @importFrom dplyr arrange #' @export dplyr::arrange @@ -14,33 +18,33 @@ dplyr::compute #' @export dplyr::collapse -#' @importFrom graphics identify +#' @importFrom dplyr count #' @export -graphics::identify +dplyr::count -#' @importFrom dplyr filter +#' @importFrom dplyr distinct #' @export -dplyr::filter +dplyr::distinct -#' @importFrom dplyr select +#' @importFrom dplyr filter #' @export -dplyr::select +dplyr::filter #' @importFrom dplyr group_by #' @export dplyr::group_by +#' @importFrom dplyr select +#' @export +dplyr::select + #' @importFrom dplyr slice_head #' @export dplyr::slice_head -# @importFrom dplyr slice_tail -# @export -# dplyr::slice_tail - -#' @importFrom dplyr count +#' @importFrom graphics identify #' @export -dplyr::count +graphics::identify #' @importFrom sf st_crop #' @export diff --git a/R/utilities_internal.R b/R/utilities_internal.R index 410771ed..95150897 100644 --- a/R/utilities_internal.R +++ b/R/utilities_internal.R @@ -232,7 +232,7 @@ gbif_upper_case <- function(string){ } #' Internal function to handle conversion from upper snake case to camelCase -#' Primarily for reversing the action of [gbif_upper_case()] above +#' Primarily for reversing the action of `gbif_upper_case()` (above) #' @noRd #' @keywords internal snake_to_camel_case <- function(string){ diff --git a/man/count.data_request.Rd b/man/count.data_request.Rd index ef69f4ab..d8023163 100644 --- a/man/count.data_request.Rd +++ b/man/count.data_request.Rd @@ -2,9 +2,12 @@ % Please edit documentation in R/dplyr-count.R \name{count.data_request} \alias{count.data_request} +\alias{add_count.data_request} \title{Count the observations in each group} \usage{ \method{count}{data_request}(x, ..., wt, sort, name) + +\method{add_count}{data_request}(x, ..., wt = NULL, sort = FALSE, name = NULL) } \arguments{ \item{x}{An object of class \code{data_request}, created using \code{\link[=galah_call]{galah_call()}}} @@ -19,5 +22,6 @@ } \description{ \code{count()} lets you quickly count the unique values of one or more variables. -It is evaluated lazily. +It is evaluated lazily. \code{add_count()} is an equivalent that uses \code{mutate()} +to add a new column with group-wise counts. } diff --git a/man/distinct.data_request.Rd b/man/distinct.data_request.Rd new file mode 100644 index 00000000..ee39b677 --- /dev/null +++ b/man/distinct.data_request.Rd @@ -0,0 +1,24 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/dplyr-distinct.R +\name{distinct.data_request} +\alias{distinct.data_request} +\title{Keep distinct/unique rows} +\usage{ +\method{distinct}{data_request}(.data, ..., .keep_all = FALSE) +} +\arguments{ +\item{.data}{A data frame, data frame extension (e.g. a tibble), or a +lazy data frame (e.g. from dbplyr or dtplyr). See Methods, below, +for more details.} + +\item{...}{Optional variables to use when determining uniqueness. If there +are multiple rows for a given combination of inputs, only the first row +will be preserved. If omitted, will use all variables in the data frame.} + +\item{.keep_all}{If \code{TRUE}, keep all variables in .data. If a combination +of \code{...} is not distinct, this keeps the first row of values.} +} +\description{ +Keep only unique/distinct rows from a data frame. This is similar to +\code{\link[=unique.data.frame]{unique.data.frame()}} but considerably faster. It is evaluated lazily. +} diff --git a/man/reexports.Rd b/man/reexports.Rd index 778aeede..dc8dc52c 100644 --- a/man/reexports.Rd +++ b/man/reexports.Rd @@ -3,16 +3,18 @@ \docType{import} \name{reexports} \alias{reexports} +\alias{add_count} \alias{arrange} \alias{collect} \alias{compute} \alias{collapse} -\alias{identify} +\alias{count} +\alias{distinct} \alias{filter} -\alias{select} \alias{group_by} +\alias{select} \alias{slice_head} -\alias{count} +\alias{identify} \alias{st_crop} \title{Objects exported from other packages} \keyword{internal} @@ -21,7 +23,7 @@ These objects are imported from other packages. Follow the links below to see their documentation. \describe{ - \item{dplyr}{\code{\link[dplyr]{arrange}}, \code{\link[dplyr:compute]{collapse}}, \code{\link[dplyr:compute]{collect}}, \code{\link[dplyr]{compute}}, \code{\link[dplyr]{count}}, \code{\link[dplyr]{filter}}, \code{\link[dplyr]{group_by}}, \code{\link[dplyr]{select}}, \code{\link[dplyr:slice]{slice_head}}} + \item{dplyr}{\code{\link[dplyr:count]{add_count}}, \code{\link[dplyr]{arrange}}, \code{\link[dplyr:compute]{collapse}}, \code{\link[dplyr:compute]{collect}}, \code{\link[dplyr]{compute}}, \code{\link[dplyr]{count}}, \code{\link[dplyr]{distinct}}, \code{\link[dplyr]{filter}}, \code{\link[dplyr]{group_by}}, \code{\link[dplyr]{select}}, \code{\link[dplyr:slice]{slice_head}}} \item{graphics}{\code{\link[graphics]{identify}}} diff --git a/man/summarise.data_request.Rd b/man/summarise.data_request.Rd deleted file mode 100644 index baaf0b8d..00000000 --- a/man/summarise.data_request.Rd +++ /dev/null @@ -1,30 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/dplyr-summarise.R -\name{summarise.data_request} -\alias{summarise.data_request} -\alias{summarize.data_request} -\title{Summarise each group down to one row} -\usage{ -\method{summarise}{data_request}(.data, ...) - -\method{summarize}{data_request}(.data, ...) -} -\arguments{ -\item{...}{Name-value pairs of summary functions. The name will be the -name of the variable in the result. The value can be a single function -such as \code{min(x)}, \code{n()}, or \code{sum()}} -} -\description{ -\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}} -\code{summarise()} creates a new data frame. It returns one row for each combination -of grouping variables; if there are no grouping variables, the output will -have a single row summarising all observations in the input. It will contain -one column for each grouping variable and one column for each of the summary -statistics that you have specified. -} -\details{ -Like all \code{dplyr} extensions in \code{galah}, this function amends a \code{data_request}, -\code{metadata_request} or \code{files_request}, and is evaluated lazily. - -\code{summarise()} and \code{summarize()} are synonyms. -} diff --git a/tests/testthat/test-atlas_counts.R b/tests/testthat/test-atlas_counts.R index 4dcb94d1..6498d6a6 100644 --- a/tests/testthat/test-atlas_counts.R +++ b/tests/testthat/test-atlas_counts.R @@ -1,21 +1,5 @@ galah_config(verbose = FALSE) -quiet_collect <- function(x){ - quiet_fun <- purrr::quietly(collect.data_request) - quiet_fun(x) |> - purrr::pluck("result") -} - -test_that("`collapse()` doesn't ping an API for type = `'occurrences-count'`", { - skip_if_offline(); skip_on_ci() - result <- request_data() |> - filter(year == 2010) |> - count() |> - collapse() - expect_true(inherits(result, "query")) - expect_equal(result$type, "data/occurrences-count") -}) - test_that("`atlas_counts()` works with no arguments", { skip_if_offline(); skip_on_ci() count <- atlas_counts() @@ -23,306 +7,6 @@ test_that("`atlas_counts()` works with no arguments", { expect_gt(count$count, 0) }) -test_that("count() |> collect() works with no arguments", { - skip_if_offline(); skip_on_ci() - count <- galah_call() |> - count() |> - collect() - expect_s3_class(count, c("tbl_df", "tbl", "data.frame")) - expect_gt(count$count, 0) -}) - -test_that("`identify()` reduces the number of records returned by `count()`", { - skip_if_offline(); skip_on_ci() - counts_all <- galah_call() |> - count() |> - collect() - counts_mammals <- galah_call() |> - identify("Perameles") |> - count() |> - collect() - expect_type(counts_mammals$count, "integer") - expect_true(counts_mammals$count < counts_all$count) -}) - -test_that("`filter()` works with dates", { - skip_if_offline(); skip_on_ci() - counts <- galah_call() |> - filter(species == "Cacatua galerita", - eventDate >= "2023-01-07T00:00:00Z", - eventDate < "2023-01-08T00:00:00Z") |> - count() |> - collect() - expect_type(counts$count, "integer") -}) - -test_that("`galah_identify()` works with `atlas_counts()`", { - skip_if_offline(); skip_on_ci() - counts_all <- galah_call() |> - count() |> - collect() - counts_mammals <- galah_call() |> - galah_identify("Perameles") |> - atlas_counts() - expect_type(counts_mammals$count, "integer") - expect_true(counts_mammals$count < counts_all$count) -}) - -test_that("`count()` handles multiple 'group by' variables", { - skip_if_offline(); skip_on_ci() - counts <- galah_call() |> - filter(year >= 2021) |> - group_by(year, month, basisOfRecord) |> - count() |> - collect() - expect_s3_class(counts, c("tbl_df", "tbl", "data.frame")) - expect_equal(names(counts), - c("year", "month", "basisOfRecord", "count")) - expect_true(all(counts$year >= 2021)) -}) - -test_that("`count()` handles 'species' as a 'group by' variable", { - skip_if_offline(); skip_on_ci() - counts <- galah_call() |> - filter(year > 2020) |> - identify("Perameles") |> - group_by(species, year) |> - count() |> - collect() - expect_s3_class(counts, c("tbl_df", "tbl", "data.frame")) - expect_true(all(names(counts) %in% c("species", "year", "count"))) - expect_true(all(counts$year > 2020)) - expect_true(all(grepl("^Perameles", counts$species))) -}) - -test_that("`atlas_counts()` handles 'taxonConceptID' as a 'group by' variable", { - skip_if_offline(); skip_on_ci() - counts <- galah_call() |> - identify("Perameles") |> - filter(year >= 2015) |> - group_by(taxonConceptID, year) |> - count() |> - collect() - expect_s3_class(counts, c("tbl_df", "tbl", "data.frame")) - expect_equal(names(counts), - c("taxonConceptID", "year", "count")) - expect_true(all(counts$year >= 2015)) -}) - -# test added to address Issue #265 -test_that("`atlas_counts()` handles `identify()` in combination with `OR` statements in `filter()`", { - skip_if_offline(); skip_on_ci() - regions <- c("Dampierland","Wet Tropics") - counts <- galah_call() |> - identify("Squamata") |> - filter(cl1048 %in% regions) |> - group_by(cl1048) |> - count() |> - collect() - expect_s3_class(counts, c("tbl_df", "tbl", "data.frame")) - expect_equal(nrow(counts), 2) - expect_true(all(regions %in% counts$cl1048)) - expect_true(all(counts$count > 0)) -}) - -test_that("`atlas_counts()` returns same result with filter using `,` and `&`", { - skip_if_offline(); skip_on_ci() - count_comma <- galah_call() |> - filter(year >= 2010, year < 2020) |> - count() |> - collect() - count_and <- galah_call() |> - filter(year >= 2010 & year < 2020) |> - count() |> - collect() - expect_equal(count_comma, count_and) -}) - -test_that("`atlas_counts()` filters correctly with galah_geolocate/galah_polygon", { - skip_if_offline(); skip_on_ci() - wkt <- "POLYGON ((146.5425 -42.63203, 146.8312 -43.13203, 147.4085 -43.13203, 147.6972 -42.63203, 147.4085 -42.13203, 146.8312 -42.13203, 146.5425 -42.63203))" |> - sf::st_as_sfc() - base_query <- galah_call() |> - identify("Perameles") |> - filter(year >= 2020) |> - count() - counts <- base_query |> collect() - counts_filtered <- base_query |> - geolocate(wkt) |> - quiet_collect() - expect_s3_class(counts_filtered, c("tbl_df", "tbl", "data.frame")) - count_1 <- counts_filtered$count[1] - count_2 <- counts$count[1] - expect_lt(count_1, count_2) -}) - -test_that("`atlas_counts()` filters correctly with galah_geolocate/galah_bbox/galah_radius", { - skip_if_offline(); skip_on_ci() - wkt <- "POLYGON ((146.5425 -42.63203, 146.8312 -43.13203, 147.4085 -43.13203, 147.6972 -42.63203, 147.4085 -42.13203, 146.8312 -42.13203, 146.5425 -42.63203))" |> - sf::st_as_sfc() - base_query <- galah_call() |> - identify("Perameles") |> - filter(year >= 2020) |> - count() - counts <- base_query |> collect() - counts_filtered <- base_query |> - geolocate(wkt, type = "bbox") |> - quiet_collect() - counts_filtered_radius <- base_query |> - geolocate(lon = 147, - lat = -42.9, - radius = 20, - type = "radius") |> - quiet_collect() - expect_s3_class(counts_filtered, c("tbl_df", "tbl", "data.frame")) - expect_s3_class(counts_filtered_radius, c("tbl_df", "tbl", "data.frame")) - count_1 <- counts_filtered$count[1] - count_2 <- counts$count[1] - count_3 <- counts_filtered_radius$count[1] - expect_lt(count_1, count_2, count_3) -}) - -test_that("`atlas_counts()` returns species counts", { - skip_if_offline(); skip_on_ci() - count_species <- galah_call(type = "species") |> - count() |> - collect() - count_records <- galah_call() |> - count() |> - collect() - expect_s3_class(count_species, c("tbl_df", "tbl", "data.frame")) - expect_type(count_species$count, "integer") - expect_gt(count_species$count, 0) - expect_lt(count_species$count, count_records$count) -}) - -test_that("species counts work with group_by()", { - skip_if_offline(); skip_on_ci() - count_species <- galah_call(type = "species") |> - identify("Crinia") |> - filter(year >= 2020) |> - group_by(year) |> - arrange(year) |> - count() |> - collect() - count_records <- galah_call() |> - identify("Crinia") |> - filter(year >= 2020) |> - group_by(year) |> - arrange(year) |> - count() |> - collect() - expect_s3_class(count_species, c("tbl_df", "tbl", "data.frame")) - expect_type(count_species$count, "integer") - expect_gte(nrow(count_species), 4) - expect_true(all(count_species$count > 0)) - expect_true(all(count_species$count < 50)) - expect_true(all(count_records$year == count_species$year)) - expect_true(all(count_records$count >= count_species$count)) -}) - -test_that("order of `group_by()` doesn't affect result in `atlas_counts()", { - # This is a test for Issue #198 raised by @shandiya - # https://github.com/AtlasOfLivingAustralia/galah-R/issues/198 - skip_if_offline(); skip_on_ci() - reg <- c("Gibson Desert", - "Little Sandy Desert", - "Southern Volcanic Plain", - "Flinders Lofty Block") - # IBRA then year (with no limit) - ibra_year <- galah_call() |> - filter(cl1048 == reg, - year >= 1971, - year <= 2020) |> - group_by(cl1048, year) |> - arrange(desc(count)) |> - count() |> - collect() - year_ibra <- galah_call() |> - filter(cl1048 == reg, - year >= 1971, - year <= 2020) |> - group_by(year, cl1048) |> - arrange(desc(count)) |> - count() |> - collect() - # we expect these two tibbles to have the same colnames, - # but in a different order (respecting user-supplied info) - expect_true(all(colnames(ibra_year) %in% c("year", "cl1048", "count"))) - expect_true(all(colnames(year_ibra) %in% c("year", "cl1048", "count"))) - expect_false(all(colnames(year_ibra) == colnames(ibra_year))) - # we also expect them to have the same number of rows, and the same total - expect_equal(nrow(ibra_year), nrow(year_ibra)) - expect_equal(sum(ibra_year$count), sum(year_ibra$count)) - ## FIXME: - # expect_equal(ibra_year, year_ibra) # this fails, - ## because `arrange` is not (re-)applied after download, - ## so rows are not in the same order -}) - -test_that("`group_by()` works when > 1 `filter()`", { - skip_if_offline(); skip_on_ci() - chosen_species <- c("Eolophus roseicapilla", "Platycercus elegans") - x <- request_data() |> - filter(species == chosen_species) |> - group_by(species) |> - count() |> - collect() - expect_s3_class(x, c("tbl_df", "tbl", "data.frame")) - expect_equal(x$species, chosen_species) - expect_equal(colnames(x), c("species", "count")) - expect_equal(nrow(x), 2) - # previously, adding an additional field (`year` below) removed one species from resulting tibble - y <- request_data() |> - filter(species == chosen_species, - year == 2023) |> - group_by(species) |> - count() |> - collect() - expect_s3_class(y, c("tbl_df", "tbl", "data.frame")) - expect_equal(y$species, chosen_species) - expect_equal(colnames(y), c("species", "count")) - expect_equal(nrow(y), 2) - expect_true(all(x$count > y$count)) # extra filter - # compare to different syntax - z <- galah_call() |> - galah_filter(species == c("Eolophus roseicapilla", "Platycercus elegans"), - year == 2023) |> - galah_group_by(species) |> - atlas_counts() - expect_equal(y, z) -}) - -## BELOW HERE TESTS WILL FAIL - -# capture_requests("count_piped_2", { -# test_that("`atlas_counts()` ignores superfluous piped arguments", { -# counts <- galah_call() |> -# filter(year >= 2018) |> -# group_by(year) |> -# galah_down_to(species) |> -# select(taxonConceptID) |> -# count() -# expect_s3_class(counts, c("tbl_df", "tbl", "data.frame")) -# expect_equal(names(counts), c("year", "count")) -# expect_gt(nrow(counts), 0) -# }) -# }) - -# test_that("`atlas_counts()` handles pagination", { -# vcr::use_cassette("count_with_pagination", { -# counts <- galah_call() |> -# group_by(year) |> -# slice_head(n = 101) |> -# count() -# }) -# expect_s3_class(counts, c("tbl_df", "tbl", "data.frame")) -# expect_equal(nrow(counts), 101) -# expect_equal(names(counts), c("year", "count")) -# }) - # FIXME: check non-piped args work # FIXME: check `galah_` functions work -# FIXME: check `atlas_counts` - -rm(quiet_collect) \ No newline at end of file +# FIXME: check `atlas_counts` \ No newline at end of file diff --git a/tests/testthat/test-atlas_species.R b/tests/testthat/test-atlas_species.R index 71bcd841..87af4de1 100644 --- a/tests/testthat/test-atlas_species.R +++ b/tests/testthat/test-atlas_species.R @@ -161,27 +161,4 @@ test_that("atlas_species reformats column names when empty tibble is returned", expect_s3_class(species, c("tbl_df", "tbl", "data.frame")) }) -test_that("`group_by()` works on occurrences", { - skip_if_offline(); skip_on_ci() - # compare group_by with atlas_species - x <- galah_call() |> - filter(year == 2024, - genus == "Crinia") |> - group_by(speciesID) |> - quiet_collect() - y <- galah_call() |> - filter(year == 2024, - genus == "Crinia") |> - quiet_species() - expect_equal(x, y) - # try with a different variable - z <- galah_call() |> - filter(year == 2024, - genus == "Crinia") |> - group_by(genusID) |> - quiet_collect() - expect_true(inherits(z, c("tbl_df", "tbl", "data.frame"))) - expect_equal(colnames(z)[1], "taxon_concept_id") -}) - rm(quiet_collect, quiet_species) \ No newline at end of file diff --git a/tests/testthat/test-count_arrange_slice.R b/tests/testthat/test-dplyr-arrange_slice.R similarity index 92% rename from tests/testthat/test-count_arrange_slice.R rename to tests/testthat/test-dplyr-arrange_slice.R index 7b31005f..cc0a69fa 100644 --- a/tests/testthat/test-count_arrange_slice.R +++ b/tests/testthat/test-dplyr-arrange_slice.R @@ -17,19 +17,6 @@ test_that("default is to arrange by decending order of count", { expect_equal(colnames(result), c("year", "count")) }) -test_that("`count(year)` groups by `year`", { - skip_if_offline(); skip_on_ci() - result <- galah_call() |> - filter(year >= 2015) |> - count(year) |> - quiet_collect() - expect_true(all(diff(result$count) < 0)) - expect_true(nrow(result) > 7) - expect_true(all(result$year >= 2015)) - expect_equal(ncol(result), 2) - expect_equal(colnames(result), c("year", "count")) -}) - test_that("arrange in increasing order of count", { skip_if_offline(); skip_on_ci() result <- galah_call() |> diff --git a/tests/testthat/test-dplyr-count.R b/tests/testthat/test-dplyr-count.R new file mode 100644 index 00000000..0bac8486 --- /dev/null +++ b/tests/testthat/test-dplyr-count.R @@ -0,0 +1,350 @@ +quiet_collect <- function(x){ + quiet_fun <- purrr::quietly(dplyr::collect) + quiet_fun(x) |> + purrr::pluck("result") +} + +test_that("`collapse()` creates a query object for type = `'occurrences-count'`", { + skip_if_offline(); skip_on_ci() + result <- request_data() |> + filter(year == 2010) |> + count() |> + collapse() + expect_true(inherits(result, "query")) + expect_equal(result$type, "data/occurrences-count") +}) + +test_that("count() |> collect() works with no arguments", { + skip_if_offline(); skip_on_ci() + count <- galah_call() |> + count() |> + collect() + expect_s3_class(count, c("tbl_df", "tbl", "data.frame")) + expect_gt(count$count, 0) +}) + +test_that("`identify()` reduces the number of records returned by `count()`", { + skip_if_offline(); skip_on_ci() + counts_all <- galah_call() |> + count() |> + collect() + counts_mammals <- galah_call() |> + identify("Perameles") |> + count() |> + collect() + expect_type(counts_mammals$count, "integer") + expect_true(counts_mammals$count < counts_all$count) +}) + +test_that("`filter()` works with dates", { + skip_if_offline(); skip_on_ci() + counts <- galah_call() |> + filter(species == "Cacatua galerita", + eventDate >= "2023-01-07T00:00:00Z", + eventDate < "2023-01-08T00:00:00Z") |> + count() |> + collect() + expect_type(counts$count, "integer") +}) + +test_that("`identify()` works for counts", { + skip_if_offline(); skip_on_ci() + counts_all <- galah_call() |> + count() |> + collect() + counts_mammals <- galah_call() |> + galah_identify("Perameles") |> + atlas_counts() + expect_type(counts_mammals$count, "integer") + expect_true(counts_mammals$count < counts_all$count) +}) + +test_that("`count(year)` groups by `year`", { + skip_if_offline(); skip_on_ci() + result <- galah_call() |> + filter(year >= 2015) |> + count(year) |> + quiet_collect() + expect_true(all(diff(result$count) < 0)) + expect_true(nrow(result) > 7) + expect_true(all(result$year >= 2015)) + expect_equal(ncol(result), 2) + expect_equal(colnames(result), c("year", "count")) +}) + +test_that("`count()` handles multiple 'group by' variables", { + skip_if_offline(); skip_on_ci() + counts <- galah_call() |> + filter(year >= 2021) |> + group_by(year, month, basisOfRecord) |> + count() |> + collect() + expect_s3_class(counts, c("tbl_df", "tbl", "data.frame")) + expect_equal(names(counts), + c("year", "month", "basisOfRecord", "count")) + expect_true(all(counts$year >= 2021)) +}) + +test_that("`count()` handles multiple variables", { + skip_if_offline(); skip_on_ci() + counts <- galah_call() |> + filter(year >= 2021) |> + count(year, month, basisOfRecord) |> + collect() + expect_s3_class(counts, c("tbl_df", "tbl", "data.frame")) + expect_equal(names(counts), + c("year", "month", "basisOfRecord", "count")) + expect_true(all(counts$year >= 2021)) +}) + +test_that("`count()` handles 'species' as a 'group by' variable", { + skip_if_offline(); skip_on_ci() + counts <- galah_call() |> + filter(year > 2020) |> + identify("Perameles") |> + group_by(species, year) |> + count() |> + collect() + expect_s3_class(counts, c("tbl_df", "tbl", "data.frame")) + expect_true(all(names(counts) %in% c("species", "year", "count"))) + expect_true(all(counts$year > 2020)) + expect_true(all(grepl("^Perameles", counts$species))) +}) + +test_that("`count()` handles 'taxonConceptID' as a 'group by' variable", { + skip_if_offline(); skip_on_ci() + counts <- galah_call() |> + identify("Perameles") |> + filter(year >= 2015) |> + group_by(taxonConceptID, year) |> + count() |> + collect() + expect_s3_class(counts, c("tbl_df", "tbl", "data.frame")) + expect_equal(names(counts), + c("taxonConceptID", "year", "count")) + expect_true(all(counts$year >= 2015)) +}) + +test_that("`count()` handles 'speciesID' as a 'group by' variable", { + # FIXME: Currently returns colname `speciesID.https://biodiversity.org` + counts <- galah_call() |> + filter(year == 1900, + genus == "Crinia") |> + count(speciesID) |> + collect() + expect_s3_class(counts, c("tbl_df", "tbl", "data.frame")) + expect_equal(names(counts), + c("speciesID", "count")) +}) + +# test added to address Issue #265 +test_that("`count()` handles `identify()` in combination with `OR` statements in `filter()`", { + skip_if_offline(); skip_on_ci() + regions <- c("Dampierland","Wet Tropics") + counts <- galah_call() |> + identify("Squamata") |> + filter(cl1048 %in% regions) |> + group_by(cl1048) |> + count() |> + collect() + expect_s3_class(counts, c("tbl_df", "tbl", "data.frame")) + expect_equal(nrow(counts), 2) + expect_true(all(regions %in% counts$cl1048)) + expect_true(all(counts$count > 0)) +}) + +test_that("`count()` returns same result with filter using `,` and `&`", { + skip_if_offline(); skip_on_ci() + count_comma <- galah_call() |> + filter(year >= 2010, year < 2020) |> + count() |> + collect() + count_and <- galah_call() |> + filter(year >= 2010 & year < 2020) |> + count() |> + collect() + expect_equal(count_comma, count_and) +}) + +test_that("`count()` filters correctly with galah_geolocate/galah_polygon", { + skip_if_offline(); skip_on_ci() + wkt <- "POLYGON ((146.5425 -42.63203, 146.8312 -43.13203, 147.4085 -43.13203, 147.6972 -42.63203, 147.4085 -42.13203, 146.8312 -42.13203, 146.5425 -42.63203))" |> + sf::st_as_sfc() + base_query <- galah_call() |> + identify("Perameles") |> + filter(year >= 2020) |> + count() + counts <- base_query |> collect() + counts_filtered <- base_query |> + geolocate(wkt) |> + quiet_collect() + expect_s3_class(counts_filtered, c("tbl_df", "tbl", "data.frame")) + count_1 <- counts_filtered$count[1] + count_2 <- counts$count[1] + expect_lt(count_1, count_2) +}) + +test_that("`count()` filters correctly with galah_geolocate/galah_bbox/galah_radius", { + skip_if_offline(); skip_on_ci() + wkt <- "POLYGON ((146.5425 -42.63203, 146.8312 -43.13203, 147.4085 -43.13203, 147.6972 -42.63203, 147.4085 -42.13203, 146.8312 -42.13203, 146.5425 -42.63203))" |> + sf::st_as_sfc() + base_query <- galah_call() |> + identify("Perameles") |> + filter(year >= 2020) |> + count() + counts <- base_query |> collect() + counts_filtered <- base_query |> + geolocate(wkt, type = "bbox") |> + quiet_collect() + counts_filtered_radius <- base_query |> + geolocate(lon = 147, + lat = -42.9, + radius = 20, + type = "radius") |> + quiet_collect() + expect_s3_class(counts_filtered, c("tbl_df", "tbl", "data.frame")) + expect_s3_class(counts_filtered_radius, c("tbl_df", "tbl", "data.frame")) + count_1 <- counts_filtered$count[1] + count_2 <- counts$count[1] + count_3 <- counts_filtered_radius$count[1] + expect_lt(count_1, count_2, count_3) +}) + +test_that("`count()` returns species counts", { + skip_if_offline(); skip_on_ci() + count_species <- galah_call(type = "species") |> + count() |> + collect() + count_records <- galah_call() |> + count() |> + collect() + expect_s3_class(count_species, c("tbl_df", "tbl", "data.frame")) + expect_type(count_species$count, "integer") + expect_gt(count_species$count, 0) + expect_lt(count_species$count, count_records$count) +}) + +test_that("species counts work with group_by()", { + skip_if_offline(); skip_on_ci() + count_species <- galah_call(type = "species") |> + identify("Crinia") |> + filter(year >= 2020) |> + group_by(year) |> + arrange(year) |> + count() |> + collect() + count_records <- galah_call() |> + identify("Crinia") |> + filter(year >= 2020) |> + group_by(year) |> + arrange(year) |> + count() |> + collect() + expect_s3_class(count_species, c("tbl_df", "tbl", "data.frame")) + expect_type(count_species$count, "integer") + expect_gte(nrow(count_species), 4) + expect_true(all(count_species$count > 0)) + expect_true(all(count_species$count < 50)) + expect_true(all(count_records$year == count_species$year)) + expect_true(all(count_records$count >= count_species$count)) +}) + +test_that("order of `group_by()` doesn't affect result in `count()", { + # This is a test for Issue #198 raised by @shandiya + # https://github.com/AtlasOfLivingAustralia/galah-R/issues/198 + skip_if_offline(); skip_on_ci() + reg <- c("Gibson Desert", + "Little Sandy Desert", + "Southern Volcanic Plain", + "Flinders Lofty Block") + # IBRA then year (with no limit) + ibra_year <- galah_call() |> + filter(cl1048 == reg, + year >= 1971, + year <= 2020) |> + group_by(cl1048, year) |> + arrange(desc(count)) |> + count() |> + collect() + year_ibra <- galah_call() |> + filter(cl1048 == reg, + year >= 1971, + year <= 2020) |> + group_by(year, cl1048) |> + arrange(desc(count)) |> + count() |> + collect() + # we expect these two tibbles to have the same colnames, + # but in a different order (respecting user-supplied info) + expect_true(all(colnames(ibra_year) %in% c("year", "cl1048", "count"))) + expect_true(all(colnames(year_ibra) %in% c("year", "cl1048", "count"))) + expect_false(all(colnames(year_ibra) == colnames(ibra_year))) + # we also expect them to have the same number of rows, and the same total + expect_equal(nrow(ibra_year), nrow(year_ibra)) + expect_equal(sum(ibra_year$count), sum(year_ibra$count)) + ## FIXME: + # expect_equal(ibra_year, year_ibra) # this fails, + ## because `arrange` is not (re-)applied after download, + ## so rows are not in the same order +}) + +test_that("`group_by()` works when > 1 `filter()`", { + skip_if_offline(); skip_on_ci() + chosen_species <- c("Eolophus roseicapilla", "Platycercus elegans") + x <- request_data() |> + filter(species == chosen_species) |> + group_by(species) |> + count() |> + collect() + expect_s3_class(x, c("tbl_df", "tbl", "data.frame")) + expect_equal(x$species, chosen_species) + expect_equal(colnames(x), c("species", "count")) + expect_equal(nrow(x), 2) + # previously, adding an additional field (`year` below) removed one species from resulting tibble + y <- request_data() |> + filter(species == chosen_species, + year == 2023) |> + group_by(species) |> + count() |> + collect() + expect_s3_class(y, c("tbl_df", "tbl", "data.frame")) + expect_equal(y$species, chosen_species) + expect_equal(colnames(y), c("species", "count")) + expect_equal(nrow(y), 2) + expect_true(all(x$count > y$count)) # extra filter + # compare to different syntax + z <- galah_call() |> + galah_filter(species == c("Eolophus roseicapilla", "Platycercus elegans"), + year == 2023) |> + galah_group_by(species) |> + atlas_counts() + expect_equal(y, z) +}) + +## BELOW HERE TESTS WILL FAIL + +# capture_requests("count_piped_2", { +# test_that("`atlas_counts()` ignores superfluous piped arguments", { +# counts <- galah_call() |> +# filter(year >= 2018) |> +# group_by(year) |> +# galah_down_to(species) |> +# select(taxonConceptID) |> +# count() +# expect_s3_class(counts, c("tbl_df", "tbl", "data.frame")) +# expect_equal(names(counts), c("year", "count")) +# expect_gt(nrow(counts), 0) +# }) +# }) + +# test_that("`atlas_counts()` handles pagination", { +# vcr::use_cassette("count_with_pagination", { +# counts <- galah_call() |> +# group_by(year) |> +# slice_head(n = 101) |> +# count() +# }) +# expect_s3_class(counts, c("tbl_df", "tbl", "data.frame")) +# expect_equal(nrow(counts), 101) +# expect_equal(names(counts), c("year", "count")) +# }) \ No newline at end of file diff --git a/tests/testthat/test-dplyr-distinct.R b/tests/testthat/test-dplyr-distinct.R new file mode 100644 index 00000000..3a4ccb46 --- /dev/null +++ b/tests/testthat/test-dplyr-distinct.R @@ -0,0 +1,138 @@ +# set up quiet functions for testing reasons +quiet_collect <- function(x){ + purrr_collect <- purrr::quietly(collect.data_request) + purrr_collect(x) |> + purrr::pluck("result") +} + +test_that("`group_by()` without `distinct()` returns occurrences, not species", { + skip_if_offline(); skip_on_ci() + query <- galah_call() |> + filter(year == 2024, + genus == "Crinia") + expected_n <- query |> + count() |> + quiet_collect() + query_final <- query |> + group_by(speciesID) + expect_equal(query_final$type, "occurrences") + x <- quiet_collect(query_final) + expect_s3_class(x, + c("tbl_df", "tbl", "data.frame")) + expect_equal(nrow(x), expected_n$count) + expect_true(length(unique(x$taxonConceptID)) < nrow(x)) +}) + +test_that("distinct() with no arguments and no `group_by()` returns occurrences (i.e. does nothing)", { + skip_if_offline(); skip_on_ci() + query <- galah_call() |> + filter(year == 2024, + genus == "Crinia") + expected_n <- query |> + count() |> + quiet_collect() + query_final <- query |> + distinct() + expect_equal(query_final$type, "occurrences") + x <- quiet_collect(query_final) + expect_s3_class(x, + c("tbl_df", "tbl", "data.frame")) + expect_equal(nrow(x), expected_n$count) + expect_true(length(unique(x$taxonConceptID)) < nrow(x)) +}) + +test_that("`group_by() |> distinct(.keep_all = FALSE)` uses occurrences-count, but *doesn't* return counts", { + skip_if_offline(); skip_on_ci() + x <- galah_call() |> + filter(year == 2024, + genus == "Crinia") |> + group_by(speciesID) |> + distinct() |> + quiet_collect() + expect_s3_class(x, + c("tbl_df", "tbl", "data.frame")) + expect_equal(ncol(x), 1) + expect_equal(colnames(x), "speciesID") + expect_equal(length(unique(x$speciesID)), + nrow(x)) +}) + +test_that("`group_by() |> distinct(.keep_all = TRUE)` converts type from occurrences to species", { + skip_if_offline(); skip_on_ci() + query <- galah_call() |> + filter(year == 2024, + genus == "Crinia") |> + group_by(speciesID) |> + distinct(.keep_all = TRUE) |> + collapse() + expect_equal(query$type, "data/species") + x <- quiet_collect(query) + expect_s3_class(x, + c("tbl_df", "tbl", "data.frame")) + expect_equal(length(unique(x$speciesID)), + nrow(x)) +}) + +test_that("`add_count() |> distinct()` adds record counts to each species", { + skip_if_offline(); skip_on_ci() + query <- galah_call() |> + filter(year == 2024, + genus == "Crinia") |> + group_by(speciesID) |> + add_count() |> + distinct(.keep_all = TRUE) |> + collapse() + expect_equal(query$type, "data/species") + x <- quiet_collect(query) + expect_s3_class(x, + c("tbl_df", "tbl", "data.frame")) + expect_equal(length(unique(x$speciesID)), + nrow(x)) +}) + +test_that("`distinct()` can be used in place of `group_by()` for species queries", { + skip_if_offline(); skip_on_ci() + result <- galah_call() |> + identify("Osphranter") |> + distinct(speciesID, .keep_all = TRUE) |> + quiet_collect() + expect_gte(nrow(result), 4) + expect_s3_class(result, + c("tbl_df", "tbl", "data.frame")) + expect_contains(colnames(result), + c("taxon_concept_id", "species_name", "kingdom")) +}) + +test_that("`distinct(speciesID) |> count()` can be used to count the number of species", { + skip_if_offline(); skip_on_ci() + result <- galah_call() |> + identify("perameles") |> + distinct(taxonConceptID) |> + count() + expect_s3_class(x, + c("tbl_df", "tbl", "data.frame")) + expect_equal(nrow(result), 1) + expect_true(result$count[1] > 1 & result$count[1] < 10) +}) + +test_that("`group_by(something) |> distinct(speciesID) |> count()` gives grouped number of categories", { + skip_if_offline(); skip_on_ci() + result <- galah_call() |> + identify("perameles") + group_by(basisOfRecord) |> + distinct(speciesID) |> + count() + expect_equal(colnames(result), + c("basisOfRecord", "count")) + all(result$count < 10) |> + expect_true() +}) + +test_that("using `add_count()` allows counts to be added to speces queries", { + galah_call() |> + group_by(taxonConceptID) |> + add_count() |> # no longer in `select()` + distinct(taxonConceptID, .keep_all = TRUE) +}) + +rm(quiet_collect) \ No newline at end of file diff --git a/tests/testthat/test-galah_filter-GBIF.R b/tests/testthat/test-dplyr-filter-GBIF.R similarity index 100% rename from tests/testthat/test-galah_filter-GBIF.R rename to tests/testthat/test-dplyr-filter-GBIF.R diff --git a/tests/testthat/test-galah_filter.R b/tests/testthat/test-dplyr-filter.R similarity index 100% rename from tests/testthat/test-galah_filter.R rename to tests/testthat/test-dplyr-filter.R diff --git a/tests/testthat/test-galah_select.R b/tests/testthat/test-dplyr-select.R similarity index 100% rename from tests/testthat/test-galah_select.R rename to tests/testthat/test-dplyr-select.R diff --git a/tests/testthat/test-international-UK.R b/tests/testthat/test-international-UK.R index 5e3783d7..fad5296e 100644 --- a/tests/testthat/test-international-UK.R +++ b/tests/testthat/test-international-UK.R @@ -167,8 +167,8 @@ test_that("atlas_counts works with galah_identify for United Kingdom", { test_that("atlas_counts works with group_by for United Kingdom", { skip_if_offline(); skip_on_ci() result <- galah_call() |> - galah_filter(year >= 2020) |> - galah_group_by(year) |> + filter(year >= 2020) |> + group_by(year) |> atlas_counts() |> try(silent = TRUE) skip_if(inherits(result, "try-error"), message = "API not available") From 1dbbe77ef71d12b8e640a28c43f6ce1e43af76e7 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Mon, 5 Jan 2026 11:43:02 +1100 Subject: [PATCH 54/94] change `update_request_object()` to add slots in the order provided (#284) This is necessary to ensure that we know when in the pipe `add_count()` etc were added, which can affect the result. Slot names are now always the same as function names with change from `data_profile` to `apply_profile` --- R/as_query-occurrences.R | 4 +- R/as_query-occurrences_count.R | 4 +- R/as_query-species.R | 2 +- R/as_query-species_count.R | 4 +- R/atlas_counts.R | 2 +- R/atlas_media.R | 2 +- R/atlas_occurrences.R | 4 +- R/atlas_species.R | 2 +- R/build_query.R | 6 +- R/coalesce.R | 2 +- R/dplyr-arrange.R | 4 +- R/dplyr-count.R | 11 +-- R/galah_apply_profile.R | 6 +- R/galah_call.R | 20 +----- R/handle_request_objects.R | 87 +++++++++++------------ R/print.R | 2 +- man/apply_profile.Rd | 2 +- man/atlas_.Rd | 10 +-- tests/testthat/test-galah_apply_profile.R | 2 +- tests/testthat/test-galah_call.R | 25 ++++--- 20 files changed, 92 insertions(+), 109 deletions(-) diff --git a/R/as_query-occurrences.R b/R/as_query-occurrences.R index 67321967..de625c5e 100644 --- a/R/as_query-occurrences.R +++ b/R/as_query-occurrences.R @@ -33,7 +33,7 @@ as_query_occurrences_uk <- function(.query, ...){ url$query <- c(build_query(identify = .query$identify, filter = .query$filter, location = .query$geolocate, - data_profile = .query$data_profile), + apply_profile = .query$apply_profile), fields = "`SELECT_PLACEHOLDER`", qa = "`ASSERTIONS_PLACEHOLDER`", sourceTypeId = source_type_id_lookup("United Kingdom"), @@ -95,7 +95,7 @@ as_query_occurrences_la <- function(.query, query <- c(build_query(identify = .query$identify, filter = .query$filter, location = .query$geolocate, - data_profile = .query$data_profile), + apply_profile = .query$apply_profile), fields = "`SELECT_PLACEHOLDER`", qa = "`ASSERTIONS_PLACEHOLDER`", facet = "false", diff --git a/R/as_query-occurrences_count.R b/R/as_query-occurrences_count.R index 287c806a..33b353fb 100644 --- a/R/as_query-occurrences_count.R +++ b/R/as_query-occurrences_count.R @@ -22,7 +22,7 @@ as_query_occurrences_count <- function(.query){ as_query_occurrences_count_atlas <- function(identify = NULL, filter = NULL, geolocate = NULL, - data_profile = NULL, + apply_profile = NULL, group_by = NULL, slice = NULL, arrange = NULL @@ -30,7 +30,7 @@ as_query_occurrences_count_atlas <- function(identify = NULL, query <- build_query(identify, filter, geolocate, - data_profile = data_profile) + apply_profile = apply_profile) # set behaviour depending on `group_by()` if(is.null(group_by)){ url <- url_lookup("data/occurrences-count") |> diff --git a/R/as_query-species.R b/R/as_query-species.R index eb998a3a..8a110d45 100644 --- a/R/as_query-species.R +++ b/R/as_query-species.R @@ -32,7 +32,7 @@ as_query_species_atlas <- function(.query){ build_query(.query$identify, .query$filter, .query$geolocate, - .query$data_profile), + .query$apply_profile), sourceTypeId = 2004, reasonTypeId = potions::pour("user", "download_reason_id"), facets = .query$group_by$name, diff --git a/R/as_query-species_count.R b/R/as_query-species_count.R index a42cb715..28d900d1 100644 --- a/R/as_query-species_count.R +++ b/R/as_query-species_count.R @@ -22,7 +22,7 @@ as_query_species_count <- function(.query, as_query_species_count_atlas <- function(identify = NULL, filter = NULL, geolocate = NULL, - data_profile = NULL, + apply_profile = NULL, group_by = NULL, slice = NULL, arrange = NULL @@ -32,7 +32,7 @@ as_query_species_count_atlas <- function(identify = NULL, query <- build_query(identify, filter, geolocate, - data_profile = data_profile) + apply_profile = apply_profile) # set behaviour depending on `group_by()` if(is.null(group_by)){ url$query <- c(query, diff --git a/R/atlas_counts.R b/R/atlas_counts.R index 495db85d..4521d0f3 100644 --- a/R/atlas_counts.R +++ b/R/atlas_counts.R @@ -13,7 +13,7 @@ atlas_counts <- function(request = NULL, identify = NULL, filter = NULL, geolocate = NULL, - data_profile = NULL, + apply_profile = NULL, group_by = NULL, limit = NULL, type = c("occurrences", "species") diff --git a/R/atlas_media.R b/R/atlas_media.R index e9c2b658..f44dcf76 100644 --- a/R/atlas_media.R +++ b/R/atlas_media.R @@ -9,7 +9,7 @@ atlas_media <- function(request = NULL, filter = NULL, select = NULL, geolocate = NULL, - data_profile = NULL, + apply_profile = NULL, all_fields = FALSE ) { diff --git a/R/atlas_occurrences.R b/R/atlas_occurrences.R index 1f3fac71..349577ad 100644 --- a/R/atlas_occurrences.R +++ b/R/atlas_occurrences.R @@ -23,7 +23,7 @@ #' @param identify `tibble`: generated by a call to [galah_identify()]. #' @param filter `tibble`: generated by a call to [galah_filter()] #' @param geolocate `string`: generated by a call to [galah_geolocate()] -#' @param data_profile `string`: generated by a call to [galah_apply_profile()] +#' @param apply_profile `string`: generated by a call to [galah_apply_profile()] #' @param select `tibble`: generated by a call to [galah_select()] #' @param mint_doi `logical`: by default no DOI will be generated. Set to #' `TRUE` if you intend to use the data in a publication or similar. @@ -104,7 +104,7 @@ atlas_occurrences <- function(request = NULL, identify = NULL, filter = NULL, geolocate = NULL, - data_profile = NULL, + apply_profile = NULL, select = NULL, mint_doi = FALSE, doi = NULL, diff --git a/R/atlas_species.R b/R/atlas_species.R index 1ea38e69..fee1a065 100644 --- a/R/atlas_species.R +++ b/R/atlas_species.R @@ -5,7 +5,7 @@ atlas_species <- function(request = NULL, identify = NULL, filter = NULL, geolocate = NULL, - data_profile = NULL + apply_profile = NULL ) { # capture supplied arguments diff --git a/R/build_query.R b/R/build_query.R index 5b524ff8..0f062990 100644 --- a/R/build_query.R +++ b/R/build_query.R @@ -11,7 +11,7 @@ build_headers <- function(){ build_query <- function(identify = NULL, filter = NULL, location = NULL, - data_profile = NULL) { + apply_profile = NULL) { if(is.null(identify)) { taxa_query <- NULL } else { # assumes a tibble or data.frame has been given @@ -54,8 +54,8 @@ build_query <- function(identify = NULL, } # add profiles information (ALA only) if(profiles_supported()){ - if(!is.null(data_profile)) { - query$qualityProfile <- data_profile + if(!is.null(apply_profile)) { + query$qualityProfile <- apply_profile } else { query$disableAllQualityFilters <- "true" } diff --git a/R/coalesce.R b/R/coalesce.R index 6e860ae3..fd36869b 100644 --- a/R/coalesce.R +++ b/R/coalesce.R @@ -197,7 +197,7 @@ build_query_set_data <- function(x, mint_doi, ...){ } # handle `apply_profile()` - if(!is.null(x$data_profile)){ + if(!is.null(x$apply_profile)){ result[[(length(result) + 1)]] <- request_metadata("profiles") |> as_query() } diff --git a/R/dplyr-arrange.R b/R/dplyr-arrange.R index bec51170..b94ed268 100644 --- a/R/dplyr-arrange.R +++ b/R/dplyr-arrange.R @@ -66,8 +66,8 @@ arrange.data_request <- function(.data, ...){ result <- tibble::tibble(variable = parsed_dots, direction = "ascending") } - .data$arrange <- result - return(.data) + update_request_object(.data, + arrange = result) } } diff --git a/R/dplyr-count.R b/R/dplyr-count.R index 7912bad0..8f7cbe9f 100644 --- a/R/dplyr-count.R +++ b/R/dplyr-count.R @@ -16,8 +16,7 @@ count.data_request <- function(x, sort, name){ count_switch(x) |> - group_by(...) |> - update_request_object(count = TRUE) + group_by(...) } #' Internal function called by `count.data_request()` and `distinct.data_request()` @@ -41,10 +40,6 @@ add_count.data_request <- function(x, wt = NULL, sort = FALSE, name = NULL){ - # note: this function effectively is only used by `atlas_species()`/`distinct()` - # unclear whether this error message will be evaluated properly at this point - if(x$type != "species"){ - cli::cli_abort("`add_count()` is only supported for `type = 'species'`") - } - update_request_object(x, count = TRUE) + update_request_object(x, + add_count = TRUE) } diff --git a/R/galah_apply_profile.R b/R/galah_apply_profile.R index 867b6568..7d0a42f9 100644 --- a/R/galah_apply_profile.R +++ b/R/galah_apply_profile.R @@ -16,7 +16,7 @@ #' @param ... a profile name. Should be a `string` - the name or abbreviation #' of a data quality profile to apply to the query. Valid values can be seen #' using `show_all(profiles)` -#' @return An updated `data_request` with a completed `data_profile` slot. +#' @return An updated `data_request` with a completed `apply_profile` slot. #' @seealso [show_all()] and [search_all()] to look up available data profiles. #' [filter.data_request()] can be used for more bespoke editing of individual data #' profile filters. @@ -36,7 +36,7 @@ apply_profile <- function(.data, ...){ purrr::pluck(!!!list(1)) |> parse_profile() update_request_object(.data, - data_profile = result) + apply_profile = result) } #' @rdname apply_profile @@ -49,7 +49,7 @@ galah_apply_profile <- function(...){ result <- parse_quosures_basic(dots[-1]) |> parse_profile() update_request_object(dots[[1]], - data_profile = result) + apply_profile = result) }, { parse_quosures_basic(dots) |> diff --git a/R/galah_call.R b/R/galah_call.R index e52e5eee..20eb851b 100644 --- a/R/galah_call.R +++ b/R/galah_call.R @@ -134,24 +134,8 @@ request_data <- function(type = c("occurrences", }else{ type <- "occurrences" } - # create an empty list - valid_names <- c("type", - "identify", - "filter", - "select", - "group_by", - "arrange", - "geolocate", - "data_profile" - # "order" # tentatively removed - ) - default_call <- vector(mode = "list", length = length(valid_names)) - names(default_call) <- valid_names - default_call$type <- type # check_type(type) - # set default for limit? - # default_call$limit <- 100 ? - structure(default_call, - class = "data_request") + list(type = type) |> + structure(class = "data_request") } #' @rdname galah_call diff --git a/R/handle_request_objects.R b/R/handle_request_objects.R index 943037b5..48e093ce 100644 --- a/R/handle_request_objects.R +++ b/R/handle_request_objects.R @@ -28,56 +28,53 @@ detect_request_object <- function(dots){ } } -#' Internal function to update a `data_request` +#' Internal function to update a `data_request` or `metadata_request` +#' @param x an object to update +#' @param ... named entry to add #' @noRd #' @keywords Internal -update_request_object <- function(x, ...){ - class_tr <- class(x) +update_request_object <- function(x, + ..., + error_call = rlang::caller_env()){ + # collect supplied information + x_class <- class(x) + x_names <- names(x) dots <- list(...) - if(length(dots)[[1]] == 1){ - if(inherits(dots[[1]], "list") & is.null(names(dots))){ - dots <- dots[[1]] - } + dot_names <- names(dots) + + # ensure only one value is supplied + if(length(dots) > 1){ + cli::cli_warn("Can only update a request with one object at a time; skipping") + x } - result <- purrr::map( - names(x), # i.e. for all slots in object of class `data_request` or `metadata_request` - function(a){ - if(any(names(dots) == a)){ # object is present in `x` - if(is.null(x[[a]])){ # slot in `x` is empty - dots[[a]] - }else{ # slot is filled - if(is.null(dots[[a]])){ # if nothing has been supplied, retain source - x[[a]] - }else{ # both supplied and source contain data - switch(a, - "identify" = { - bind_unique_rows(x[[a]], dots[[a]], "search_term") - }, - "filter" = { - bind_unique_rows(x[[a]], dots[[a]], "query") - }, - "select" = { - update_select(x[[a]], dots[[a]]) - }, - # for below, we assume that in all other circumstances we - # simply pass the most recent result (i.e. overwrite) - dots[[a]] # default - ) - } - } - }else{ # if supplied object is not named in `data_request` - x[[a]] - } - }) - names(result) <- names(x) - - # check if any names in `dots` have been missed from `results` - missing_names <- !(names(dots) %in% names(result)) - if(any(missing_names)){ - result <- append(result, dots[missing_names]) + + # ensure it is named + if(length(dot_names) < 1){ + cli::cli_warn("Error updating `data_request` object - all entries must be named - skipping", + call = error_call) + x + } + + # if this slot is already populated, update or overwrite + if(any(x_names == dot_names)){ + x[dot_names] <- switch(a, + "identify" = { + bind_unique_rows(x[[dot_names]], dots, "search_term") + }, + "filter" = { + bind_unique_rows(x[[dot_names]], dots, "query") + }, + "select" = { + update_select(x[[dot_names]], dots) + }, + # for below, we assume that in all other circumstances we + # simply pass the most recent result (i.e. overwrite) + dots # default + ) + structure(x, class = x_class) + }else{ # if not already present, add to end of object + structure(c(x, dots), class = x_class) } - structure(result, - class = class_tr) } #' Internal function to join together two `select` objects diff --git a/R/print.R b/R/print.R index 49769105..91b40155 100644 --- a/R/print.R +++ b/R/print.R @@ -101,7 +101,7 @@ switch_slot_text <- function(x, a){ }, "select" = x[[a]]$summary, "group_by" = glue::glue_collapse(x[[a]]$name, sep = " | "), - "data_profile" ={x[[a]][1]}, + "apply_profile" ={x[[a]][1]}, "mint_doi" = {x[[a]][1]}, "") } diff --git a/man/apply_profile.Rd b/man/apply_profile.Rd index 1e4ddf0a..db289227 100644 --- a/man/apply_profile.Rd +++ b/man/apply_profile.Rd @@ -17,7 +17,7 @@ of a data quality profile to apply to the query. Valid values can be seen using \code{show_all(profiles)}} } \value{ -An updated \code{data_request} with a completed \code{data_profile} slot. +An updated \code{data_request} with a completed \code{apply_profile} slot. } \description{ A 'profile' is a group of filters that are pre-applied by the ALA. Using a diff --git a/man/atlas_.Rd b/man/atlas_.Rd index e6f95e03..5980ccb8 100644 --- a/man/atlas_.Rd +++ b/man/atlas_.Rd @@ -15,7 +15,7 @@ atlas_occurrences( identify = NULL, filter = NULL, geolocate = NULL, - data_profile = NULL, + apply_profile = NULL, select = NULL, mint_doi = FALSE, doi = NULL, @@ -27,7 +27,7 @@ atlas_counts( identify = NULL, filter = NULL, geolocate = NULL, - data_profile = NULL, + apply_profile = NULL, group_by = NULL, limit = NULL, type = c("occurrences", "species") @@ -38,7 +38,7 @@ atlas_species( identify = NULL, filter = NULL, geolocate = NULL, - data_profile = NULL + apply_profile = NULL ) atlas_media( @@ -47,7 +47,7 @@ atlas_media( filter = NULL, select = NULL, geolocate = NULL, - data_profile = NULL, + apply_profile = NULL, all_fields = FALSE ) @@ -68,7 +68,7 @@ atlas_taxonomy( \item{geolocate}{\code{string}: generated by a call to \code{\link[=galah_geolocate]{galah_geolocate()}}} -\item{data_profile}{\code{string}: generated by a call to \code{\link[=galah_apply_profile]{galah_apply_profile()}}} +\item{apply_profile}{\code{string}: generated by a call to \code{\link[=galah_apply_profile]{galah_apply_profile()}}} \item{select}{\code{tibble}: generated by a call to \code{\link[=galah_select]{galah_select()}}} diff --git a/tests/testthat/test-galah_apply_profile.R b/tests/testthat/test-galah_apply_profile.R index a1babaec..5413ca96 100644 --- a/tests/testthat/test-galah_apply_profile.R +++ b/tests/testthat/test-galah_apply_profile.R @@ -2,7 +2,7 @@ test_that("`apply_profile()` amends a `query", { x <- galah_call() |> filter (year == 2025) |> apply_profile("ALA") - expect_equal(x$data_profile, "ALA") + expect_equal(x$apply_profile, "ALA") }) test_that("`galah_apply_profile()` matches profile", { diff --git a/tests/testthat/test-galah_call.R b/tests/testthat/test-galah_call.R index 5d019068..9b169393 100644 --- a/tests/testthat/test-galah_call.R +++ b/tests/testthat/test-galah_call.R @@ -1,5 +1,3 @@ -## Note: "order" arg was removed from galah_call, adding it back will require updating of these tests - test_that("galah_call builds objects of class 'data_request' by default", { expect_equal(length(galah_call()), 8) expect_s3_class(galah_call(), "data_request") @@ -18,14 +16,23 @@ test_that("galah_call accepts method arg", { test_that("galah_call works with all `galah_` functions", { skip_if_offline(); skip_on_ci() result <- galah_call() |> - galah_identify("Litoria") |> - galah_filter(year == 2021, cl22 == "Tasmania") |> - galah_select(year) |> - galah_apply_profile(ALA) |> - galah_geolocate("POLYGON((143.32 -18.78,145.30 -20.52,141.52 -21.50,143.32 -18.78))") |> - galah_group_by(year, basisOfRecord) |> + identify("Litoria") |> + filter(year == 2021, cl22 == "Tasmania") |> + select(year) |> + apply_profile(ALA) |> + geolocate("POLYGON((143.32 -18.78,145.30 -20.52,141.52 -21.50,143.32 -18.78))") |> + group_by(year, basisOfRecord) |> arrange(basisOfRecord) - expect_false(any(unlist(lapply(result, is.null)))) + # ensure no null values + purrr::map(result, is.null) |> + unlist() |> + any() |> + expect_false() + # ensure content is added in same order as supplied + expect_equal( + names(result), + c("type", "identify", "filter", "select", "apply_profile", + "geolocate", "group_by", "arrange")) }) test_that("galah_call works irrespective of `galah_` function order", { From 197be60c76a664c5246624b7d745699d3f0c967b Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Mon, 5 Jan 2026 16:23:00 +1100 Subject: [PATCH 55/94] Append `request` object to `query` to ensure all info available for later functions (#284) This replaces more piecemeal earlier approach of adding e.g. `filter` and `select` slots after more api-focussed content such as `url` and `header`. Although more consistent it is a big change and will probably introduce small bugs. --- R/as_query-metadata-unnest.R | 4 - R/as_query-metadata.R | 31 ++---- R/as_query-occurrences.R | 14 +-- R/as_query-occurrences_count.R | 47 ++------- R/as_query-species.R | 2 - R/as_query-species_count.R | 20 +--- R/as_query-taxa.R | 2 - R/as_query.R | 96 ++++++++++++++++++- R/check.R | 47 ++++----- R/coalesce.R | 23 ++--- R/collapse_metadata.R | 6 +- R/collapse_occurrences_count_atlas.R | 6 +- R/collapse_query_set.R | 3 +- R/collect_occurrences_count.R | 4 +- R/dplyr-arrange.R | 2 +- R/handle_request_objects.R | 15 ++- R/search_all.R | 11 ++- R/utilities_internal.R | 36 +------ tests/testthat/test-atlas_occurrences.R | 23 +++-- tests/testthat/test-galah_call.R | 2 +- tests/testthat/test-request_metadata_unnest.R | 8 +- tests/testthat/test-search_taxa.R | 4 +- tests/testthat/test-show_values.R | 1 + 23 files changed, 201 insertions(+), 206 deletions(-) diff --git a/R/as_query-metadata-unnest.R b/R/as_query-metadata-unnest.R index b5319040..699587e1 100644 --- a/R/as_query-metadata-unnest.R +++ b/R/as_query-metadata-unnest.R @@ -15,7 +15,6 @@ as_query_fields_unnest <- function(.query){ } list(type = "metadata/fields-unnest", url = httr2::url_build(url)) |> - enforce_select_query(.query) |> as_query() } @@ -34,7 +33,6 @@ as_query_lists_unnest <- function(.query){ # create object list(type = "metadata/lists-unnest", url = httr2::url_build(url)) |> - enforce_select_query(.query) |> as_query() } @@ -46,7 +44,6 @@ as_query_profiles_unnest <- function(.query){ list(type = "metadata/profiles-unnest", url = url_lookup("metadata/profiles-unnest", profile = .query$filter$value[1])) |> - enforce_select_query(.query) |> as_query() } @@ -63,6 +60,5 @@ as_query_taxa_unnest <- function(.query){ list(type = "metadata/taxa-unnest", url = url_lookup("metadata/taxa-unnest", id = id), headers = build_headers()) |> - enforce_select_query(.query) |> as_query() } diff --git a/R/as_query-metadata.R b/R/as_query-metadata.R index e44e77c7..e6f2f3e4 100644 --- a/R/as_query-metadata.R +++ b/R/as_query-metadata.R @@ -42,7 +42,6 @@ filtered_query <- function(query_type, .query){ as_query_apis <- function(x){ list(type = "metadata/apis", data = "galah:::node_config") |> - enforce_select_query(supplied_query = x) |> as_query() } @@ -63,7 +62,6 @@ as_query_assertions <- function(x){ } } result |> - enforce_select_query(supplied_query = x) |> as_query() } @@ -73,7 +71,6 @@ as_query_assertions <- function(x){ as_query_atlases <- function(x){ list(type = "metadata/atlases", data = "galah:::node_metadata") |> - enforce_select_query(supplied_query = x) |> as_query() } @@ -99,7 +96,6 @@ as_query_collections <- function(x){ } } result |> - enforce_select_query(supplied_query = x) |> as_query() } # NOTE: LA collectory functions do not accept `max` or `offset` @@ -141,7 +137,6 @@ as_query_datasets <- function(x){ } } result |> - enforce_select_query(supplied_query = x) |> as_query() } @@ -151,13 +146,10 @@ as_query_datasets <- function(x){ as_query_fields <- function(x){ query_type <- "metadata/fields" if(check_if_cache_update_needed("fields")){ - result <- default_query(query_type) + default_query(query_type) |> as_query() }else{ - result <- default_cache(query_type) + default_cache(query_type) |> as_query() } - result |> - enforce_select_query(supplied_query = x) |> - as_query() } #' Internal function to create a licences query @@ -166,13 +158,10 @@ as_query_fields <- function(x){ as_query_licences <- function(x){ query_type <- "metadata/licences" if(check_if_cache_update_needed("licences")){ - result <- default_query(query_type) + default_query(query_type) |> as_query() }else{ - result <- default_cache(query_type) + default_cache(query_type) |> as_query() } - result |> - enforce_select_query(supplied_query = x) |> - as_query() } #' Internal function to create a lists query @@ -190,8 +179,7 @@ as_query_lists <- function(x, url <- glue::glue("{base_url}/{dr_values}") result <- list(type = query_type, url = tibble::tibble(url = url), # note: tibbles are used to skip pagination in `collapse()` - headers = build_headers(), - slot_name = "lists") + headers = build_headers()) }else{ cli::cli_abort(c("`filter()` arguments to `lists` only accept a data resource number", i = "e.g. request_metadata() |> filter(lists == 'dr656')"), @@ -210,14 +198,12 @@ as_query_lists <- function(x, } result <- list(type = query_type, url = httr2::url_build(url), - headers = build_headers(), - slot_name = "lists") + headers = build_headers()) }else{ result <- default_cache(query_type) } } result |> - enforce_select_query(supplied_query = x) |> as_query() } @@ -259,7 +245,6 @@ as_query_media_metadata <- function(.query, id= .query$filter$value)), headers = build_headers(), filter = .query$filter) |> - enforce_select_query(supplied_query = .query) |> as_query() } @@ -275,7 +260,6 @@ as_query_profiles <- function(x){ result <- default_cache(query_type) } result |> - enforce_select_query(supplied_query = x) |> as_query() } @@ -301,7 +285,6 @@ as_query_providers <- function(x){ } } result |> - enforce_select_query(supplied_query = x) |> as_query() } @@ -316,7 +299,6 @@ as_query_reasons <- function(x){ result <- default_cache(query_type) } result |> - enforce_select_query(supplied_query = x) |> as_query() } @@ -332,6 +314,5 @@ as_query_ranks <- function(x){ data = "galah:::galah_internal_archived$ranks") } result |> - enforce_select_query(supplied_query = x) |> as_query() } \ No newline at end of file diff --git a/R/as_query-occurrences.R b/R/as_query-occurrences.R index de625c5e..394d4806 100644 --- a/R/as_query-occurrences.R +++ b/R/as_query-occurrences.R @@ -44,9 +44,7 @@ as_query_occurrences_uk <- function(.query, ...){ # build output list(type = "data/occurrences", url = httr2::url_build(url), - headers = build_headers(), - filter = .query$filter, - select = .query$select) |> + headers = build_headers()) |> as_query() } @@ -85,12 +83,6 @@ as_query_occurrences_gbif <- function(.query, #' @keywords Internal as_query_occurrences_la <- function(.query, mint_doi = FALSE){ - - # set default columns - if(is.null(.query$select)){ - .query <- .query |> select(group = "basic") - } - # build a query query <- c(build_query(identify = .query$identify, filter = .query$filter, @@ -115,8 +107,6 @@ as_query_occurrences_la <- function(.query, # build output list(type = "data/occurrences", url = httr2::url_build(url), - headers = build_headers(), - filter = .query$filter, - select = .query$select) |> + headers = build_headers()) |> as_query() } diff --git a/R/as_query-occurrences_count.R b/R/as_query-occurrences_count.R index 33b353fb..60407045 100644 --- a/R/as_query-occurrences_count.R +++ b/R/as_query-occurrences_count.R @@ -24,8 +24,7 @@ as_query_occurrences_count_atlas <- function(identify = NULL, geolocate = NULL, apply_profile = NULL, group_by = NULL, - slice = NULL, - arrange = NULL + slice_arrange = NULL ){ query <- build_query(identify, filter, @@ -38,32 +37,17 @@ as_query_occurrences_count_atlas <- function(identify = NULL, url$query <- c(query, pageSize = 0) result <- list(type = "data/occurrences-count", url = httr2::url_build(url), - headers = build_headers(), - filter = filter, - slot_name = "totalRecords") + headers = build_headers()) }else{ url <- url_lookup("data/occurrences-count-groupby") |> httr2::url_parse() facets <- as.list(group_by$name) names(facets) <- rep("facets", length(facets)) - if(is.null(slice)){ - # limits to 10,000 rows - # TODO: This should ultimately be set by `slice` or `atlas_counts(limit = )`, not internally. - # Will need updating to avoid hidden limit setting here & in `compute_occurrences_count()` - slice <- tibble::tibble(slice_n = 1e4, slice_called = FALSE) - } - if(is.null(arrange)){ - arrange <- tibble::tibble(variable = "count", - direction = "descending") - } - slice_arrange <- dplyr::bind_cols(slice, arrange) - arrange_list <- check_slice_arrange(slice_arrange) - url$query <- c(query, facets, arrange_list) + + url$query <- c(query, facets, parse_slice_arrange(slice_arrange)) result <- list(type = "data/occurrences-count-groupby", url = httr2::url_build(url), - headers = build_headers(), - filter = filter, - arrange = slice_arrange) + headers = build_headers()) } as_query(result) } @@ -75,7 +59,7 @@ as_query_occurrences_count_gbif <- function(identify = NULL, filter = NULL, geolocate = NULL, group_by = NULL, - slice = NULL + slice = NULL # probably broken ){ # compile supplied arguments into a list # honestly this is a little messy, but the alternative is to call @@ -119,22 +103,3 @@ as_query_occurrences_count_gbif <- function(identify = NULL, slot_name = "count") |> as_query() } - -#' Internal function to check `slice` and `arrange` for counts -#' @keywords Internal -#' @noRd -check_slice_arrange <- function(df){ - if(df$variable == "count"){ # arranged in descending order by default - if(df$direction == "ascending"){ - list(fsort = "count", flimit = 0) - }else{ - list(fsort = "count", flimit = df$slice_n) - } - }else{ # non-count fields are arranged in ascending order by default - if(df$direction == "ascending"){ - list(fsort = "index", flimit = df$slice_n) - }else{ - list(fsort = "index", flimit = 0) - } - } -} diff --git a/R/as_query-species.R b/R/as_query-species.R index 8a110d45..2984f451 100644 --- a/R/as_query-species.R +++ b/R/as_query-species.R @@ -48,8 +48,6 @@ as_query_species_atlas <- function(.query){ list(type = "data/species", url = httr2::url_build(url), headers = build_headers(), - filter = .query$filter, - group_by = .query$group_by, download = TRUE) |> as_query() } diff --git a/R/as_query-species_count.R b/R/as_query-species_count.R index 28d900d1..73ba2f4e 100644 --- a/R/as_query-species_count.R +++ b/R/as_query-species_count.R @@ -24,8 +24,7 @@ as_query_species_count_atlas <- function(identify = NULL, geolocate = NULL, apply_profile = NULL, group_by = NULL, - slice = NULL, - arrange = NULL + slice_arrange = NULL ){ url <- url_lookup("data/species-count") |> httr2::url_parse() @@ -40,25 +39,14 @@ as_query_species_count_atlas <- function(identify = NULL, facets = species_facets())) result <- list(type = "data/species-count", url = httr2::url_build(url), - headers = build_headers(), - filter = filter) + headers = build_headers()) }else{ facets <- c(as.list(group_by$name), species_facets()) names(facets) <- rep("facets", length(facets)) - if(is.null(slice)){ - slice <- tibble::tibble(slice_n = 30, slice_called = FALSE) - } - if(is.null(arrange)){ - arrange <- tibble::tibble(variable = "count", direction = "descending") - } - slice_arrange <- dplyr::bind_cols(slice, arrange) - arrange_list <- check_slice_arrange(slice_arrange) - url$query <- c(query, facets, arrange_list) + url$query <- c(query, facets, slice_arrange) result <- list(type = "data/species-count", url = httr2::url_build(url), - headers = build_headers(), - filter = filter, - arrange = slice_arrange) + headers = build_headers()) } as_query(result) } diff --git a/R/as_query-taxa.R b/R/as_query-taxa.R index a0b4ea17..947d59cc 100644 --- a/R/as_query-taxa.R +++ b/R/as_query-taxa.R @@ -13,7 +13,6 @@ as_query_taxa <- function(.query){ } } result |> - enforce_select_query(supplied_query = .query) |> as_query() } @@ -96,7 +95,6 @@ as_query_identifiers <- function(.query){ url = tibble::tibble(url = urls, search_term = search_terms), headers = build_headers()) |> - enforce_select_query(supplied_query = .query) |> as_query() } diff --git a/R/as_query.R b/R/as_query.R index 2fe15fcb..38424442 100644 --- a/R/as_query.R +++ b/R/as_query.R @@ -60,8 +60,10 @@ as_query.data_request <- function(x, mint_doi = FALSE, ...){ x <- x |> + enforce_select_query() |> check_authentication() |> - check_distinct() + check_distinct() |> + check_slice_arrange() switch(x$type, "occurrences" = as_query_occurrences(x, mint_doi = mint_doi), "occurrences-count" = as_query_occurrences_count(x), @@ -112,12 +114,59 @@ check_distinct <- function(x){ } } +#' Internal function to check `slice` and `arrange` for counts +#' @keywords Internal +#' @noRd +check_slice_arrange <- function(x){ + if(is.null(x$count)){ + x + }else{ + if(is.null(x$slice)){ + # limits to 10,000 rows + # TODO: This should ultimately be set by `slice` or `atlas_counts(limit = )`, not internally. + # Will need updating to avoid hidden limit setting here & in `compute_occurrences_count()` + slice <- tibble::tibble(slice_n = 1e4, slice_called = FALSE) + }else{ + slice <- x$slice + } + if(is.null(x$arrange)){ + arrange <- tibble::tibble(variable = "count", + direction = "descending") + }else{ + arrange <- x$arrange + } + x$slice_arrange <- dplyr::bind_cols(slice, arrange) + x$arrange <- NULL + x$slice <- NULL + x + } +} + +#' Internal function to parse `slice` and `arrange` for counts +#' @keywords Internal +#' @noRd +parse_slice_arrange <- function(df){ + if(df$variable == "count"){ # arranged in descending order by default + if(df$direction == "ascending"){ + list(fsort = "count", flimit = 0) + }else{ + list(fsort = "count", flimit = df$slice_n) + } + }else{ # non-count fields are arranged in ascending order by default + if(df$direction == "ascending"){ + list(fsort = "index", flimit = df$slice_n) + }else{ + list(fsort = "index", flimit = 0) + } + } +} #' @rdname as_query.data_request #' @order 3 #' @export as_query.metadata_request <- function(x, ...){ - x <- check_authentication(x) + x <- check_authentication(x) |> + enforce_select_query() switch(x$type, "apis" = as_query_apis(x), "assertions" = as_query_assertions(x), @@ -145,6 +194,49 @@ as_query.metadata_request <- function(x, ...){ add_request(x) } +#' Internal function to enforce `select()` for metadata queries. Basically just +#' supplies defaults. This is the *setup* phase as is usually called by +#' `as_query()` +#' @noRd +#' @keywords Internal +enforce_select_query <- function(x){ + if(inherits(x, "metadata_request")){ + # if `select()` is given, we simply pass it on + # if missing, we have to apply some logic + if(is.null(x$select)){ + specific_type <- x |> + purrr::pluck("type") |> + stringr::str_remove("^metadata/") + # see whether `lookup_select_columns()` returns anything + chosen_columns <- lookup_select_columns(specific_type) + # some `unnest` queries internally rename the lead column to the name of the supplied field + if(is.null(chosen_columns) & + stringr::str_detect(specific_type, "-unnest$")){ + chosen_columns <- x$filter |> + purrr::pluck("value") + } + # if we have, after 2 attempts, found some chosen_columns, use them + if(!is.null(chosen_columns)){ + x <- dplyr::select(x, + tidyselect::any_of({{chosen_columns}})) + # if *still* null, choose `everything()` + }else{ + x <- dplyr::select(x, + tidyselect::everything()) + } + } + }else if(inherits(x, "data_request")){ + # `select()` is only needed if DOI is not requested + is_doi <- ifelse(is.null(x$filter), + FALSE, + {ifelse(x$filter$variable[1] == "doi", TRUE, FALSE)}) + if(is.null(x$select) & !is_doi){ + x <- x |> select(group = "basic") + } + } + x +} + #' @noRd #' @keywords Internal add_request <- function(new_obj, source_obj){ diff --git a/R/check.R b/R/check.R index c9749902..24b01515 100644 --- a/R/check.R +++ b/R/check.R @@ -2,28 +2,30 @@ #' Called exclusively by `atlas_` functions #' @noRd #' @keywords Internal -check_atlas_inputs <- function(args){ +check_atlas_inputs <- function(args, + error_call = rlang::caller_env()){ if(!is.null(args$request)){ - check_data_request(args$request) - update_request_object(args$request, args[-1]) + if(!inherits(args$request, "data_request")){ + c("Argument `.query` requires an object of type `data_request`.", + i = "You can create this object using `galah_call()`.", + i = "Did you specify the incorrect argument?") |> + cli::cli_abort(call = error_call) + } + request_obj <- args$request }else{ - galah_call() |> - update_request_object(args[-1]) + request_obj <- galah_call() + } + added_arguments <- args[-1] + added_arguments <- added_arguments[!(purrr::map(added_arguments, is.null) |> unlist())] + if(length(added_arguments) > 0){ + for(i in seq_along(added_arguments)){ + request_object <- do.call(update_request_object, + append(list(x = request_obj), added_arguments[i])) + } } + request_object } -#' Internal function to check for `data_request`s -#' @noRd -#' @keywords Internal -check_data_request <- function(request, - error_call = rlang::caller_env()){ - if(!inherits(request, "data_request")){ - c("Argument `.query` requires an object of type `data_request`.", - i = "You can create this object using `galah_call()`.", - i = "Did you specify the incorrect argument?") |> - cli::cli_abort(call = error_call) - } -} #' Internal function to check that the specified path exists, and if not, #' to create it. Called by `galah_config()` @@ -723,7 +725,7 @@ check_reason <- function(.query, #' @keywords Internal check_select <- function(.query, error_call = rlang::caller_env()){ - if(any(names(.query) == "select")){ + if(any(names(.query$request) == "select")){ if(is_gbif() & stringr::str_detect(.query$type, "^data")){ cli::cli({ cli::cli_text("Skipping `select()`.") @@ -739,7 +741,7 @@ check_select <- function(.query, as.data.frame() # 2. parse groups - group_initial <- .query$select$group + group_initial <- .query$request$select$group # new step to avoid calling `show_all_assertions()` internally group <- group_initial[group_initial != "assertions"] if(length(group) > 0){ @@ -755,8 +757,8 @@ check_select <- function(.query, } # 3. parse quosures to get list of field names - if(length(.query$select$quosure) > 0){ - dot_names <- purrr::map(.query$select$quosure, + if(length(.query$request$select$quosure) > 0){ + dot_names <- purrr::map(.query$request$select$quosure, function(a){ tidyselect::eval_select(a, data = df, @@ -826,13 +828,12 @@ check_select <- function(.query, field_text <- glue::glue_collapse(field_values[!is_assertion], sep = ",") - # 7. replace `SELECT_PLACEHOLDER` with valid query + # 7. replace `SELECT_PLACEHOLDER` and `ASSERTIONS_PLACEHOLDER` with valid queries # located in .query$url in query/fields url <- httr2::url_parse(.query$url) # note: this assumes a single url every time url$query$fields <- field_text url$query$qa <- assertion_text .query$url <- httr2::url_build(url) - .query$select <- NULL } } .query diff --git a/R/coalesce.R b/R/coalesce.R index fd36869b..0cec8b14 100644 --- a/R/coalesce.R +++ b/R/coalesce.R @@ -46,7 +46,6 @@ coalesce.data_request <- function(x, mint_doi, ...){ #' @order 3 #' @export coalesce.metadata_request <- function(x, ...){ - # create an empty object to store results result <- list() @@ -70,7 +69,10 @@ coalesce.metadata_request <- function(x, ...){ if(x$type == "taxa-unnest"){ # identify() calls must be parsed, irrespective of `run_checks` (which is parsed above) if(!is.null(x$identify)){ - result[[(length(result) + 1)]] <- as_query_taxa(x) # best syntax for this?? + result[[(length(result) + 1)]] <- list(type = "taxa", + identify = x$identify) |> + structure(class = "metadata_request") |> + as_query() } if(is.null(x$identify) & is.null(x$filter)){ cli::cli_abort("Requests of type `taxa-unnest` must also supply one of `filter()` or `identify()`.") @@ -81,13 +83,8 @@ coalesce.metadata_request <- function(x, ...){ } } - # lists have extra steps - if(x$type == "lists-unnest"){ - query_obj <- as_query_lists_unnest(x, ...) - }else{ - query_obj <- as_query(x) - } - result[[(length(result) + 1)]] <- query_obj + # add query in last place + result[[(length(result) + 1)]] <- as_query(x) # return object of correct class structure(result, @@ -151,10 +148,10 @@ build_query_set_data <- function(x, mint_doi, ...){ } # handle `run_checks` - fields_absent <- purrr::map( - x[c("arrange", "filter", "select", "group_by")], - is.null) |> - unlist() + # find which functions are missing from the pipe + lookup_fields <- c("arrange", "filter", "select", "group_by") + fields_absent <- !(lookup_fields %in% names(x)) + names(fields_absent) <- lookup_fields if(potions::pour("package", "run_checks") & x$type != "occurrences-doi"){ diff --git a/R/collapse_metadata.R b/R/collapse_metadata.R index 09fc6439..b280f06e 100644 --- a/R/collapse_metadata.R +++ b/R/collapse_metadata.R @@ -51,13 +51,14 @@ get_max_n <- function(.query){ paginate = 500, max_available = { url$query <- list(max = 0) - list(url = httr2::url_build(url), + list(type = "metadata/list-count", + url = httr2::url_build(url), headers = .query$headers) |> query_API() |> purrr::pluck(count_field) # NOTE: only tested for ALA }) n$max_requested <- min(c(n$requested, n$max_available)) - return(n) + n } @@ -80,7 +81,6 @@ collapse_profile_values <- function(.query, } list(type = .query$type, url = httr2::url_build(url)) |> - enforce_select_query(.query) |> as_query() } # this doesn't print for some reason diff --git a/R/collapse_occurrences_count_atlas.R b/R/collapse_occurrences_count_atlas.R index 0e78ad82..355827f4 100644 --- a/R/collapse_occurrences_count_atlas.R +++ b/R/collapse_occurrences_count_atlas.R @@ -12,9 +12,9 @@ collapse_occurrences_count_atlas_basic <- function(.query){ # handle slice_head if(as.integer(url$query$flimit) < 1){ - url$query$flimit <- .query$arrange$slice_n # Q: is this correct? - if(.query$arrange$slice_n < n_facets){ - url$query$foffset <- n_facets - .query$arrange$slice_n + url$query$flimit <- .query$request$slice_arrange$slice_n # Q: is this correct? + if(.query$request$slice_arrange$slice_n < n_facets){ + url$query$foffset <- n_facets - .query$request$slice_arrange$slice_n } .query$url <- httr2::url_build(url) .query diff --git a/R/collapse_query_set.R b/R/collapse_query_set.R index c1a77abe..0562fcf9 100644 --- a/R/collapse_query_set.R +++ b/R/collapse_query_set.R @@ -35,7 +35,8 @@ collapse_query_set <- function(x, "data/species-count" = collapse_species_count(x), # "-unnest" functions require some checks "metadata/profiles-unnest" = collapse_profile_values(x, - error_call = error_call), + error_call = error_call) |> + add_request(x$request), # some "metadata/" functions require pagination under some circumstances "metadata/lists" = collapse_lists(x), # always paginates x # remaining "metadata/" functions are passed as-is diff --git a/R/collect_occurrences_count.R b/R/collect_occurrences_count.R index 14075259..8446cc9e 100644 --- a/R/collect_occurrences_count.R +++ b/R/collect_occurrences_count.R @@ -77,8 +77,8 @@ collect_occurrences_count_la <- function(.query){ clean_group_by(result, .query) |> dplyr::bind_rows() |> clean_labels() |> - arrange_counts(direction = .query$arrange$direction, - variable = .query$arrange$variable) + arrange_counts(direction = .query$request$slice_arrange$direction, + variable = .query$request$slice_arrange$variable) } } diff --git a/R/dplyr-arrange.R b/R/dplyr-arrange.R index b94ed268..3f321e0e 100644 --- a/R/dplyr-arrange.R +++ b/R/dplyr-arrange.R @@ -54,7 +54,7 @@ arrange.data_request <- function(.data, ...){ parsed_dots <- purrr::map(dots, \(a){ switch(expr_type(a), "symbol" = {rlang::as_label(a)}, - "call" = {purrr::map(rlang::quo_get_expr(a), as_string)}, + "call" = {purrr::map(rlang::quo_get_expr(a), rlang::as_string)}, "literal" = {rlang::quo_get_expr(a)}, cli::cli_abort("Quosure type not recognised.", call = rlang::caller_env()))}) |> diff --git a/R/handle_request_objects.R b/R/handle_request_objects.R index 48e093ce..cb242b6c 100644 --- a/R/handle_request_objects.R +++ b/R/handle_request_objects.R @@ -57,19 +57,24 @@ update_request_object <- function(x, # if this slot is already populated, update or overwrite if(any(x_names == dot_names)){ - x[dot_names] <- switch(a, + x[[dot_names]] <- switch(dot_names, "identify" = { - bind_unique_rows(x[[dot_names]], dots, "search_term") + bind_unique_rows(x[[dot_names]], + dots[[dot_names]], + "search_term") }, "filter" = { - bind_unique_rows(x[[dot_names]], dots, "query") + bind_unique_rows(x[[dot_names]], + dots[[dot_names]], + "query") }, "select" = { - update_select(x[[dot_names]], dots) + update_select(x[[dot_names]], + dots[[dot_names]]) }, # for below, we assume that in all other circumstances we # simply pass the most recent result (i.e. overwrite) - dots # default + dots[[dot_names]] # default ) structure(x, class = x_class) }else{ # if not already present, add to end of object diff --git a/R/search_all.R b/R/search_all.R index 683d1c6e..b4617745 100644 --- a/R/search_all.R +++ b/R/search_all.R @@ -170,15 +170,24 @@ search_all <- function(type, #' @keywords Internal search_text_cols <- function(df, query){ + if(nrow(df) < 1){ + return(df) + } + query <- tolower(query) keep_cols <- unlist(purrr::map(df, is.character)) & colnames(df) != "type" + check_list <- purrr::map(df[, keep_cols], \(a){stringr::str_detect(tolower(a), query)}) + + if(length(check_list) < 1){ + return(df) + } + check_vector <- purrr::list_transpose(check_list) |> purrr::map(any) |> unlist() - result <- df |> dplyr::filter({{check_vector}}) # order search_all() results diff --git a/R/utilities_internal.R b/R/utilities_internal.R index 95150897..04feb5ec 100644 --- a/R/utilities_internal.R +++ b/R/utilities_internal.R @@ -2,40 +2,6 @@ ## Output formatting functions -- ##--------------------------------------------------------------- -#' Internal function to enforce `select()` for metadata queries. Basically just -#' supplies defaults. This is the *setup* phase as is usually called by -#' `as_query()` -#' @noRd -#' @keywords Internal -enforce_select_query <- function(new_query, supplied_query){ - # if `select()` is given, we simply pass it on - # if missing, we have to apply some logic - if(is.null(supplied_query$select)){ - specific_type <- supplied_query |> - purrr::pluck("type") |> - stringr::str_remove("^metadata/") - # see whether `lookup_select_columns()` returns anything - chosen_columns <- lookup_select_columns(specific_type) - # some `unnest` queries internally rename the lead column to the name of the supplied field - if(is.null(chosen_columns) & - stringr::str_detect(specific_type, "-unnest$")){ - chosen_columns <- supplied_query$filter |> - purrr::pluck("value") - } - # if we have, after 2 attempts, found some chosen_columns, use them - if(!is.null(chosen_columns)){ - supplied_query <- dplyr::select(supplied_query, - tidyselect::any_of({{chosen_columns}})) - # if *still* null, choose `everything()` - }else{ - supplied_query <- dplyr::select(supplied_query, - tidyselect::everything()) - } - } - update_request_object(new_query, - select = supplied_query$select) -} - #' Internal function to run `eval_tidy()` on captured `select()` requests. #' This is the *enactment* phase and is usually called by `collect()`. #' Critically, this function is *NOT* called by `select()`. This matters because @@ -45,7 +11,7 @@ enforce_select_query <- function(new_query, supplied_query){ #' @keywords Internal parse_select <- function(df, .query){ # get quosures captured by `select()` - quo_list <- purrr::pluck(.query, "select", "quosure") + quo_list <- purrr::pluck(.query, "request", "select", "quosure") # map() over list of quosures # honestly I don't know why `!!quo_list` fails here, but it does, so used this instead pos <- purrr::map(quo_list, \(a){ diff --git a/tests/testthat/test-atlas_occurrences.R b/tests/testthat/test-atlas_occurrences.R index 1d8a3717..394e7843 100644 --- a/tests/testthat/test-atlas_occurrences.R +++ b/tests/testthat/test-atlas_occurrences.R @@ -47,6 +47,7 @@ test_that("`atlas_occurrences()` gives a nice error for invalid emails", { test_that("collapse(type = 'occurrences') creates an object", { skip_if_offline(); skip_on_ci() + galah_config(email = "ala4r@ala.org.au") result <- galah_call() |> identify("Perameles") |> collapse() @@ -57,6 +58,7 @@ test_that("collapse(type = 'occurrences') creates an object", { test_that("`compute(type = 'occurrences')` works", { skip_if_offline(); skip_on_ci() + galah_config(email = "ala4r@ala.org.au") base_query <- galah_call() |> identify("Vulpes vulpes") |> filter(year <= 1900, @@ -78,12 +80,14 @@ test_that("`compute(type = 'occurrences')` works", { "status_url", "cancel_url", "search_url", - "fields")) + "fields", + "request")) }) # test all filters and type of columns in one call test_that("`atlas_occurrences()` accepts all narrowing functions inline", { skip_if_offline(); skip_on_ci() + galah_config(email = "ala4r@ala.org.au") expected_cols <- c("decimalLatitude", "decimalLongitude", "eventDate", "basisOfRecord", "scientificName", "taxonConceptID", "recordID", "dataResourceName", "occurrenceStatus", @@ -105,7 +109,8 @@ test_that("`atlas_occurrences()` accepts all narrowing functions inline", { "status_url", "cancel_url", "search_url", - "fields")) + "fields", + "request")) expect_s3_class(x, "computed_query") # collect with wait = TRUE y <- quiet_collect(x, wait = TRUE) @@ -141,12 +146,13 @@ test_that("`atlas_occurrences()`() and friends accept a file name", { directory <- "TEMP" unlink(directory, recursive = TRUE) dir.create(directory) - galah_config(directory = directory) + galah_config(directory = directory, + email = "ala4r@ala.org.au") # set up query base_query <- galah_call() |> - galah_filter(year <= 1970) |> - galah_select(group = "basic") |> - galah_identify("Crinia tinnula") + filter(year <= 1970) |> + select(group = "basic") |> + identify("Crinia tinnula") # base_query |> count() |> collect() # n = 49 on 2023-11-15 # test `atlas_occurrences` occ1 <- base_query |> @@ -192,7 +198,9 @@ test_that("`atlas_occurrences()` downloads data from a DOI", { result2 <- request_data() |> filter(doi == doi) |> quiet_collapse() - expect_equal(length(result2), 4) + expect_equal(length(result2), 5) + expect_equal(names(result2), + c("type", "url", "headers", "download", "request")) expect_s3_class(result2, "query") expect_equal(result2$type, "data/occurrences-doi") result3 <- quiet_collect(result2) @@ -202,6 +210,7 @@ test_that("`atlas_occurrences()` downloads data from a DOI", { # TODO check DOIs still placed correctly in as_query(), collapse() etc +# NOTE: This test is *very* slow - worth investigating why test_that("`atlas_occurrences()` places DOI in `attr()` correctly", { skip_if_offline(); skip_on_ci() directory <- "TEMP" diff --git a/tests/testthat/test-galah_call.R b/tests/testthat/test-galah_call.R index 9b169393..edd5ade8 100644 --- a/tests/testthat/test-galah_call.R +++ b/tests/testthat/test-galah_call.R @@ -1,5 +1,5 @@ test_that("galah_call builds objects of class 'data_request' by default", { - expect_equal(length(galah_call()), 8) + expect_equal(length(galah_call()), 1) expect_s3_class(galah_call(), "data_request") }) diff --git a/tests/testthat/test-request_metadata_unnest.R b/tests/testthat/test-request_metadata_unnest.R index ca1b3ddd..2444ff1a 100644 --- a/tests/testthat/test-request_metadata_unnest.R +++ b/tests/testthat/test-request_metadata_unnest.R @@ -61,7 +61,7 @@ test_that("request_metadata() |> unnest() works for type = 'lists'", { expect_s3_class(x, "query") expect_equal(x$type, "metadata/lists-unnest") expect_equal(names(x), - c("type", "url", "select")) + c("type", "url", "request")) y <- compute(x) expect_s3_class(y, "computed_query") z <- collect(y) @@ -95,7 +95,7 @@ test_that("`request_metadata() |> unnest() |> collapse()` works for type = profi expect_s3_class(x, "query") expect_equal(x$type, "metadata/profiles-unnest") expect_equal(names(x), - c("type", "url", "select")) + c("type", "url", "request")) }) test_that("request_metadata() |> unnest() works for type = 'profiles'", { @@ -132,7 +132,7 @@ test_that("request_metadata() |> unnest() works for type = 'taxa' using `identif collapse() expect_s3_class(x, "query") expect_equal(length(x), 4) - expect_equal(names(x), c("type", "url", "headers", "select")) + expect_equal(names(x), c("type", "url", "headers", "request")) expect_equal(x$type, "metadata/taxa-unnest") y <- compute(x) expect_s3_class(y, "computed_query") @@ -151,7 +151,7 @@ test_that("request_metadata() |> unnest() works for type = 'taxa' using `filter( collapse() expect_s3_class(x, "query") expect_equal(length(x), 4) - expect_equal(names(x), c("type", "url", "headers", "select")) + expect_equal(names(x), c("type", "url", "headers", "request")) expect_equal(x$type, "metadata/taxa-unnest") y <- compute(x) expect_s3_class(y, "computed_query") diff --git a/tests/testthat/test-search_taxa.R b/tests/testthat/test-search_taxa.R index 59094c0c..5ff37992 100644 --- a/tests/testthat/test-search_taxa.R +++ b/tests/testthat/test-search_taxa.R @@ -134,8 +134,6 @@ test_that("`search_identifiers()` works via `search_all()`", { id <- "urn:lsid:biodiversity.org.au:afd.taxon:08b9a1f0-62ae-45ca-9208-e773b00021ed" search <- search_identifiers(id) search2 <- search_all(identifiers, id) - - expect_equal(attributes(search)$call, "identifiers") expect_s3_class(search, c("tbl_df", "tbl", "data.frame")) expect_equal(nrow(search), 1) expect_equal(search, search2) @@ -150,7 +148,7 @@ test_that("`request_metadata()` works for `type = 'taxa'`", { expect_equal(x$identify$search_term, "crinia") y <- collapse(x) expect_s3_class(y, "query") - expect_equal(names(y), c("type", "url", "headers")) + expect_equal(names(y), c("type", "url", "headers", "request")) z <- collect(y) expect_s3_class(z, c("tbl_df", "tbl", "data.frame")) expect_equal(nrow(z), 1) diff --git a/tests/testthat/test-show_values.R b/tests/testthat/test-show_values.R index 0dbcaa88..b020d91b 100644 --- a/tests/testthat/test-show_values.R +++ b/tests/testthat/test-show_values.R @@ -83,6 +83,7 @@ test_that("`search_values()` returns filtered results for lists", { # use more efficient syntax base_df <- request_metadata() |> filter(lists == "dr650") |> + unnest() |> collect() values_search <- base_df |> quiet_search("frog") values_show <- base_df |> quiet_values() From 5009b8f36a262b915c6e1cbe353eda347e430dac Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Tue, 6 Jan 2026 17:00:11 +1100 Subject: [PATCH 56/94] More progress implementing `distinct()` (#284) - rewritten parser for distinct, group_by etc with `as_query()` - major progress is grouped species counts are working now - some minor bug fixes, but still work remaining --- R/as_query-occurrences_count.R | 20 +- R/as_query-species_count.R | 4 +- R/as_query.R | 327 ++++++++++-------- R/collapse_checks.R | 16 +- R/collapse_query_set.R | 1 + R/collapse_species_count.R | 70 +--- R/collect_metadata.R | 7 +- R/collect_occurrences_count.R | 10 +- R/dplyr-collect.R | 1 + R/dplyr-count.R | 19 +- man/as_query.data_request.Rd | 2 +- tests/testthat/test-dplyr-count.R | 39 +-- tests/testthat/test-dplyr-distinct.R | 54 +-- tests/testthat/test-request_metadata_select.R | 75 ++-- tests/testthat/test-show_values.R | 13 +- 15 files changed, 316 insertions(+), 342 deletions(-) diff --git a/R/as_query-occurrences_count.R b/R/as_query-occurrences_count.R index 60407045..e331f908 100644 --- a/R/as_query-occurrences_count.R +++ b/R/as_query-occurrences_count.R @@ -43,7 +43,6 @@ as_query_occurrences_count_atlas <- function(identify = NULL, httr2::url_parse() facets <- as.list(group_by$name) names(facets) <- rep("facets", length(facets)) - url$query <- c(query, facets, parse_slice_arrange(slice_arrange)) result <- list(type = "data/occurrences-count-groupby", url = httr2::url_build(url), @@ -52,6 +51,25 @@ as_query_occurrences_count_atlas <- function(identify = NULL, as_query(result) } +#' Internal function to parse `slice` and `arrange` for counts +#' @keywords Internal +#' @noRd +parse_slice_arrange <- function(df){ + if(df$variable == "count"){ # arranged in descending order by default + if(df$direction == "ascending"){ + list(fsort = "count", flimit = 0) + }else{ + list(fsort = "count", flimit = df$slice_n) + } + }else{ # non-count fields are arranged in ascending order by default + if(df$direction == "ascending"){ + list(fsort = "index", flimit = df$slice_n) + }else{ + list(fsort = "index", flimit = 0) + } + } +} + #' collapse for counts on GBIF #' @keywords Internal #' @noRd diff --git a/R/as_query-species_count.R b/R/as_query-species_count.R index 73ba2f4e..b39a9b34 100644 --- a/R/as_query-species_count.R +++ b/R/as_query-species_count.R @@ -43,8 +43,8 @@ as_query_species_count_atlas <- function(identify = NULL, }else{ facets <- c(as.list(group_by$name), species_facets()) names(facets) <- rep("facets", length(facets)) - url$query <- c(query, facets, slice_arrange) - result <- list(type = "data/species-count", + url$query <- c(query, facets, parse_slice_arrange(slice_arrange)) + result <- list(type = "data/species-count-groupby", url = httr2::url_build(url), headers = build_headers()) } diff --git a/R/as_query.R b/R/as_query.R index 38424442..5d5b1114 100644 --- a/R/as_query.R +++ b/R/as_query.R @@ -39,7 +39,7 @@ #' - `headers`: headers to be sent with the API call #' - `body`: body section of the API call #' - `options`: options section of the API call -#' - Any other information retained from the preceeding `_request` object (see [galah_call()]) +#' - `request`: captures the preceeding `_request` object (see [galah_call()]) #' #' @seealso To open a piped query, see [galah_call()]. For alternative #' operations on `_request` objects, see [coalesce()], @@ -60,10 +60,10 @@ as_query.data_request <- function(x, mint_doi = FALSE, ...){ x <- x |> - enforce_select_query() |> check_authentication() |> - check_distinct() |> - check_slice_arrange() + check_distinct_count_groupby() |> + check_slice_arrange() |> + enforce_select_query() switch(x$type, "occurrences" = as_query_occurrences(x, mint_doi = mint_doi), "occurrences-count" = as_query_occurrences_count(x), @@ -75,92 +75,6 @@ as_query.data_request <- function(x, add_request(x) } - -#' Internal function to check behaviour of `distinct()`, `group_by()` etc. -#' called by `as_query()` -#' @noRd -#' @keywords Internal -check_distinct <- function(x){ - - # 1. no distinct() call = no changes (regardless of group_by) - if(is.null(x$distinct)){ - x - # 2. no args to group_by(), no args to distinct() = no changes - }else if(is.na(x$distinct$name) & is.null(x$group_by)){ - x$distinct <- NULL - x - # 3. args to group_by() but not to distinct(), keep_all is FALSE = switch to counts - }else if(!is.null(x$group_by) & is.na(x$distinct$name) & isFALSE(x$distinct$keep_all)){ - count_switch(x) - # 4. args to group_by() but not distinct(), keep_all is TRUE = switch to species - }else if(!is.null(x$group_by) & is.na(x$distinct$name) & isTRUE(x$distinct$keep_all)){ - x$type <- "species" - x - # 5. no args to group_by(), args to distinct(), keep_all is FALSE = switch to counts - }else if(is.null(x$group_by) & !is.na(x$distinct$name) & isFALSE(x$distinct$keep_all)){ - count_switch(x) - # 6. no args to group_by, args to distinct, keep_all is TRUE = switch to species - }else if(is.null(x$group_by) & !is.na(x$distinct$name) & isTRUE(x$distinct$keep_all)){ - x$type <- "species" - x - # 7. args to group_by AND distinct, keep_all is FALSE = switch to species counts - }else if(!is.null(x$group_by) & !is.na(x$distinct$name) & isTRUE(x$distinct$keep_all)){ - x$type <- "species-count" - x - # 8. args to both group_by AND distinct, keep_all is TRUE = switch to species, prioritizing distinct() - }else if(!is.null(x$group_by) & !is.na(x$distinct$name) & isTRUE(x$distinct$keep_all)){ - x$type <- "species" - x - } -} - -#' Internal function to check `slice` and `arrange` for counts -#' @keywords Internal -#' @noRd -check_slice_arrange <- function(x){ - if(is.null(x$count)){ - x - }else{ - if(is.null(x$slice)){ - # limits to 10,000 rows - # TODO: This should ultimately be set by `slice` or `atlas_counts(limit = )`, not internally. - # Will need updating to avoid hidden limit setting here & in `compute_occurrences_count()` - slice <- tibble::tibble(slice_n = 1e4, slice_called = FALSE) - }else{ - slice <- x$slice - } - if(is.null(x$arrange)){ - arrange <- tibble::tibble(variable = "count", - direction = "descending") - }else{ - arrange <- x$arrange - } - x$slice_arrange <- dplyr::bind_cols(slice, arrange) - x$arrange <- NULL - x$slice <- NULL - x - } -} - -#' Internal function to parse `slice` and `arrange` for counts -#' @keywords Internal -#' @noRd -parse_slice_arrange <- function(df){ - if(df$variable == "count"){ # arranged in descending order by default - if(df$direction == "ascending"){ - list(fsort = "count", flimit = 0) - }else{ - list(fsort = "count", flimit = df$slice_n) - } - }else{ # non-count fields are arranged in ascending order by default - if(df$direction == "ascending"){ - list(fsort = "index", flimit = df$slice_n) - }else{ - list(fsort = "index", flimit = 0) - } - } -} - #' @rdname as_query.data_request #' @order 3 #' @export @@ -194,57 +108,6 @@ as_query.metadata_request <- function(x, ...){ add_request(x) } -#' Internal function to enforce `select()` for metadata queries. Basically just -#' supplies defaults. This is the *setup* phase as is usually called by -#' `as_query()` -#' @noRd -#' @keywords Internal -enforce_select_query <- function(x){ - if(inherits(x, "metadata_request")){ - # if `select()` is given, we simply pass it on - # if missing, we have to apply some logic - if(is.null(x$select)){ - specific_type <- x |> - purrr::pluck("type") |> - stringr::str_remove("^metadata/") - # see whether `lookup_select_columns()` returns anything - chosen_columns <- lookup_select_columns(specific_type) - # some `unnest` queries internally rename the lead column to the name of the supplied field - if(is.null(chosen_columns) & - stringr::str_detect(specific_type, "-unnest$")){ - chosen_columns <- x$filter |> - purrr::pluck("value") - } - # if we have, after 2 attempts, found some chosen_columns, use them - if(!is.null(chosen_columns)){ - x <- dplyr::select(x, - tidyselect::any_of({{chosen_columns}})) - # if *still* null, choose `everything()` - }else{ - x <- dplyr::select(x, - tidyselect::everything()) - } - } - }else if(inherits(x, "data_request")){ - # `select()` is only needed if DOI is not requested - is_doi <- ifelse(is.null(x$filter), - FALSE, - {ifelse(x$filter$variable[1] == "doi", TRUE, FALSE)}) - if(is.null(x$select) & !is_doi){ - x <- x |> select(group = "basic") - } - } - x -} - -#' @noRd -#' @keywords Internal -add_request <- function(new_obj, source_obj){ - new_class <- class(new_obj) - new_obj$request <- source_obj - structure(new_obj, class = new_class) -} - #' @rdname as_query.data_request #' @param thumbnail Logical: should thumbnail-size images be returned? Defaults #' to `FALSE`, indicating full-size images are required. @@ -275,4 +138,186 @@ as_query.list <- function(x){ #' @order 6 as_query.query <- function(x){ x +} + +#' Internal function called by `as_query()` +#' @noRd +#' @keywords Internal +count_switch <- function(x){ + x$type <- switch(x$type, + "occurrences" = "occurrences-count", + "occurrences-count" = "occurrences-count", + "species" = "species-count", + "species-count" = "species-count", + "media" = cli::cli_abort("type = 'media' is not supported by `count()`"), + cli::cli_abort("`count()` only supports `type = 'occurrences' or` `'species'`")) + x +} + +#' Internal function to check behaviour of `distinct()`, `group_by()` etc. +#' called by `as_query()` +#' @noRd +#' @keywords Internal +check_distinct_count_groupby <- function(x){ + + # get basic info + has_group_by <- !is.null(x$group_by) + has_count <- !is.null(x$count) + has_distinct <- !is.null(x$distinct) + has_select <- !is.null(x$select) + + # first handle case when distinct() is supplied + if(has_distinct){ + has_distinct_name <- !is.na(x$distinct$name) + keep_all <- x$distinct$keep_all + if(has_distinct_name){ + if(keep_all){ # this section feels incomplete + update_request_object(x, type = "species") + }else{ # keep_all is FALSE + update_request_object(x, type = "species-count") + } + }else{ # no distinct name + if(has_group_by){ + if(keep_all){ + x <- update_request_object(x, type = "species") + if(has_count){ + count_switch(x) + }else{ + x + } + }else{ + x <- update_request_object(x, type = "occurrences-count") + if(has_select){ + if(has_count){ + x + }else{ + dplyr::select(x, -dplyr::any_of("count")) + } # end has_count + }else{ # end has_select + if(has_count){ + dplyr::select(x, -dplyr::any_of(c("label", "i18nCode", "fq"))) + }else{ + dplyr::select(x, -dplyr::any_of(c("label", "i18nCode", "fq", "count"))) + } + } # end has_select + } # end keep_all = FALSE + }else{ # no group_by() AND empty call distinct() + if(has_count){ + count_switch(x) + }else{ + x + } + } # end has_group_by + } # end has_distinct_name + }else{ # no distinct() call + if(has_select){ + if(has_count){ + count_switch(x) + }else{ + x + } + }else{ # no select + if(has_count){ + x |> + count_switch() |> + dplyr::select(-dplyr::any_of(c("label", "i18nCode", "fq"))) + }else{ + dplyr::select(x, group = "basic") # assumes type = "occurrences" + } + } # end has_select + } # end has_distinct +} # end function + +#' Internal function to check `slice` and `arrange` for counts +#' @keywords Internal +#' @noRd +check_slice_arrange <- function(x){ + if(!stringr::str_detect(x$type, "-count$")){ + x + }else{ + if(is.null(x$slice)){ + # limits to 10,000 rows + # TODO: This should ultimately be set by `slice` or `atlas_counts(limit = )`, not internally. + # Will need updating to avoid hidden limit setting here & in `compute_occurrences_count()` + slice <- tibble::tibble(slice_n = 1e4, slice_called = FALSE) + }else{ + slice <- x$slice + } + if(is.null(x$arrange)){ + arrange <- tibble::tibble(variable = "count", + direction = "descending") + }else{ + arrange <- x$arrange + } + x$slice_arrange <- dplyr::bind_cols(slice, arrange) + x$arrange <- NULL + x$slice <- NULL + x + } +} + +#' Internal function to enforce `select()` for metadata queries. Basically just +#' supplies defaults. This is the *setup* phase as is usually called by +#' `as_query()` +#' @noRd +#' @keywords Internal +enforce_select_query <- function(x){ + # note: UseMethod() would be tidier, but seems to need to be exported to work? + switch(class(x)[1], + "metadata_request" = enforce_select_query_metadata(x), + "data_request" = enforce_select_query_data(x), + x) +} + +#' sub-function to `enforce_select_query()` +#' @noRd +#' @keywords Internal +enforce_select_query_metadata <- function(x){ + # if `select()` is given, we simply pass it on + # if missing, we have to apply some logic + if(is.null(x$select)){ + specific_type <- x |> + purrr::pluck("type") |> + stringr::str_remove("^metadata/") + # see whether `lookup_select_columns()` returns anything + chosen_columns <- lookup_select_columns(specific_type) + # some `unnest` queries internally rename the lead column to the name of the supplied field + if(is.null(chosen_columns) & + stringr::str_detect(specific_type, "-unnest$")){ + chosen_columns <- x$filter |> + purrr::pluck("value") + } + # if we have, after 2 attempts, found some chosen_columns, use them + if(!is.null(chosen_columns)){ + x <- dplyr::select(x, tidyselect::any_of({{chosen_columns}})) + # if *still* null, choose `everything()` + }else{ + x <- dplyr::select(x, tidyselect::everything()) + } + } +} + +#' sub-function to `enforce_select_query()` +#' @noRd +#' @keywords Internal +enforce_select_query_data <- function(x){ + if(is.null(x$select)){ + switch(x$type, + "occurrences" = dplyr::select(x, group = "basic"), + "occurrences-count" = dplyr::select(x, -dplyr::any_of(c("label", "i18nCode", "fq"))), + "species" = dplyr::select(x, group = "taxonomy"), + # NOTE: further exceptions may be needed for type = "species" + dplyr::select(x, tidyselect::everything()) # useful for type = "occurrences-doi" + ) + }else{ + x + } +} + +#' @noRd +#' @keywords Internal +add_request <- function(new_obj, source_obj){ + new_class <- class(new_obj) + new_obj$request <- source_obj + structure(new_obj, class = new_class) } \ No newline at end of file diff --git a/R/collapse_checks.R b/R/collapse_checks.R index 9d2fa647..57d46968 100644 --- a/R/collapse_checks.R +++ b/R/collapse_checks.R @@ -45,11 +45,17 @@ collapse_run_checks <- function(.query, error_call = rlang::caller_env()){ # "data/" functions require pre-processing of metadata, if(stringr::str_detect(.query$type, "^data/")){ - # some checks should happen regardless of `run_checks` - .query <- .query |> - check_login(error_call) |> - check_identifiers(error_call) |> - check_select(error_call) + # taxon concept ID must always be evaluated + .query <- check_identifiers(.query, error_call) + # login should only be evaluated for species and occurrence + if(.query$type %in% c("data/occurrences", "data/species")){ + .query <- check_login(.query, error_call) + } + # check_select() is specifically for parsing fields into urls, + # should only be called for occurrences + if(.query$type == "data/occurrences"){ + .query <- check_select(.query, error_call) + } if(potions::pour("package", "run_checks")) { .query <- .query |> check_reason(error_call) |> diff --git a/R/collapse_query_set.R b/R/collapse_query_set.R index 0562fcf9..8e60e765 100644 --- a/R/collapse_query_set.R +++ b/R/collapse_query_set.R @@ -33,6 +33,7 @@ collapse_query_set <- function(x, }, "data/species" = collapse_occurrences(x), # optimised for GBIF "data/species-count" = collapse_species_count(x), + "data/species-count-groupby" = collapse_species_count(x), # "-unnest" functions require some checks "metadata/profiles-unnest" = collapse_profile_values(x, error_call = error_call) |> diff --git a/R/collapse_species_count.R b/R/collapse_species_count.R index e8310cae..21938cf8 100644 --- a/R/collapse_species_count.R +++ b/R/collapse_species_count.R @@ -2,12 +2,12 @@ #' @noRd #' @keywords Internal collapse_species_count <- function(.query){ - if(!is.null(.query$arrange)){ - .query <- collapse_species_query_list(.query) + if(!is.null(.query$request$group_by)){ + collapse_species_query_list(.query) }else{ .query$url <- tibble::tibble(url = .query$url) + .query } - .query } #' Internal function to generate correct set of species-count queries when @@ -15,54 +15,18 @@ collapse_species_count <- function(.query){ #' @noRd #' @keywords Internal collapse_species_query_list <- function(.query){ - - # remove `species_facets()` from query - url <- httr2::url_parse(.query$url) - query_temp <- url$query - query_temp <- query_temp[-which( - unlist(query_temp) == species_facets() & - names(query_temp) == "facets")] - n_facet_terms <- length(which(names(query_temp) == "facets")) - url$query <- c(query_temp, list(pageSize = 0)) - - # rebuild a .query object for this query - data_temp <- .query - data_temp$type <- "data/occurrences-count" - data_temp$url <- httr2::url_build(url) - - # collect using `occurrences-count` code (to parse expand correctly) - df <- collect(data_temp) - - # create new set of fq args - fq_args <- purrr::map( - split(df, seq_len(nrow(df))), - \(a){ - x <- a[, - ncol(a)] - glue::glue_collapse( - glue::glue("{names(x)}:{x}"), - sep = " AND ") - }) |> unlist() - - # modify url to only have `species_facets()` in facets slot - url <- httr2::url_parse(.query$url) - query <- url$query - query <- query[-which( - unlist(query_temp) != species_facets() & - names(query_temp) == "facets")] - query$flimit <- 1 - url$query <- query - - # create new fq urls - new_fqs <- paste(url$query$fq, fq_args, sep = " AND ") - urls <- purrr::map(new_fqs, function(a, x){ - x$query$fq <- a - httr2::url_build(x) - }, x = url) |> + # use existing `group_by()` code to handle the facet construction + new_query <- collapse_occurrences_count_atlas_groupby_crossed(.query) + distinct_facet <- .query$request$distinct$name # this is not retained for some reason + new_urls <- new_query |> + purrr::pluck("url", "url") |> + purrr::map(.f = \(a){ + url_now <- httr2::url_parse(a) + url_now$query$flimit <- 0 # set no facets to be returned; we only want the number of facets + url_now$query$facets <- distinct_facet # set facet correctly + httr2::url_build(url_now) + }) |> unlist() - - # convert to a tibble to pass back to .query - .query$url <- dplyr::bind_cols( - dplyr::select(df, -count), - tibble::tibble(url = urls)) - return(.query) -} + new_query$url$url <- new_urls + new_query +} \ No newline at end of file diff --git a/R/collect_metadata.R b/R/collect_metadata.R index cb3ad548..21e2e41d 100644 --- a/R/collect_metadata.R +++ b/R/collect_metadata.R @@ -272,7 +272,12 @@ collect_fields <- function(.query){ type = "fields") |> dplyr::rename_with(camel_to_snake_case) |> dplyr::bind_rows(galah_internal_archived$media, - galah_internal_archived$other) + galah_internal_archived$other) |> + dplyr::distinct(.data$id, .keep_all = TRUE) # please keep this line! + # Sometimes living atlases update their field lists to add or remove + # media information which is *also* added here in the `bind_rows()` line. + # Using `distinct()` is a safe way to ensure they are always present + # but **not** duplicated. } result_df <- update_attributes(result_df, type = "fields") update_cache(fields = result_df) diff --git a/R/collect_occurrences_count.R b/R/collect_occurrences_count.R index 8446cc9e..db6320ab 100644 --- a/R/collect_occurrences_count.R +++ b/R/collect_occurrences_count.R @@ -2,11 +2,12 @@ #' @noRd #' @keywords Internal collect_occurrences_count <- function(.query){ - if(is_gbif()){ + {if(is_gbif()){ collect_occurrences_count_gbif(.query) }else{ collect_occurrences_count_la(.query) - } + }} |> + parse_select(.query) } #' `collect()` for `type = "data/occurrences-count"` for gbif @@ -118,7 +119,6 @@ clean_labels <- function(df){ stringr::str_replace("\\.$", "") df[[variable]] <- values df |> - dplyr::select(-dplyr::any_of(c("label", "i18nCode", "fq"))) |> dplyr::relocate("count", .after = dplyr::last_col()) }else{ @@ -129,8 +129,8 @@ clean_labels <- function(df){ col_lookup <- c("label") names(col_lookup) <- field_name df |> - dplyr::rename(dplyr::all_of(col_lookup)) |> - dplyr::select(-"fq") + dplyr::rename(dplyr::all_of(col_lookup)) + # dplyr::select(-"fq") }else{ # some are completely empty df diff --git a/R/dplyr-collect.R b/R/dplyr-collect.R index 7e71e2c1..97dcaede 100644 --- a/R/dplyr-collect.R +++ b/R/dplyr-collect.R @@ -87,6 +87,7 @@ collect.computed_query <- function(x, "data/occurrences-doi" = collect_occurrences_doi(x, file = file), "data/species"= collect_species(x, file = file), "data/species-count" = collect_species_count(x), + "data/species-count-groupby" = collect_species_count(x), # "data/taxonomy" = collect_taxonomy(x), "files/media" = collect_media_files(x), "metadata/apis" = collect_apis(x), diff --git a/R/dplyr-count.R b/R/dplyr-count.R index 8f7cbe9f..c80cf94a 100644 --- a/R/dplyr-count.R +++ b/R/dplyr-count.R @@ -15,22 +15,9 @@ count.data_request <- function(x, wt, sort, name){ - count_switch(x) |> - group_by(...) -} - -#' Internal function called by `count.data_request()` and `distinct.data_request()` -#' @noRd -#' @keywords Internal -count_switch <- function(x){ - x$type <- switch(x$type, - "occurrences" = "occurrences-count", - "occurrences-count" = "occurrences-count", - "species" = "species-count", - "species-count" = "species-count", - "media" = cli::cli_abort("type = 'media' is not supported by `count()`"), - cli::cli_abort("`count()` only supports `type = 'occurrences' or` `'species'`")) - x + x |> + group_by(...) |> + update_request_object(count = TRUE) } #' @rdname count.data_request diff --git a/man/as_query.data_request.Rd b/man/as_query.data_request.Rd index aa990437..89b53a12 100644 --- a/man/as_query.data_request.Rd +++ b/man/as_query.data_request.Rd @@ -47,7 +47,7 @@ two or more of the following slots: \item \code{headers}: headers to be sent with the API call \item \code{body}: body section of the API call \item \code{options}: options section of the API call -\item Any other information retained from the preceeding \verb{_request} object (see \code{\link[=galah_call]{galah_call()}}) +\item \code{request}: captures the preceeding \verb{_request} object (see \code{\link[=galah_call]{galah_call()}}) } } \description{ diff --git a/tests/testthat/test-dplyr-count.R b/tests/testthat/test-dplyr-count.R index 0bac8486..8932a4c6 100644 --- a/tests/testthat/test-dplyr-count.R +++ b/tests/testthat/test-dplyr-count.R @@ -72,23 +72,11 @@ test_that("`count(year)` groups by `year`", { expect_equal(colnames(result), c("year", "count")) }) -test_that("`count()` handles multiple 'group by' variables", { - skip_if_offline(); skip_on_ci() - counts <- galah_call() |> - filter(year >= 2021) |> - group_by(year, month, basisOfRecord) |> - count() |> - collect() - expect_s3_class(counts, c("tbl_df", "tbl", "data.frame")) - expect_equal(names(counts), - c("year", "month", "basisOfRecord", "count")) - expect_true(all(counts$year >= 2021)) -}) - test_that("`count()` handles multiple variables", { skip_if_offline(); skip_on_ci() counts <- galah_call() |> - filter(year >= 2021) |> + filter(year >= 2021, + month >= 10) |> count(year, month, basisOfRecord) |> collect() expect_s3_class(counts, c("tbl_df", "tbl", "data.frame")) @@ -321,6 +309,17 @@ test_that("`group_by()` works when > 1 `filter()`", { expect_equal(y, z) }) +test_that("`select()` works for count queries", { + x <- galah_call() |> + filter(year == 2024, + genus == "Crinia") |> + count(speciesID) |> + select(dplyr::everything()) |> + collect() + expect_s3_class(x, c("tbl_df", "tbl", "data.frame")) + expect_gte(ncol(x), 4) # traditionally this was only two cols +}) + ## BELOW HERE TESTS WILL FAIL # capture_requests("count_piped_2", { @@ -335,16 +334,4 @@ test_that("`group_by()` works when > 1 `filter()`", { # expect_equal(names(counts), c("year", "count")) # expect_gt(nrow(counts), 0) # }) -# }) - -# test_that("`atlas_counts()` handles pagination", { -# vcr::use_cassette("count_with_pagination", { -# counts <- galah_call() |> -# group_by(year) |> -# slice_head(n = 101) |> -# count() -# }) -# expect_s3_class(counts, c("tbl_df", "tbl", "data.frame")) -# expect_equal(nrow(counts), 101) -# expect_equal(names(counts), c("year", "count")) # }) \ No newline at end of file diff --git a/tests/testthat/test-dplyr-distinct.R b/tests/testthat/test-dplyr-distinct.R index 3a4ccb46..319b5bbe 100644 --- a/tests/testthat/test-dplyr-distinct.R +++ b/tests/testthat/test-dplyr-distinct.R @@ -4,6 +4,7 @@ quiet_collect <- function(x){ purrr_collect(x) |> purrr::pluck("result") } +galah_config(email = "ala4r@ala.org.au") test_that("`group_by()` without `distinct()` returns occurrences, not species", { skip_if_offline(); skip_on_ci() @@ -69,24 +70,7 @@ test_that("`group_by() |> distinct(.keep_all = TRUE)` converts type from occurre x <- quiet_collect(query) expect_s3_class(x, c("tbl_df", "tbl", "data.frame")) - expect_equal(length(unique(x$speciesID)), - nrow(x)) -}) - -test_that("`add_count() |> distinct()` adds record counts to each species", { - skip_if_offline(); skip_on_ci() - query <- galah_call() |> - filter(year == 2024, - genus == "Crinia") |> - group_by(speciesID) |> - add_count() |> - distinct(.keep_all = TRUE) |> - collapse() - expect_equal(query$type, "data/species") - x <- quiet_collect(query) - expect_s3_class(x, - c("tbl_df", "tbl", "data.frame")) - expect_equal(length(unique(x$speciesID)), + expect_equal(length(unique(x$species)), nrow(x)) }) @@ -100,7 +84,7 @@ test_that("`distinct()` can be used in place of `group_by()` for species queries expect_s3_class(result, c("tbl_df", "tbl", "data.frame")) expect_contains(colnames(result), - c("taxon_concept_id", "species_name", "kingdom")) + c("species", "species_name", "kingdom")) }) test_that("`distinct(speciesID) |> count()` can be used to count the number of species", { @@ -108,7 +92,8 @@ test_that("`distinct(speciesID) |> count()` can be used to count the number of s result <- galah_call() |> identify("perameles") |> distinct(taxonConceptID) |> - count() + count() |> + quiet_collect() expect_s3_class(x, c("tbl_df", "tbl", "data.frame")) expect_equal(nrow(result), 1) @@ -118,21 +103,36 @@ test_that("`distinct(speciesID) |> count()` can be used to count the number of s test_that("`group_by(something) |> distinct(speciesID) |> count()` gives grouped number of categories", { skip_if_offline(); skip_on_ci() result <- galah_call() |> - identify("perameles") + identify("perameles") |> group_by(basisOfRecord) |> distinct(speciesID) |> - count() + count() |> + collect() expect_equal(colnames(result), c("basisOfRecord", "count")) all(result$count < 10) |> expect_true() }) -test_that("using `add_count()` allows counts to be added to speces queries", { - galah_call() |> - group_by(taxonConceptID) |> - add_count() |> # no longer in `select()` - distinct(taxonConceptID, .keep_all = TRUE) +test_that("`add_count() |> distinct()` adds record counts to each species", { + skip_if_offline(); skip_on_ci() + query <- galah_call() |> + filter(year == 2024, + genus == "Crinia") |> + group_by(speciesID) |> + add_count() |> + distinct(.keep_all = TRUE) |> + collapse() + expect_equal(query$type, "data/species") + x <- quiet_collect(query) + expect_s3_class(x, + c("tbl_df", "tbl", "data.frame")) + expect_equal(length(unique(x$speciesID)), + nrow(x)) +}) + +test_that("add_count() without `distinct()` just adds a column of 1s", { + skip("not built") }) rm(quiet_collect) \ No newline at end of file diff --git a/tests/testthat/test-request_metadata_select.R b/tests/testthat/test-request_metadata_select.R index 71411caa..f0621800 100644 --- a/tests/testthat/test-request_metadata_select.R +++ b/tests/testthat/test-request_metadata_select.R @@ -7,6 +7,18 @@ quiet_collect <- function(...){ purrr::pluck("result") } +check_select_structure <- function(query){ + purrr::pluck(query, "request", "select") |> + is.null() |> + expect_false() + purrr::pluck(query, !!!list("request", "select", "quosure", 1)) |> + rlang::is_quosure() |> + expect_true() + purrr::pluck(query, "request", "select", "summary") |> + is.null() |> + expect_false() +} + test_that("`request_metadata()` works with `select()` for local APIs", { type_list <- c("atlases", "apis", @@ -22,16 +34,9 @@ test_that("`request_metadata()` works with `select()` for local APIs", { purrr::pluck("data") |> is.null() |> expect_false() + # check `select` exists, and contains a quosure and a summary - purrr::pluck(query, "select") |> - is.null() |> - expect_false() - purrr::pluck(query, !!!list("select", "quosure", 1)) |> - rlang::is_quosure() |> - expect_true() - purrr::pluck(query, "select", "summary") |> - is.null() |> - expect_false() + check_select_structure(query) # collect result result <- collect(query) @@ -73,15 +78,7 @@ test_that("`request_metadata()` works with `select()` for remote APIs *without* is.null() |> expect_false() # check `select` exists, and contains a quosure and a summary - purrr::pluck(query, "select") |> - is.null() |> - expect_false() - purrr::pluck(query, !!!list("select", "quosure", 1)) |> - rlang::is_quosure() |> - expect_true() - purrr::pluck(query, "select", "summary") |> - is.null() |> - expect_false() + check_select_structure(query) # collect result result <- collect(query) @@ -131,15 +128,7 @@ test_that("`request_metadata()` works with `select()` for remote APIs *with* def is.null() |> expect_false() # check `select` exists, and contains a quosure and a summary - purrr::pluck(query, "select") |> - is.null() |> - expect_false() - purrr::pluck(query, !!!list("select", "quosure", 1)) |> - rlang::is_quosure() |> - expect_true() - purrr::pluck(query, "select", "summary") |> - is.null() |> - expect_false() + check_select_structure(query) # collect that query, and check for expected columns result <- quiet_collect(query) @@ -195,15 +184,7 @@ test_that("`request_metdata()` works with `select()` for `type = 'taxa'`", { is.null() |> expect_false() # check `select` exists, and contains a quosure and a summary - purrr::pluck(query, "select") |> - is.null() |> - expect_false() - purrr::pluck(query, !!!list("select", "quosure", 1)) |> - rlang::is_quosure() |> - expect_true() - purrr::pluck(query, "select", "summary") |> - is.null() |> - expect_false() + check_select_structure(query) # now run the query basic_search <- search_taxa("Crinia") everything_search <- quiet_collect(query) @@ -229,15 +210,7 @@ test_that("`request_metdata()` works with `select()` for complex taxa", { is.null() |> expect_false() # check `select` exists, and contains a quosure and a summary - purrr::pluck(query, "select") |> - is.null() |> - expect_false() - purrr::pluck(query, !!!list("select", "quosure", 1)) |> - rlang::is_quosure() |> - expect_true() - purrr::pluck(query, "select", "summary") |> - is.null() |> - expect_false() + check_select_structure(query) # now run the query basic_search <- search_taxa(crinia_tibble) everything_search <- quiet_collect(query) @@ -263,15 +236,7 @@ test_that("`request_metdata()` works with `select()` for `type = 'identifiers'`" stringr::str_detect("namematching\\/api\\/getByTaxonID") |> expect_true() # check `select` exists, and contains a quosure and a summary - purrr::pluck(query, "select") |> - is.null() |> - expect_false() - purrr::pluck(query, !!!list("select", "quosure", 1)) |> - rlang::is_quosure() |> - expect_true() - purrr::pluck(query, "select", "summary") |> - is.null() |> - expect_false() + check_select_structure(query) # now run the query basic_search <- search_identifiers(tcid) everything_search <- quiet_collect(query) @@ -283,4 +248,4 @@ test_that("`request_metdata()` works with `select()` for `type = 'identifiers'`" c("success", "lft", "rgt", "kingdom_id")) }) -rm(purrr_collect, quiet_collect) \ No newline at end of file +rm(purrr_collect, quiet_collect, check_select_structure) \ No newline at end of file diff --git a/tests/testthat/test-show_values.R b/tests/testthat/test-show_values.R index b020d91b..710c2ab3 100644 --- a/tests/testthat/test-show_values.R +++ b/tests/testthat/test-show_values.R @@ -80,11 +80,7 @@ test_that("`search_values()` returns filtered results for profiles", { test_that("`search_values()` returns filtered results for lists", { skip_if_offline(); skip_on_ci() - # use more efficient syntax - base_df <- request_metadata() |> - filter(lists == "dr650") |> - unnest() |> - collect() + base_df <- search_all(lists, "dr650") values_search <- base_df |> quiet_search("frog") values_show <- base_df |> quiet_values() search_result_check <- all(grepl(pattern = "frog", @@ -143,10 +139,9 @@ test_that("`show_values()` all_fields = TRUE works for lists", { # doesn't work for fields x <- search_all(fields, "cl22") |> purrr_values(all_fields = TRUE) - expect_equal(x$warnings, - "`all_fields` only applies to type `lists`. Ignoring `all_fields = TRUE`.") - expect_equal(x$messages, - "* Showing values for 'cl22'.") + stringr::str_detect(x$messages, "cl22") |> + any() |> + expect_true() }) rm(purrr_values, quiet_values, purrr_search, quiet_search) \ No newline at end of file From d6564456ef3f750b46453f7fb763890423d18187 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Wed, 7 Jan 2026 15:16:21 +1100 Subject: [PATCH 57/94] Misc bug fixes - ensure backwards compatibility of setting `type = "species"` with new `distinct()` workflow - ensure taxon concept IDs are returned from GBIF namematching APIs - minor updates to tests etc --- NEWS.md | 7 +++-- R/as_query-metadata.R | 5 ++-- R/as_query-species_count.R | 2 +- R/as_query.R | 16 +++++++++- R/atlas_species.R | 1 + R/check.R | 6 ++-- R/collapse_query_set.R | 1 - R/collect_taxa.R | 10 ++----- R/dplyr-collect.R | 1 - R/galah_radius.R | 8 ++--- R/utilities_internal.R | 5 ++-- tests/testthat/_snaps/print.md | 12 +++----- tests/testthat/test-atlas_media.R | 14 ++++----- tests/testthat/test-atlas_species.R | 31 ++++++++------------ tests/testthat/test-dplyr-count.R | 4 --- tests/testthat/test-dplyr-distinct.R | 4 +-- tests/testthat/test-dplyr-filter.R | 6 ++-- tests/testthat/test-dplyr-select.R | 2 +- tests/testthat/test-galah_group_by.R | 4 +-- tests/testthat/test-galah_identify.R | 4 +-- tests/testthat/test-galah_radius.R | 2 +- tests/testthat/test-international-Flanders.R | 13 ++++---- tests/testthat/test-international-France.R | 23 +++++++++++++++ tests/testthat/test-print.R | 3 ++ 24 files changed, 102 insertions(+), 82 deletions(-) diff --git a/NEWS.md b/NEWS.md index b1418bb8..165be80d 100644 --- a/NEWS.md +++ b/NEWS.md @@ -5,9 +5,12 @@ * DOIs now supported for `GBIF` * Kew gardens and Flanders living atlases added -### Major changes -* authentication supported for ALA users +### New functions +* `dplyr::distinct()` can be used to find grouped data and summaries, generalising `atlas_species()` * new functions `as_query()` and `coalesce()` as prequels to `collapse()` + +### Major changes +* authentication supported for ALA users within `galah_config()` * media functions have been updated and have their own vignette; fields returned have changed * all metadata requests now accept `select()`; all `show_all()` and `search_all()` functions gain an `all_fields` argument diff --git a/R/as_query-metadata.R b/R/as_query-metadata.R index e6f2f3e4..f0dae529 100644 --- a/R/as_query-metadata.R +++ b/R/as_query-metadata.R @@ -242,9 +242,8 @@ as_query_media_metadata <- function(.query, # } list(type = "metadata/media", url = tibble::tibble(url = url_lookup("metadata/media", - id= .query$filter$value)), - headers = build_headers(), - filter = .query$filter) |> + id = .query$filter$value)), + headers = build_headers()) |> as_query() } diff --git a/R/as_query-species_count.R b/R/as_query-species_count.R index b39a9b34..9b298953 100644 --- a/R/as_query-species_count.R +++ b/R/as_query-species_count.R @@ -44,7 +44,7 @@ as_query_species_count_atlas <- function(identify = NULL, facets <- c(as.list(group_by$name), species_facets()) names(facets) <- rep("facets", length(facets)) url$query <- c(query, facets, parse_slice_arrange(slice_arrange)) - result <- list(type = "data/species-count-groupby", + result <- list(type = "data/species-count", url = httr2::url_build(url), headers = build_headers()) } diff --git a/R/as_query.R b/R/as_query.R index 5d5b1114..0e6c10b7 100644 --- a/R/as_query.R +++ b/R/as_query.R @@ -79,7 +79,8 @@ as_query.data_request <- function(x, #' @order 3 #' @export as_query.metadata_request <- function(x, ...){ - x <- check_authentication(x) |> + x <- x |> + check_authentication() |> enforce_select_query() switch(x$type, "apis" = as_query_apis(x), @@ -166,6 +167,17 @@ check_distinct_count_groupby <- function(x){ has_distinct <- !is.null(x$distinct) has_select <- !is.null(x$select) + # if type is 'species', ensure `distinct` is added + # this is clunky, but backwards compatible + if(x$type == "species" & !has_distinct){ + if(has_count){ + x <- x |> distinct("speciesID", .keep_all = FALSE) + }else{ + x <- x |> distinct("speciesID", .keep_all = TRUE) + } + has_distinct <- TRUE + } + # first handle case when distinct() is supplied if(has_distinct){ has_distinct_name <- !is.na(x$distinct$name) @@ -294,6 +306,8 @@ enforce_select_query_metadata <- function(x){ }else{ x <- dplyr::select(x, tidyselect::everything()) } + }else{ + x } } diff --git a/R/atlas_species.R b/R/atlas_species.R index fee1a065..0420bf08 100644 --- a/R/atlas_species.R +++ b/R/atlas_species.R @@ -20,6 +20,7 @@ atlas_species <- function(request = NULL, # convert to `data_request` object check_atlas_inputs(args) |> + distinct("speciesID", .keep_all = TRUE) |> collapse() |> collect() } \ No newline at end of file diff --git a/R/check.R b/R/check.R index 24b01515..a7cf95ac 100644 --- a/R/check.R +++ b/R/check.R @@ -19,11 +19,11 @@ check_atlas_inputs <- function(args, added_arguments <- added_arguments[!(purrr::map(added_arguments, is.null) |> unlist())] if(length(added_arguments) > 0){ for(i in seq_along(added_arguments)){ - request_object <- do.call(update_request_object, - append(list(x = request_obj), added_arguments[i])) + request_obj <- do.call(update_request_object, + append(list(x = request_obj), added_arguments[i])) } } - request_object + request_obj } diff --git a/R/collapse_query_set.R b/R/collapse_query_set.R index 8e60e765..0562fcf9 100644 --- a/R/collapse_query_set.R +++ b/R/collapse_query_set.R @@ -33,7 +33,6 @@ collapse_query_set <- function(x, }, "data/species" = collapse_occurrences(x), # optimised for GBIF "data/species-count" = collapse_species_count(x), - "data/species-count-groupby" = collapse_species_count(x), # "-unnest" functions require some checks "metadata/profiles-unnest" = collapse_profile_values(x, error_call = error_call) |> diff --git a/R/collect_taxa.R b/R/collect_taxa.R index ec8277b5..8daeec81 100644 --- a/R/collect_taxa.R +++ b/R/collect_taxa.R @@ -4,12 +4,10 @@ collect_taxa <- function(.query){ if(stringr::str_detect(.query$url$url[1], "namematching|name-matching")){ collect_taxa_namematching(.query) # Australia, Spain, Sweden + }else if(stringr::str_detect(.query$url$url[1], "api.gbif.org")){ + collect_taxa_gbif(.query) }else{ - if(is_gbif()){ - collect_taxa_gbif(.query) - }else{ - collect_taxa_la(.query) # tested for Austria, UK - } + collect_taxa_la(.query) # tested for Austria, UK } } @@ -161,8 +159,6 @@ clean_la_taxa <- function(result, search_terms){ } else { list_of_results <- list_of_results[[min_distance]] } - } else { - list_of_results <- list_of_results } # unlist if necessary diff --git a/R/dplyr-collect.R b/R/dplyr-collect.R index 97dcaede..7e71e2c1 100644 --- a/R/dplyr-collect.R +++ b/R/dplyr-collect.R @@ -87,7 +87,6 @@ collect.computed_query <- function(x, "data/occurrences-doi" = collect_occurrences_doi(x, file = file), "data/species"= collect_species(x, file = file), "data/species-count" = collect_species_count(x), - "data/species-count-groupby" = collect_species_count(x), # "data/taxonomy" = collect_taxonomy(x), "files/media" = collect_media_files(x), "metadata/apis" = collect_apis(x), diff --git a/R/galah_radius.R b/R/galah_radius.R index 928ec166..50f315c8 100644 --- a/R/galah_radius.R +++ b/R/galah_radius.R @@ -133,11 +133,9 @@ parse_point_radius <- function(..., # Should this be an error? A message? if(radius > 1565) { - cli::cli({ - cli::cli_text("Supplied radius is larger than the area of Australia.") - c(i = "Try reducing the radius to narrow your query.") |> - cli::cli_bullets() - }) + c("Supplied radius is larger than the area of Australia.", + i = "Try reducing the radius to narrow your query.") |> + cli::cli_inform() } out_query <- list(lat = lat, diff --git a/R/utilities_internal.R b/R/utilities_internal.R index 04feb5ec..8c993a2c 100644 --- a/R/utilities_internal.R +++ b/R/utilities_internal.R @@ -32,6 +32,7 @@ parse_rename <- function(df, .query){ cols <- colnames(df) rename_vec <- .query$type |> stringr::str_remove("^metadata/") |> + stringr::str_remove("-single$|-multiple$") |> # so that `taxa` args are matched lookup_rename_columns() # check whether renaming information is given if(!is.null(rename_vec)){ @@ -124,6 +125,7 @@ lookup_select_columns_taxa <- function(){ "taxon_concept_id", # ALA "taxon_concept_lsid", # Austria, Guatemala "authority", # OpenObs + "key", # GBIF "usage_key", # GBIF "guid", # species search "canonical_name", "status", @@ -257,8 +259,7 @@ add_email_notify <- function(x) { if(is.na(notify)) { notify <- FALSE } - # ala api requires lowercase - x$email_notify <- ifelse(notify, "true", "false") + x$emailNotify <- ifelse(notify, "true", "false") x } diff --git a/tests/testthat/_snaps/print.md b/tests/testthat/_snaps/print.md index b95570b6..d1ecd02b 100644 --- a/tests/testthat/_snaps/print.md +++ b/tests/testthat/_snaps/print.md @@ -58,11 +58,6 @@ Message Object of class query with type metadata/taxa-single * url: https://api.ala.org.au/namematching/api/search?q=Crinia - * select: ~tidyselect::any_of(~c("search_term", "scientific_name", - "scientific_name_authorship", "taxon_concept_id", "taxon_concept_lsid", - "authority", "usage_key", "guid", "canonical_name", "status", "rank", - "match_type", "confidence", "time_taken", "vernacular_name", "issues", - "kingdom", "phylum", "class", "order", "family", "genus", "species")) # object of class `computed_query` formats correctly @@ -89,16 +84,17 @@ Code galah_config() Message - `galah` package configuration: + `galah` package configuration Package v verbose v run_checks x send_email - v authenticate - i directory: /var/folders/47/2_32ylzx64qgpyqlh7zkd_5h0000gn/T//Rtmpu7Kgdm + v caching + i directory: something User + x authentication username [Not Provided] email password [Not Provided] diff --git a/tests/testthat/test-atlas_media.R b/tests/testthat/test-atlas_media.R index 7b195b65..4fe1591d 100644 --- a/tests/testthat/test-atlas_media.R +++ b/tests/testthat/test-atlas_media.R @@ -93,24 +93,22 @@ test_that("`collapse()` and `collect()` work for `type = 'media'`", { filter(media == unlist(dplyr::pull(occ_collect, "images"))) |> quiet_collapse() expect_true(inherits(media_collapse, "query")) - expect_equal(length(media_collapse), 5) + expect_equal(length(media_collapse), 4) expect_equal(names(media_collapse), c("type", "url", "headers", - "filter", - "select")) + "request")) expect_true(media_collapse$type == "metadata/media") # compute media_compute <- quiet_compute(media_collapse) expect_true(inherits(media_compute, "computed_query")) - expect_equal(length(media_compute), 5) + expect_equal(length(media_compute), 4) expect_equal(names(media_compute), c("type", "url", "headers", - "filter", - "select")) + "request")) # collect media_collect <- quiet_collect(media_compute) expect_s3_class(media_collect, c("tbl_df", "tbl", "data.frame")) @@ -160,7 +158,7 @@ test_that("`collapse()` and `collect()` work for `type = 'media'`", { any() |> expect_true() fullsize <- get_image_sizes(media_dir) - purrr_collect_media(df, thumbnail = TRUE) + collect_media_catcher <- purrr_collect_media(df, thumbnail = TRUE) thumbsize <- get_image_sizes(media_dir) expect_lt(thumbsize, fullsize) unlink(media_dir, recursive = TRUE) @@ -193,7 +191,7 @@ test_that("collect_media handles different file formats", { dplyr::group_by(multimedia) |> dplyr::sample_n(size = 1) expect_equal(sort(unique(media_data$multimedia)), - c("Image", "Image | Sound", "Sound")) + c("Image", "Image | Sound")) result <- purrr_collect_media(media_summary, thumbnail = TRUE) downloads <- list.files(path = media_dir) expect_true(any(grepl(".mpg$", downloads))) # sounds diff --git a/tests/testthat/test-atlas_species.R b/tests/testthat/test-atlas_species.R index 87af4de1..9578e9ad 100644 --- a/tests/testthat/test-atlas_species.R +++ b/tests/testthat/test-atlas_species.R @@ -22,39 +22,30 @@ test_that("atlas_species fails nicely if no email is provided", { test_that("`atlas_species()` returns a tibble", { skip_if_offline(); skip_on_ci() - species <- quiet_species(identify = galah_identify("Osphranter")) + species <- galah_call() |> + identify("Osphranter") |> + quiet_species() expect_s3_class(species, c("tbl_df", "tbl", "data.frame")) expect_gt(nrow(species), 1) }) -test_that("`select()` works for type = 'species' with no arguments", { - skip_if_offline(); skip_on_ci() - x <- galah_call(type = "species") |> - identify("Crinia") |> - select() |> - quiet_collect() - expect_equal(colnames(x), "taxon_concept_id") - expect_gt(nrow(x), 10) -}) - test_that("`select()` works for type = 'species' with `counts`", { skip_if_offline(); skip_on_ci() x <- galah_call(type = "species") |> identify("Crinia") |> select(counts) |> quiet_collect() - expect_equal(colnames(x), c("taxon_concept_id", "count")) + expect_equal(colnames(x), c("species_id", "count")) expect_gt(nrow(x), 10) }) - test_that("`select()` works for type = 'species' with group = 'taxonomy'", { skip_if_offline(); skip_on_ci() x <- galah_call(type = "species") |> identify("Crinia") |> select(counts, lists, group = "taxonomy") |> quiet_collect() - expect_true(all(c("taxon_concept_id", "count", "kingdom", "phylum") %in% colnames(x))) + expect_true(all(c("species", "count", "kingdom", "phylum") %in% colnames(x))) expect_gt(nrow(x), 10) }) @@ -70,12 +61,14 @@ test_that("`atlas_species()` returns correct results when piped", { "Perameles fasciata", "Perameles pallescens", "Perameles bougainville") - expected_cols <- c("taxon_concept_id", "species_name", + expected_cols <- c("species", "species_name", "scientific_name_authorship", "taxon_rank", "kingdom", "phylum", "class", "order", "family", "genus", "vernacular_name") expect_setequal(names(species), expected_cols) - expect_equal(species$species_name[1:5], expected_species) + (expected_species %in% species$species_name) |> + all() |> + expect_true() expect_gt(nrow(species), 1) expect_s3_class(species, c("tbl_df", "tbl", "data.frame")) }) @@ -91,7 +84,7 @@ test_that("`atlas_species()` returns correct results filtered by galah_geolocate geolocate(wkt) |> quiet_species() expected_species <- c("Perameles gunnii") - expected_cols <- c("taxon_concept_id", "species_name", + expected_cols <- c("species", "species_name", "scientific_name_authorship", "taxon_rank", "kingdom", "phylum", "class", "order", "family", "genus", "vernacular_name") @@ -110,6 +103,8 @@ test_that("`atlas_species()` works when no species are present", { filter(cl1048 == "Kimberley") |> quiet_species() expect_s3_class(result, c("tbl_df", "tbl", "data.frame")) + expect_equal(ncol(result), 11) + expect_equal(nrow(result), 0) }) test_that("collapse -> compute -> collect workflow is functional", { @@ -151,7 +146,7 @@ test_that("atlas_species reformats column names when empty tibble is returned", identify("sarcopterygii") |> filter(cl1048 == "Wet Tropics") |> quiet_species() - expected_cols <- c("taxon_concept_id", "species_name", + expected_cols <- c("species", "species_name", "scientific_name_authorship", "taxon_rank", "kingdom", "phylum", "class", "order", "family", "genus", "vernacular_name") diff --git a/tests/testthat/test-dplyr-count.R b/tests/testthat/test-dplyr-count.R index 8932a4c6..9c035123 100644 --- a/tests/testthat/test-dplyr-count.R +++ b/tests/testthat/test-dplyr-count.R @@ -270,10 +270,6 @@ test_that("order of `group_by()` doesn't affect result in `count()", { # we also expect them to have the same number of rows, and the same total expect_equal(nrow(ibra_year), nrow(year_ibra)) expect_equal(sum(ibra_year$count), sum(year_ibra$count)) - ## FIXME: - # expect_equal(ibra_year, year_ibra) # this fails, - ## because `arrange` is not (re-)applied after download, - ## so rows are not in the same order }) test_that("`group_by()` works when > 1 `filter()`", { diff --git a/tests/testthat/test-dplyr-distinct.R b/tests/testthat/test-dplyr-distinct.R index 319b5bbe..102f35ad 100644 --- a/tests/testthat/test-dplyr-distinct.R +++ b/tests/testthat/test-dplyr-distinct.R @@ -74,7 +74,7 @@ test_that("`group_by() |> distinct(.keep_all = TRUE)` converts type from occurre nrow(x)) }) -test_that("`distinct()` can be used in place of `group_by()` for species queries", { +test_that("`distinct(.keep_all = TRUE)` sets species queries", { skip_if_offline(); skip_on_ci() result <- galah_call() |> identify("Osphranter") |> @@ -94,7 +94,7 @@ test_that("`distinct(speciesID) |> count()` can be used to count the number of s distinct(taxonConceptID) |> count() |> quiet_collect() - expect_s3_class(x, + expect_s3_class(result, c("tbl_df", "tbl", "data.frame")) expect_equal(nrow(result), 1) expect_true(result$count[1] > 1 & result$count[1] < 10) diff --git a/tests/testthat/test-dplyr-filter.R b/tests/testthat/test-dplyr-filter.R index 082a57e8..6eb5b568 100644 --- a/tests/testthat/test-dplyr-filter.R +++ b/tests/testthat/test-dplyr-filter.R @@ -238,14 +238,12 @@ test_that("`filter()` handles lsid as an input", { lsid == ids) |> count() |> collapse() - # number of taxa searches is 3, not 4 expect_s3_class(query, "query") - expect_equal(length(query), 5) + expect_equal(length(query), 4) expect_equal(names(query), c("type", "url", "headers", - "filter", - "slot_name")) + "request")) }) test_that("`filter()` handles different fields separated by OR", { diff --git a/tests/testthat/test-dplyr-select.R b/tests/testthat/test-dplyr-select.R index a8ccb050..154f03bb 100644 --- a/tests/testthat/test-dplyr-select.R +++ b/tests/testthat/test-dplyr-select.R @@ -192,7 +192,7 @@ test_that("`select()` warns for invalid field names when type = 'species'", { skip_if_offline(); skip_on_ci() expect_warning({galah_call() |> identify("Crinia") |> - group_by(speciesID) |> + distinct(speciesID, .keep_all = TRUE) |> select(an_unrecognised_field_name) |> as_query()}) }) diff --git a/tests/testthat/test-galah_group_by.R b/tests/testthat/test-galah_group_by.R index 12b1d334..1ef65109 100644 --- a/tests/testthat/test-galah_group_by.R +++ b/tests/testthat/test-galah_group_by.R @@ -69,11 +69,11 @@ test_that("`count()` with `group_by()` returns an empty tibble if number of reco test_that("`count()` with `group_by()` for species returns expected output", { skip_if_offline(); skip_on_ci() - counts <- galah_call() |> + counts <- galah_call(type = "species") |> identify("Mammalia") |> filter(year == 2020) |> group_by(month) |> - count(type = "species") |> + count() |> quiet_collect() |> purrr::pluck("result") expect_s3_class(counts, c("tbl_df", "tbl", "data.frame")) diff --git a/tests/testthat/test-galah_identify.R b/tests/testthat/test-galah_identify.R index 559140fe..f56dd550 100644 --- a/tests/testthat/test-galah_identify.R +++ b/tests/testthat/test-galah_identify.R @@ -87,8 +87,8 @@ test_that("`identify()` truncates unmatched list of taxa at 3 ", { expected_messages <- c( "Matched 1 of 5 taxonomic search terms in selected atlas (Australia).\n", "4 unmatched search terms:\n", - "* \"blarghy\", \"blorp\", \"florp\" + 1 more\"\n", - "") + "• \"blarghy\", \"blorp\", \"florp\" + 1 more\"\n", + "\n") x |> purrr::pluck("messages") |> expect_equal(expected_messages) diff --git a/tests/testthat/test-galah_radius.R b/tests/testthat/test-galah_radius.R index 84b99c49..8191fb67 100644 --- a/tests/testthat/test-galah_radius.R +++ b/tests/testthat/test-galah_radius.R @@ -91,7 +91,7 @@ test_that("galah_radius messages when radius is very large", { radius <- 1600 expect_message( galah_radius(lon = 151, lat = -32, radius = radius), - "Radius is larger than the area of Australia") + "Supplied radius is larger than the area of Australia") }) test_that("galah_radius only uses first arguments supplied to lon/lat/radius", { diff --git a/tests/testthat/test-international-Flanders.R b/tests/testthat/test-international-Flanders.R index d43257ac..13f60f3c 100644 --- a/tests/testthat/test-international-Flanders.R +++ b/tests/testthat/test-international-Flanders.R @@ -139,6 +139,7 @@ test_that("search_all(taxa) works for Flanders", { skip_if(inherits(x, "try-error"), message = "API not available") expect_gte(nrow(x), 1) expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) + expect_true(any(colnames(x) == "taxon_concept_id")) }) test_that("search_all(identifiers) works for Flanders", { @@ -163,7 +164,7 @@ test_that("show_values works for fields for Flanders", { test_that("atlas_counts works for Flanders", { skip_if_offline(); skip_on_ci() x <- atlas_counts() |> - pull(count) |> + dplyr::pull(count) |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") expect_gt(x, 0) @@ -172,7 +173,7 @@ test_that("atlas_counts works for Flanders", { test_that("atlas_counts works with type = 'species' for Flanders", { skip_if_offline(); skip_on_ci() x <- atlas_counts(type = "species") |> - pull(count) |> + dplyr::pull(count) |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") expect_gt(x, 0) @@ -208,7 +209,7 @@ test_that("atlas_counts works with group_by for Flanders", { try(silent = TRUE) skip_if(inherits(result, "try-error"), message = "API not available") expect_gt(nrow(result), 1) - expect_equal(names(result), c("basis_of_record", "count")) + expect_equal(names(result), c("basisOfRecord", "count")) }) test_that("atlas_species works for Flanders", { @@ -218,7 +219,7 @@ test_that("atlas_species works for Flanders", { email = "galah@natuurdata@inbo.be", send_email = FALSE) spp <- galah_call() |> - identify("Canis") |> + identify("Corvus") |> atlas_species() |> try(silent = TRUE) skip_if(inherits(spp, "try-error"), message = "API not available") @@ -227,7 +228,7 @@ test_that("atlas_species works for Flanders", { expect_s3_class(spp, c("tbl_df", "tbl", "data.frame")) }) -test_that("atlas_occurrences works for Flanders", { +test_that("atlas_occurrences() works for Flanders", { skip_if_offline(); skip_on_ci() galah_config( atlas = "Flanders", @@ -242,7 +243,7 @@ test_that("atlas_occurrences works for Flanders", { response <- compute(query) |> try(silent = TRUE) - skip_if(inherits(occ, "try-error"), message = "API not available") + skip_if(inherits(response, "try-error"), message = "API not available") occ <- collect(response) |> try(silent = TRUE) diff --git a/tests/testthat/test-international-France.R b/tests/testthat/test-international-France.R index 0624436b..26103632 100644 --- a/tests/testthat/test-international-France.R +++ b/tests/testthat/test-international-France.R @@ -1,12 +1,15 @@ # set verbose to off galah_config(verbose = FALSE, run_checks = FALSE) +skip_message <- "French APIs disabled at source" + test_that("swapping to atlas = France works", { expect_message(galah_config(atlas = "France")) }) test_that("show_all(fields) works for France", { skip_if_offline(); skip_on_ci() + skip(skip_message) x <- show_all(fields) |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") @@ -16,6 +19,7 @@ test_that("show_all(fields) works for France", { test_that("show_all(collections) works for France", { skip_if_offline(); skip_on_ci() + skip(skip_message) x <- show_all(collections, limit = 10) |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") @@ -25,6 +29,7 @@ test_that("show_all(collections) works for France", { test_that("show_all(datasets) works for France", { skip_if_offline(); skip_on_ci() + skip(skip_message) x <- show_all(datasets, limit = 10) |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") @@ -34,6 +39,7 @@ test_that("show_all(datasets) works for France", { test_that("show_all(providers) works for France", { skip_if_offline(); skip_on_ci() + skip(skip_message) x <- show_all(providers, limit = 10) |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") @@ -42,11 +48,14 @@ test_that("show_all(providers) works for France", { }) test_that("show_all(reasons) fails for France", { + skip_if_offline(); skip_on_ci() + skip(skip_message) expect_error(show_all(reasons)) }) test_that("show_all(assertions) works for France", { skip_if_offline(); skip_on_ci() + skip(skip_message) x <- show_all(assertions) |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") @@ -55,15 +64,20 @@ test_that("show_all(assertions) works for France", { }) test_that("show_all(profiles) fails for France", { + skip_if_offline(); skip_on_ci() + skip(skip_message) expect_error(show_all(profiles)) }) test_that("show_all(lists) fails for France", { + skip_if_offline(); skip_on_ci() + skip(skip_message) expect_error(show_all(lists)) }) test_that("search_all(fields) works for France", { skip_if_offline(); skip_on_ci() + skip(skip_message) x <- search_all(fields, "year") |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") @@ -73,6 +87,7 @@ test_that("search_all(fields) works for France", { test_that("search_all(taxa) works for France", { skip_if_offline(); skip_on_ci() + skip(skip_message) x <- search_all(taxa, "Vulpes vulpes") |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") @@ -82,6 +97,7 @@ test_that("search_all(taxa) works for France", { test_that("show_values works for France", { skip_if_offline(); skip_on_ci() + skip(skip_message) x <- search_fields("basisOfRecord") |> show_values() |> try(silent = TRUE) @@ -91,6 +107,7 @@ test_that("show_values works for France", { test_that("atlas_counts works for France", { skip_if_offline(); skip_on_ci() + skip(skip_message) x <- atlas_counts() |> pull(count) |> try(silent = TRUE) @@ -100,6 +117,7 @@ test_that("atlas_counts works for France", { test_that("atlas_counts works with type = 'species' for France", { skip_if_offline(); skip_on_ci() + skip(skip_message) x <- atlas_counts(type = "species") |> pull(count) |> try(silent = TRUE) @@ -109,6 +127,7 @@ test_that("atlas_counts works with type = 'species' for France", { test_that("atlas_counts works with galah_identify for France", { skip_if_offline(); skip_on_ci() + skip(skip_message) result <- galah_call() |> identify("Mammalia") |> count() |> @@ -128,6 +147,7 @@ test_that("atlas_counts works with galah_identify for France", { test_that("atlas_counts works with group_by for France", { skip_if_offline(); skip_on_ci() + skip(skip_message) result <- galah_call() |> filter(year >= 2018) |> group_by(year) |> @@ -141,6 +161,7 @@ test_that("atlas_counts works with group_by for France", { test_that("atlas_species works for France", { skip_if_offline(); skip_on_ci() + skip(skip_message) galah_config(email = "ala4r@ala.org.au") x <- galah_call(type = "species") |> identify("Lagomorpha") |> @@ -155,6 +176,7 @@ test_that("atlas_species works for France", { test_that("atlas_occurrences works for France", { skip_if_offline(); skip_on_ci() + skip(skip_message) galah_config(atlas = "France", email = "ala4r@ala.org.au") base_query <- galah_call() |> @@ -187,4 +209,5 @@ test_that("atlas_occurrences works for France", { unlink("temp", recursive = TRUE) }) +rm(skip_message) galah_config(atlas = "Australia") diff --git a/tests/testthat/test-print.R b/tests/testthat/test-print.R index 80fe41cb..768f2079 100644 --- a/tests/testthat/test-print.R +++ b/tests/testthat/test-print.R @@ -56,6 +56,9 @@ test_that("object of class `query_set` formats correctly", { }) test_that("`galah_config()` formats correctly", { + galah_config(directory = "something") galah_config() |> expect_snapshot() + galah_config(directory = tempdir(check = TRUE)) + unlink("something", recursive = TRUE) }) \ No newline at end of file From 585530a19190df18d27272581a3553dbbf1addff Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Wed, 7 Jan 2026 16:07:01 +1100 Subject: [PATCH 58/94] remove deprecated functions and superfluous `lifecycle` badges some improvements to help files as well --- R/collect_media.R | 27 ++------------ R/dplyr-arrange.R | 2 -- R/dplyr-filter.R | 3 -- R/dplyr-slice_head.R | 2 -- R/galah-package.R | 26 +++++++------- R/galah_call.R | 64 ++++++++++------------------------ R/galah_config.R | 10 ------ R/galah_filter.R | 2 +- R/galah_geolocate.R | 1 - R/read_zip.R | 2 -- R/search_all.R | 1 - R/show_all.R | 1 - _pkgdown.yml | 28 ++++++++------- man/arrange.data_request.Rd | 2 -- man/collect_media.Rd | 6 +--- man/filter.data_request.Rd | 5 +-- man/galah.Rd | 22 ++++++------ man/galah_call.Rd | 62 ++++++++++---------------------- man/geolocate.Rd | 1 - man/read_zip.Rd | 2 -- man/search_all.Rd | 1 - man/show_all.Rd | 1 - man/slice_head.data_request.Rd | 2 -- 23 files changed, 82 insertions(+), 191 deletions(-) diff --git a/R/collect_media.R b/R/collect_media.R index bb349700..ca410986 100644 --- a/R/collect_media.R +++ b/R/collect_media.R @@ -7,12 +7,7 @@ #' @param df A `tibble` returned by `atlas_media()` or a pipe starting with #' `request_data(type = "media")`. #' @param thumbnail Default is `FALSE`. If `TRUE` will download small -#' thumbnail-sized images, rather than full size images (default). -#' @param path -#' `r lifecycle::badge("deprecated")` -#' Use `galah_config(directory = "path-to-directory)"` instead. Supply a path -#' to a local folder/directory where downloaded media will be saved to. -#' +#' thumbnail-sized images, rather than full size images (default). #' @return Invisibly returns a `tibble` listing the number of files downloaded, #' grouped by their HTML status codes. Primarily called for the side effect of #' downloading available image & media files to a user local directory. @@ -49,24 +44,8 @@ #' } #' @export collect_media <- function(df, - thumbnail = FALSE, - path - ){ - if (!missing(path)) { - lifecycle::deprecate_stop( - when = "2.0.0", - what = "collect_media(path = )", - details = "Use `galah_config(directory = )` to supply a folder path instead." - ) - } - - # suggest option to set directory in galah_config() - user_directory <- potions::pour("package", "directory") - if (stringr::str_detect(user_directory, "Temp")) { - cli::col_magenta("To change which file directory media files are saved to, use `galah_config(directory = )`.") |> - cli::cli_text() - } - + thumbnail = FALSE + ){ request_files() |> filter("media" == df) |> collapse(thumbnail = thumbnail) |> diff --git a/R/dplyr-arrange.R b/R/dplyr-arrange.R index 3f321e0e..73be6878 100644 --- a/R/dplyr-arrange.R +++ b/R/dplyr-arrange.R @@ -1,8 +1,6 @@ #' Order rows using column values #' #' @description -#' `r lifecycle::badge("experimental")` -#' #' `arrange.data_request()` arranges rows of a query on the server side, meaning #' that the query is constructed in such a way that information will be arranged #' when the query is processed. This only has an effect when used in combination diff --git a/R/dplyr-filter.R b/R/dplyr-filter.R index bc292882..5724d795 100644 --- a/R/dplyr-filter.R +++ b/R/dplyr-filter.R @@ -14,9 +14,6 @@ #' of the variables in the selected atlas (and checked using `show_all(fields)`. #' If multiple expressions are included, they are combined with the & operator. #' Only rows for which all conditions evaluate to `TRUE` are kept. -#' @param profile -#' `r lifecycle::badge("deprecated")` -#' Use `galah_apply_profile` instead. #' @return A tibble containing filter values. #' @seealso \code{\link[=select.data_request]{select()}}, #' \code{\link[=group_by.data_request]{group_by()}} and [geolocate()] for diff --git a/R/dplyr-slice_head.R b/R/dplyr-slice_head.R index 560716d5..07696530 100644 --- a/R/dplyr-slice_head.R +++ b/R/dplyr-slice_head.R @@ -1,8 +1,6 @@ #' Subset rows using their positions #' #' @description -#' `r lifecycle::badge("experimental")` -#' #' `slice()` lets you index rows by their (integer) locations. For objects of #' classes `data_request` or `metadata_request`, only `slice_head()` is #' currently implemented, and selects the first `n` rows. diff --git a/R/galah-package.R b/R/galah-package.R index d96c64c5..32cc52bc 100644 --- a/R/galah-package.R +++ b/R/galah-package.R @@ -3,9 +3,9 @@ #' @description #' The Global Biodiversity Information Facility (GBIF; ) #' provides tools to enable users to find, access, combine and visualise -#' biodiversity data. `galah` enables the R community to directly access data and -#' resources hosted by GBIF and several of it's subsidiary organisations, known -#' as 'nodes'. +#' biodiversity data. `galah` is a `dplyr` extension package that enables the R +#' community to directly access data and resources hosted by GBIF and several +#' of it's subsidiary organisations (known as 'nodes') using `dplyr` verbs. #' #' The basic unit of data stored by these infrastructures is #' an **occurrence** record, which is an observation of a biological entity at @@ -13,8 +13,8 @@ #' taxonomic information, or associated media such images or sounds, #' all while restricting their queries to particular taxa or locations. Users #' can specify which columns are returned by a query, or restrict their results -#' to observations that meet particular quality-control criteria. -#' +#' to observations that meet particular quality-control criteria. +#' #' For those outside Australia, 'galah' is the common name of #' *Eolophus roseicapilla*, a widely-distributed Australian bird species. #' @name galah @@ -24,16 +24,14 @@ #' **Getting Started** #' #' * [galah_config()] Set package configuration options -#' * [galah_call()]/\code{\link[=request_data]{request_()}} Start to build a query -#' * [show_all()] & [search_all()] Data for generating filter queries -#' * [show_values()] & [search_values()] Show or search for values _within_ -#' `fields`, `profiles`, `lists`, `collections`, `datasets` or `providers` +#' * [galah_call()]/\code{\link[=request_data]{request_()}} Start to build a request #' -#' **Update a data request** +#' **Update a request object** #' -#' * [apply_profile()] Restrict to data that pass predefined checks (ALA only) +#' * [apply_profile()] Restrict to data that pass predefined checks #' * \code{\link[=arrange.data_request]{arrange()}} Arrange rows of a query on the server side #' * \code{\link[=count.data_request]{count()}} Request counts of the specified data type +#' * \code{\link[=distinct.data_request]{distinct()}} Keep distinct/unique rows #' * \code{\link[=filter.data_request]{filter()}} Filter records (see also \code{\link[=filter_object_classes]{filter_object_classes}})) #' * [geolocate()] Spatial filtering of a query #' * \code{\link[=group_by.data_request]{group_by()}} Group counts by one or more fields @@ -44,14 +42,16 @@ #' #' **Create and execute a query** #' -#' * [as_query()] Represent a `data_request` as a `query` object +#' * [as_query()] Convert a request into a `query` object #' * [coalesce()] Convert a `data_request` or `query` into a `query_set` showing all calls needed for evaluation #' * \code{\link[=collapse.data_request]{collapse()}} Convert an object to a valid `query` #' * \code{\link[=compute.data_request]{compute()}} Compute a query -#' * \code{\link[=collect.data_request]{collect()}}/\code{\link[=atlas_]{atlas_()}}/[collect_media()] Retrieve a database query +#' * \code{\link[=collect.data_request]{collect()}} Retrieve a database query #' #' **Wrappers for accessing data** #' +#' * [show_all()] & [search_all()] Data for generating filter queries +#' * [show_values()] & [search_values()] Show or search for values _within_ `fields`, `profiles`, `lists`, `collections`, `datasets` or `providers` #' * [atlas_occurrences()] Download occurrence data #' * [atlas_counts()] Get a summary of the number of records or species #' * [atlas_species()] Download occurrences grouped by `speciesID` diff --git a/R/galah_call.R b/R/galah_call.R index 20eb851b..afbf1ffd 100644 --- a/R/galah_call.R +++ b/R/galah_call.R @@ -1,4 +1,4 @@ -#' Start building a query +#' Start building a request #' #' @description #' To download data from the selected atlas, one must construct a query. This @@ -11,7 +11,7 @@ #' @param type string: what form of data should be returned? Acceptable values #' are specified by the corresponding `request` function #' @details -#' In practice, [galah_call()] is a wrapper to a group of underlying +#' [galah_call()] is a wrapper to a group of underlying #' `request_` functions, selected using the `method` argument. #' Each of these functions can begin a piped query, which is then actioned using #' \code{\link[=collect.data_request]{collect()}}, or optionally one of the @@ -20,13 +20,7 @@ #' \code{vignette("object_oriented_programming", package = "galah")} #' #' Accepted values of the `type` argument are set by the underlying `request_` -#' functions. While all accepted types can be set directly, some are affected -#' by later functions. The most common example is that adding -#' \code{\link[=count.data_request]{count()}} to a pipe updates `type`, -#' converting `type = "occurrences"` to `type = "occurrences-count"` (and ditto -#' for `type = "species"`). -#' -#' The underlying `request_` functions are useful because they allow `galah` +#' functions. These functions are useful because they allow `galah` #' to separate different types of requests to perform better. For example, #' \code{\link[=filter.data_request]{filter.data_request()}} translates filters #' to `solr` syntax for the living atlases, or to predicates for GBIF, whereas @@ -38,19 +32,19 @@ #' - [request_metadata()] returns class `"metadata_request"` #' - [request_files()] returns class `"files_request"` #' -#' -#' These objects are list-like and contain the following slots: -#' -#' - `filter`: edit by piping \code{\link[=filter.data_request]{filter()}} or [galah_filter()]. -#' - `select`: edit by piping \code{\link[=filter.data_request]{select}} or [galah_select()]. -#' - `group_by`: edit by piping \code{\link[=group_by.data_request]{group_by()}} or [galah_group_by()]. -#' - `identify`: edit by piping \code{\link[=identify.data_request]{identify()}} or [galah_identify()]. -#' - `geolocate`: edit by piping \code{\link[=st_crop.data_request]{st_crop()}}, -#' [galah_geolocate()], [galah_polygon()] or [galah_bbox()]. -#' - `limit`: edit by piping \code{\link[=slice_head.data_request]{slice_head()}}. -#' - `doi`: edit by piping \code{\link[=filter.data_request]{filter(doi == "my-doi-here")}}. +#' These objects are list-like and store later dplyr verbs in the order +#' they are provided. #' -#' @seealso For operations on `_request` objects, see +#' @seealso To amend a request object, use [apply_profile()], +#' \code{\link[=arrange.data_request]{arrange()}}, +#' \code{\link[=count.data_request]{count()}}, +#' \code{\link[=distinct.data_request]{distinct()}}, +#' \code{\link[=filter.data_request]{filter()}}, +#' \code{\link[=group_by.data_request]{group_by()}}, +#' \code{\link[=identify.data_request]{identify()}}, +#' \code{\link[=select.data_request]{select}}, +#' \code{\link[=slice_head.data_request]{slice_head()}} or [unnest()]. +#' For operations on `_request` objects, see #' \code{\link[=as_query.data_request]{as_query()}}, #' [coalesce()], #' \code{\link[=collapse.data_request]{collapse()}}, @@ -65,12 +59,14 @@ #' identify("Aves") |> #' filter(year > 2000 & year < 2005) |> #' group_by(year) |> -#' atlas_counts() +#' count() |> +#' collect() #' #' # Get information for all species in *Cacatuidae* family #' galah_call() |> #' identify("Cacatuidae") |> -#' atlas_species() +#' distinct("speciesID", .keep_all = TRUE) |> +#' collect() #' #' # Download records of genus *Eolophus* from 2001 to 2004 #' galah_config(email = "your-email@email.com") @@ -78,29 +74,7 @@ #' galah_call() |> #' identify("Eolophus") |> #' filter(year > 2000 & year < 2005) |> -#' atlas_occurrences() # synonymous with `collect()` -#' -#' -#' # galah_call() is a wrapper to various `request_` functions. -#' # These can be called directly for greater specificity. -#' -#' # Get number of records of *Aves* from 2001 to 2004 by year -#' request_data() |> -#' identify("Aves") |> -#' filter(year > 2000 & year < 2005) |> -#' group_by(year) |> -#' count() |> -#' collect() -#' -#' # Get information for all species in *Cacatuidae* family -#' request_data(type = "species") |> -#' identify("Cacatuidae") |> #' collect() -#' -#' # Get metadata information about supported atlases in galah -#' request_metadata(type = "atlases") |> -#' collect() -#' #' } #' @export galah_call <- function(method = c("data", diff --git a/R/galah_config.R b/R/galah_config.R index 1738e214..730b25ea 100644 --- a/R/galah_config.R +++ b/R/galah_config.R @@ -88,16 +88,6 @@ galah_config <- function(...) { # add user-provided information if(length(dots) > 0){ - # check for deprecated `cache_directory` - if(any(names(dots) == "cache_directory")){ - dots_location <- which(names(dots) == "cache_directory") - value <- dots$cache_directory - lifecycle::deprecate_warn(when = "2.0.0", - what = "galah_config(cache_directory)", - details = glue::glue("Use `galah_config(directory = \"{value}\")` instead.") - ) - names(dots)[dots_location] <- "directory" - } # add exception so that people can supply a named list to `galah_config()` # this avoids calling things like: diff --git a/R/galah_filter.R b/R/galah_filter.R index 4127f80c..2c22e9e5 100644 --- a/R/galah_filter.R +++ b/R/galah_filter.R @@ -1,7 +1,7 @@ #' @rdname filter.data_request #' @order 4 #' @export -galah_filter <- function(..., profile = NULL){ +galah_filter <- function(...){ dots <- rlang::enquos(..., .ignore_empty = "all") |> detect_request_object() check_named_input(dots) diff --git a/R/galah_geolocate.R b/R/galah_geolocate.R index 05a88a59..7f81a5e6 100644 --- a/R/galah_geolocate.R +++ b/R/galah_geolocate.R @@ -21,7 +21,6 @@ #' a shapefile. A bounding box can also be supplied as a `bbox` object #' (via `sf::st_bbox()`) or a `tibble`/`data.frame`. #' -#' `r lifecycle::badge("experimental")` #' **Use a point radius** #' Alternatively, set `type = "radius"` to narrow queries to within a circular #' area around a specific point location. Point coordinates can be supplied as diff --git a/R/read_zip.R b/R/read_zip.R index 570b511d..d9d09fd3 100644 --- a/R/read_zip.R +++ b/R/read_zip.R @@ -1,8 +1,6 @@ #' Read downloaded data from a zip file #' #' @description -#' `r lifecycle::badge("experimental")` -#' #' Living atlases supply data downloads as zip files. This function reads these #' data efficiently, i.e. without unzipping them first, using the `readr` #' package. Although this function has been part of galah for some time, it was diff --git a/R/search_all.R b/R/search_all.R index b4617745..d7b5b061 100644 --- a/R/search_all.R +++ b/R/search_all.R @@ -13,7 +13,6 @@ #' **For more information about taxonomic searches using `search_taxa()`, see** #' \code{\link[=taxonomic_searches]{?taxonomic_searches}}. #' -#' `r lifecycle::badge("stable")` #' `search_all()` is a helper function that can do searches for multiple #' types of information, acting as a wrapper around many `search_` sub-functions. #' See `Details` (below) for accepted values. diff --git a/R/show_all.R b/R/show_all.R index ce56b402..788704e6 100644 --- a/R/show_all.R +++ b/R/show_all.R @@ -9,7 +9,6 @@ #' Functions prefixed with `show_all_` do this, displaying all valid options #' for the information specified by the suffix. #' -#' `r lifecycle::badge("stable")` #' `show_all()` is a helper function that can display multiple types of #' information from `show_all_` sub-functions. #' @param ... String showing what type of information is to be requested. See diff --git a/_pkgdown.yml b/_pkgdown.yml index 6cbe5c13..677a776e 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -61,23 +61,12 @@ reference: contents: - galah_config - galah_call - - show_all - - search_all - - search_taxa - - search_identifiers - - search_fields - - search_values - - show_all_profiles - - show_all_fields - - show_all_atlases - - show_all_reasons - - show_all_ranks - - show_values -- title: Update a data request +- title: Update a request object contents: - apply_profile - arrange.data_request - count.data_request + - distinct.data_request - filter.data_request - geolocate - group_by.data_request @@ -85,6 +74,7 @@ reference: - select.data_request - slice_head.data_request - reexports + - unnest - title: Create and execute a query contents: - as_query.data_request @@ -94,6 +84,18 @@ reference: - collect.data_request - title: Wrappers for accessing data contents: + - show_all + - search_all + - search_taxa + - search_identifiers + - search_fields + - search_values + - show_all_profiles + - show_all_fields + - show_all_atlases + - show_all_reasons + - show_all_ranks + - show_values - atlas_occurrences - atlas_counts - atlas_species diff --git a/man/arrange.data_request.Rd b/man/arrange.data_request.Rd index e332fefe..f8b1e693 100644 --- a/man/arrange.data_request.Rd +++ b/man/arrange.data_request.Rd @@ -19,8 +19,6 @@ the variables also listed in \code{\link[=group_by.data_request]{group_by()}}.} An amended \code{data_request} with a completed \code{arrange} slot. } \description{ -\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}} - \code{arrange.data_request()} arranges rows of a query on the server side, meaning that the query is constructed in such a way that information will be arranged when the query is processed. This only has an effect when used in combination diff --git a/man/collect_media.Rd b/man/collect_media.Rd index a2e70cf7..7d67b54a 100644 --- a/man/collect_media.Rd +++ b/man/collect_media.Rd @@ -4,7 +4,7 @@ \alias{collect_media} \title{Collect media files} \usage{ -collect_media(df, thumbnail = FALSE, path) +collect_media(df, thumbnail = FALSE) } \arguments{ \item{df}{A \code{tibble} returned by \code{atlas_media()} or a pipe starting with @@ -12,10 +12,6 @@ collect_media(df, thumbnail = FALSE, path) \item{thumbnail}{Default is \code{FALSE}. If \code{TRUE} will download small thumbnail-sized images, rather than full size images (default).} - -\item{path}{\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#deprecated}{\figure{lifecycle-deprecated.svg}{options: alt='[Deprecated]'}}}{\strong{[Deprecated]}} -Use \verb{galah_config(directory = "path-to-directory)"} instead. Supply a path -to a local folder/directory where downloaded media will be saved to.} } \value{ Invisibly returns a \code{tibble} listing the number of files downloaded, diff --git a/man/filter.data_request.Rd b/man/filter.data_request.Rd index db022755..2a2f236c 100644 --- a/man/filter.data_request.Rd +++ b/man/filter.data_request.Rd @@ -13,7 +13,7 @@ \method{filter}{files_request}(.data, ...) -galah_filter(..., profile = NULL) +galah_filter(...) } \arguments{ \item{.data}{An object of class \code{data_request}, \code{metadata_request} @@ -23,9 +23,6 @@ or \code{files_request}, created using \code{\link[=galah_call]{galah_call()}} o of the variables in the selected atlas (and checked using \code{show_all(fields)}. If multiple expressions are included, they are combined with the & operator. Only rows for which all conditions evaluate to \code{TRUE} are kept.} - -\item{profile}{\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#deprecated}{\figure{lifecycle-deprecated.svg}{options: alt='[Deprecated]'}}}{\strong{[Deprecated]}} -Use \code{galah_apply_profile} instead.} } \value{ A tibble containing filter values. diff --git a/man/galah.Rd b/man/galah.Rd index 9708b1d2..5d4bb4f9 100644 --- a/man/galah.Rd +++ b/man/galah.Rd @@ -8,9 +8,9 @@ \description{ The Global Biodiversity Information Facility (GBIF; \url{https://www.gbif.org}) provides tools to enable users to find, access, combine and visualise -biodiversity data. \code{galah} enables the R community to directly access data and -resources hosted by GBIF and several of it's subsidiary organisations, known -as 'nodes'. +biodiversity data. \code{galah} is a \code{dplyr} extension package that enables the R +community to directly access data and resources hosted by GBIF and several +of it's subsidiary organisations (known as 'nodes') using \code{dplyr} verbs. The basic unit of data stored by these infrastructures is an \strong{occurrence} record, which is an observation of a biological entity at @@ -29,17 +29,15 @@ For those outside Australia, 'galah' is the common name of \strong{Getting Started} \itemize{ \item \code{\link[=galah_config]{galah_config()}} Set package configuration options -\item \code{\link[=galah_call]{galah_call()}}/\code{\link[=request_data]{request_()}} Start to build a query -\item \code{\link[=show_all]{show_all()}} & \code{\link[=search_all]{search_all()}} Data for generating filter queries -\item \code{\link[=show_values]{show_values()}} & \code{\link[=search_values]{search_values()}} Show or search for values \emph{within} -\code{fields}, \code{profiles}, \code{lists}, \code{collections}, \code{datasets} or \code{providers} +\item \code{\link[=galah_call]{galah_call()}}/\code{\link[=request_data]{request_()}} Start to build a request } -\strong{Update a data request} +\strong{Update a request object} \itemize{ -\item \code{\link[=apply_profile]{apply_profile()}} Restrict to data that pass predefined checks (ALA only) +\item \code{\link[=apply_profile]{apply_profile()}} Restrict to data that pass predefined checks \item \code{\link[=arrange.data_request]{arrange()}} Arrange rows of a query on the server side \item \code{\link[=count.data_request]{count()}} Request counts of the specified data type +\item \code{\link[=distinct.data_request]{distinct()}} Keep distinct/unique rows \item \code{\link[=filter.data_request]{filter()}} Filter records (see also \code{\link[=filter_object_classes]{filter_object_classes}})) \item \code{\link[=geolocate]{geolocate()}} Spatial filtering of a query \item \code{\link[=group_by.data_request]{group_by()}} Group counts by one or more fields @@ -51,15 +49,17 @@ For those outside Australia, 'galah' is the common name of \strong{Create and execute a query} \itemize{ -\item \code{\link[=as_query]{as_query()}} Represent a \code{data_request} as a \code{query} object +\item \code{\link[=as_query]{as_query()}} Convert a request into a \code{query} object \item \code{\link[=coalesce]{coalesce()}} Convert a \code{data_request} or \code{query} into a \code{query_set} showing all calls needed for evaluation \item \code{\link[=collapse.data_request]{collapse()}} Convert an object to a valid \code{query} \item \code{\link[=compute.data_request]{compute()}} Compute a query -\item \code{\link[=collect.data_request]{collect()}}/\code{\link[=atlas_]{atlas_()}}/\code{\link[=collect_media]{collect_media()}} Retrieve a database query +\item \code{\link[=collect.data_request]{collect()}} Retrieve a database query } \strong{Wrappers for accessing data} \itemize{ +\item \code{\link[=show_all]{show_all()}} & \code{\link[=search_all]{search_all()}} Data for generating filter queries +\item \code{\link[=show_values]{show_values()}} & \code{\link[=search_values]{search_values()}} Show or search for values \emph{within} \code{fields}, \code{profiles}, \code{lists}, \code{collections}, \code{datasets} or \code{providers} \item \code{\link[=atlas_occurrences]{atlas_occurrences()}} Download occurrence data \item \code{\link[=atlas_counts]{atlas_counts()}} Get a summary of the number of records or species \item \code{\link[=atlas_species]{atlas_species()}} Download occurrences grouped by \code{speciesID} diff --git a/man/galah_call.Rd b/man/galah_call.Rd index 67bc89cc..4dc83365 100644 --- a/man/galah_call.Rd +++ b/man/galah_call.Rd @@ -5,7 +5,7 @@ \alias{request_data} \alias{request_metadata} \alias{request_files} -\title{Start building a query} +\title{Start building a request} \usage{ galah_call(method = c("data", "metadata", "files"), type) @@ -37,17 +37,8 @@ Each sub-function returns a different object class: \item \code{\link[=request_files]{request_files()}} returns class \code{"files_request"} } -These objects are list-like and contain the following slots: -\itemize{ -\item \code{filter}: edit by piping \code{\link[=filter.data_request]{filter()}} or \code{\link[=galah_filter]{galah_filter()}}. -\item \code{select}: edit by piping \code{\link[=filter.data_request]{select}} or \code{\link[=galah_select]{galah_select()}}. -\item \code{group_by}: edit by piping \code{\link[=group_by.data_request]{group_by()}} or \code{\link[=galah_group_by]{galah_group_by()}}. -\item \code{identify}: edit by piping \code{\link[=identify.data_request]{identify()}} or \code{\link[=galah_identify]{galah_identify()}}. -\item \code{geolocate}: edit by piping \code{\link[=st_crop.data_request]{st_crop()}}, -\code{\link[=galah_geolocate]{galah_geolocate()}}, \code{\link[=galah_polygon]{galah_polygon()}} or \code{\link[=galah_bbox]{galah_bbox()}}. -\item \code{limit}: edit by piping \code{\link[=slice_head.data_request]{slice_head()}}. -\item \code{doi}: edit by piping \code{\link[=filter.data_request]{filter(doi == "my-doi-here")}}. -} +These objects are list-like and store later dplyr verbs in the order +they are provided. } \description{ To download data from the selected atlas, one must construct a query. This @@ -57,7 +48,7 @@ download data, in the same way that you would wrangle data with \code{dplyr} and the \code{tidyverse}. } \details{ -In practice, \code{\link[=galah_call]{galah_call()}} is a wrapper to a group of underlying +\code{\link[=galah_call]{galah_call()}} is a wrapper to a group of underlying \code{request_} functions, selected using the \code{method} argument. Each of these functions can begin a piped query, which is then actioned using \code{\link[=collect.data_request]{collect()}}, or optionally one of the @@ -66,13 +57,7 @@ details see the object-oriented programming vignette: \code{vignette("object_oriented_programming", package = "galah")} Accepted values of the \code{type} argument are set by the underlying \code{request_} -functions. While all accepted types can be set directly, some are affected -by later functions. The most common example is that adding -\code{\link[=count.data_request]{count()}} to a pipe updates \code{type}, -converting \code{type = "occurrences"} to \code{type = "occurrences-count"} (and ditto -for \code{type = "species"}). - -The underlying \code{request_} functions are useful because they allow \code{galah} +functions. These functions are useful because they allow \code{galah} to separate different types of requests to perform better. For example, \code{\link[=filter.data_request]{filter.data_request()}} translates filters to \code{solr} syntax for the living atlases, or to predicates for GBIF, whereas @@ -88,12 +73,14 @@ galah_call() |> identify("Aves") |> filter(year > 2000 & year < 2005) |> group_by(year) |> - atlas_counts() + count() |> + collect() # Get information for all species in *Cacatuidae* family galah_call() |> identify("Cacatuidae") |> - atlas_species() + distinct("speciesID", .keep_all = TRUE) |> + collect() # Download records of genus *Eolophus* from 2001 to 2004 galah_config(email = "your-email@email.com") @@ -101,32 +88,19 @@ galah_config(email = "your-email@email.com") galah_call() |> identify("Eolophus") |> filter(year > 2000 & year < 2005) |> - atlas_occurrences() # synonymous with `collect()` - - -# galah_call() is a wrapper to various `request_` functions. -# These can be called directly for greater specificity. - -# Get number of records of *Aves* from 2001 to 2004 by year -request_data() |> - identify("Aves") |> - filter(year > 2000 & year < 2005) |> - group_by(year) |> - count() |> - collect() - -# Get information for all species in *Cacatuidae* family -request_data(type = "species") |> - identify("Cacatuidae") |> collect() - -# Get metadata information about supported atlases in galah -request_metadata(type = "atlases") |> - collect() - } } \seealso{ +To amend a request object, use \code{\link[=apply_profile]{apply_profile()}}, +\code{\link[=arrange.data_request]{arrange()}}, +\code{\link[=count.data_request]{count()}}, +\code{\link[=distinct.data_request]{distinct()}}, +\code{\link[=filter.data_request]{filter()}}, +\code{\link[=group_by.data_request]{group_by()}}, +\code{\link[=identify.data_request]{identify()}}, +\code{\link[=select.data_request]{select}}, +\code{\link[=slice_head.data_request]{slice_head()}} or \code{\link[=unnest]{unnest()}}. For operations on \verb{_request} objects, see \code{\link[=as_query.data_request]{as_query()}}, \code{\link[=coalesce]{coalesce()}}, diff --git a/man/geolocate.Rd b/man/geolocate.Rd index f38e186b..d497b709 100644 --- a/man/geolocate.Rd +++ b/man/geolocate.Rd @@ -64,7 +64,6 @@ box. Bounding boxes can be extracted from a supplied \code{sf} object or a shapefile. A bounding box can also be supplied as a \code{bbox} object (via \code{sf::st_bbox()}) or a \code{tibble}/\code{data.frame}. -\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}} \strong{Use a point radius} Alternatively, set \code{type = "radius"} to narrow queries to within a circular area around a specific point location. Point coordinates can be supplied as diff --git a/man/read_zip.Rd b/man/read_zip.Rd index 3d0a206a..d5428cba 100644 --- a/man/read_zip.Rd +++ b/man/read_zip.Rd @@ -11,8 +11,6 @@ read_zip(file) \code{.zip}.} } \description{ -\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}} - Living atlases supply data downloads as zip files. This function reads these data efficiently, i.e. without unzipping them first, using the \code{readr} package. Although this function has been part of galah for some time, it was diff --git a/man/search_all.Rd b/man/search_all.Rd index ce7f41db..8f491d77 100644 --- a/man/search_all.Rd +++ b/man/search_all.Rd @@ -77,7 +77,6 @@ suffix. \strong{For more information about taxonomic searches using \code{search_taxa()}, see} \code{\link[=taxonomic_searches]{?taxonomic_searches}}. -\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#stable}{\figure{lifecycle-stable.svg}{options: alt='[Stable]'}}}{\strong{[Stable]}} \code{search_all()} is a helper function that can do searches for multiple types of information, acting as a wrapper around many \code{search_} sub-functions. See \code{Details} (below) for accepted values. diff --git a/man/show_all.Rd b/man/show_all.Rd index 8b2ad414..4fd8ba41 100644 --- a/man/show_all.Rd +++ b/man/show_all.Rd @@ -67,7 +67,6 @@ options or categories for the type of information they are interested in. Functions prefixed with \code{show_all_} do this, displaying all valid options for the information specified by the suffix. -\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#stable}{\figure{lifecycle-stable.svg}{options: alt='[Stable]'}}}{\strong{[Stable]}} \code{show_all()} is a helper function that can display multiple types of information from \code{show_all_} sub-functions. } diff --git a/man/slice_head.data_request.Rd b/man/slice_head.data_request.Rd index 7e210423..0b712be9 100644 --- a/man/slice_head.data_request.Rd +++ b/man/slice_head.data_request.Rd @@ -26,8 +26,6 @@ performed on each group.} An amended \code{data_request} with a completed \code{slice} slot. } \description{ -\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}} - \code{slice()} lets you index rows by their (integer) locations. For objects of classes \code{data_request} or \code{metadata_request}, only \code{slice_head()} is currently implemented, and selects the first \code{n} rows. From 419f52b760901630b89ce018b164b2b24d3710ce Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Thu, 8 Jan 2026 15:17:20 +1100 Subject: [PATCH 59/94] Start updating documentation for version 2.2.0 - remove unnecessary vignettes - update Quick Start Guide - support use of `filter()` for metadata functions that use`unnest()` --- NEWS.md | 20 +- R/as_query-metadata.R | 12 +- R/collapse_metadata.R | 2 + R/collect_metadata.R | 14 +- R/dplyr-filter.R | 14 +- R/galah_identify.R | 3 +- R/show_values.R | 2 +- R/utilities_internal.R | 18 + _pkgdown.yml | 24 +- man/figures/atlases_plot.png | Bin 301630 -> 336204 bytes man/filter.data_request.Rd | 14 +- man/identify.data_request.Rd | 2 +- man/show_values.Rd | 2 +- tests/testthat/test-request_metadata_unnest.R | 45 ++- vignettes/atlas_stats.csv | 11 - vignettes/atlases_plot.R | 18 +- vignettes/choosing_an_atlas.Rmd | 51 --- vignettes/download_data.Rmd | 280 -------------- vignettes/download_data.Rmd.orig | 175 --------- vignettes/narrow_your_results.Rmd | 366 ------------------ vignettes/narrow_your_results.Rmd.orig | 208 ---------- vignettes/precompile.R | 31 -- vignettes/quick_start_guide.Rmd | 308 +++++++++------ vignettes/quick_start_guide.Rmd.orig | 198 ++++++---- 24 files changed, 441 insertions(+), 1377 deletions(-) delete mode 100644 vignettes/atlas_stats.csv delete mode 100644 vignettes/choosing_an_atlas.Rmd delete mode 100644 vignettes/download_data.Rmd delete mode 100644 vignettes/download_data.Rmd.orig delete mode 100644 vignettes/narrow_your_results.Rmd delete mode 100644 vignettes/narrow_your_results.Rmd.orig diff --git a/NEWS.md b/NEWS.md index 165be80d..3146e3cb 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,30 +1,32 @@ # galah 2.2.0 ### Improved organisational support -* `filter()` now builds predicate queries natively when atlas is set to `GBIF` +* `filter()` now builds predicate queries natively when atlas is set to `GBIF`. Filter now uses an object-oriented workflow. * DOIs now supported for `GBIF` * Kew gardens and Flanders living atlases added +* authentication supported for ALA users within `galah_config()` ### New functions * `dplyr::distinct()` can be used to find grouped data and summaries, generalising `atlas_species()` * new functions `as_query()` and `coalesce()` as prequels to `collapse()` -### Major changes -* authentication supported for ALA users within `galah_config()` -* media functions have been updated and have their own vignette; fields returned have changed -* all metadata requests now accept `select()`; all `show_all()` and `search_all()` functions gain an `all_fields` argument +## Changes to metadata functions +* all metadata requests now accept `select()` +* metadata types that support `unnest()` now also support `filter()` when unnest is not supplied +* all `show_all()` and `search_all()` functions gain an `all_fields` argument +* metadata now supports list-columns where the API returns nested data +* metadata functions now return columns names in `snake_case` rather than `camelCase` +* all metadata functions support caching, and are affected by re-introduced `caching` argument in `galah_config()` (set to `TRUE` by default) +* media metadata now uses a different API to return more relevant information ### Minor and internal changes, bug fixes * Move to `testthat` 3rd edition for improved test functionality * move to `{cli}` for `print()` calls, not `cat()` * reduce usage of `@importFrom` in favour of `pkg::fun()` syntax, as per R style guide -* Object-oriented workflow for handling `filter()` requests and printing -* metadata now supports list-columns where the API returns nested data -* metadata functions now return columns names in `snake_case` rather than `camelCase` * `basisOfRecord` now included as default field (i.e. with `select(group = "basic")`) (#281) -* all metadata functions support caching, and are affected by re-introduced `caching` argument in `galah_config()` (set to `TRUE` by default) * `query` objects now have a `request` slot showing the request that generated them + # galah 2.1.2 ### Minor improvements diff --git a/R/as_query-metadata.R b/R/as_query-metadata.R index f0dae529..1b1fc8a7 100644 --- a/R/as_query-metadata.R +++ b/R/as_query-metadata.R @@ -177,9 +177,15 @@ as_query_lists <- function(x, dr_values <- x$filter$value[dr_lookup] base_url <- url_lookup(query_type) url <- glue::glue("{base_url}/{dr_values}") - result <- list(type = query_type, - url = tibble::tibble(url = url), # note: tibbles are used to skip pagination in `collapse()` - headers = build_headers()) + if(length(url) > 1){ + result <- list(type = query_type, + url = tibble::tibble(url = url), # note: tibbles are used to skip pagination in `collapse()` + headers = build_headers()) + }else{ + result <- list(type = query_type, + url = url, + headers = build_headers()) + } }else{ cli::cli_abort(c("`filter()` arguments to `lists` only accept a data resource number", i = "e.g. request_metadata() |> filter(lists == 'dr656')"), diff --git a/R/collapse_metadata.R b/R/collapse_metadata.R index b280f06e..47655f93 100644 --- a/R/collapse_metadata.R +++ b/R/collapse_metadata.R @@ -9,6 +9,8 @@ collapse_lists <- function(.query){ .query }else if(inherits(.query$url, "tbl_df")){ .query + }else if(stringr::str_detect(.query$url, "[:digit:]+$")){ + .query }else{ url <- httr2::url_parse(.query$url) n_requested <- as.integer(url$query$max) diff --git a/R/collect_metadata.R b/R/collect_metadata.R index 21e2e41d..f279d570 100644 --- a/R/collect_metadata.R +++ b/R/collect_metadata.R @@ -282,7 +282,9 @@ collect_fields <- function(.query){ result_df <- update_attributes(result_df, type = "fields") update_cache(fields = result_df) } - parse_select(result_df, .query) + result_df |> + parse_select(.query) |> + parse_filter(.query) } #' Internal function to `collect()` licences @@ -337,9 +339,9 @@ collect_lists <- function(.query){ should_update_cache <- TRUE }else{ lists_slot <- purrr::pluck(result, "lists") - # single list queries that use `filter()` don't have a `lists` slot + # single-list queries that use `filter()` don't have a `lists` slot if(is.null(lists_slot)){ - result_df <- result[[1]] |> + result_df <- result |> make_nulls_safe() |> tibble::as_tibble() # but some queries do @@ -349,7 +351,7 @@ collect_lists <- function(.query){ should_update_cache <- TRUE } } - + # cleaning result_df <- result_df |> dplyr::rename_with(camel_to_snake_case) |> @@ -403,7 +405,9 @@ collect_profiles <- function(.query){ update_attributes(type = "profiles") update_cache(profiles = result_df) } - parse_select(result_df, .query) + result_df |> + parse_select(.query) |> + parse_filter(.query) } #' Internal function to `collect()` providers diff --git a/R/dplyr-filter.R b/R/dplyr-filter.R index 5724d795..dc969c40 100644 --- a/R/dplyr-filter.R +++ b/R/dplyr-filter.R @@ -24,8 +24,8 @@ #' #' *Syntax* #' -#' `filter.data_request()` and `galah_filter()` uses non-standard evaluation -#' (NSE), and are designed to be as compatible as possible with +#' `filter()` uses non-standard evaluation +#' (NSE), and is designed to be as compatible as possible with #' `dplyr::filter()` syntax. Permissible examples include: #' #' * `==` (e.g. `year = 2020`) but not `=` (for consistency with `dplyr`) @@ -34,7 +34,6 @@ #' * `<` or `<=` (e.g. `year <= 2020`) #' * `OR` statements (e.g. `year == 2018 | year == 2020`) #' * `AND` statements (e.g. `year >= 2000 & year <= 2020`) -#' * Field names can be parsed from objects using `{{}}` syntax, e.g. `field <- "year"; value <- "2025"; galah_filter({{field}} == value)` #' #' Some general tips: #' * Separating statements with a comma is equivalent to an `AND` statement; @@ -96,11 +95,20 @@ #' collect()} #' #' @examples \dontrun{ +#' # basic example #' galah_call() |> #' filter(year >= 2019, #' basisOfRecord == "HumanObservation") |> #' count() |> #' collect() +#' +#' # Field names can be parsed from objects using `{{}}` syntax, e.g. +#' field <- "year" +#' value <- "2025" +#' galah_call() |> +#' filter({{field}} == value) |> +#' count() |> +#' collect() #' } #' @export filter.data_request <- function(.data, ...){ diff --git a/R/galah_identify.R b/R/galah_identify.R index 6578c6b6..2239b72a 100644 --- a/R/galah_identify.R +++ b/R/galah_identify.R @@ -12,7 +12,7 @@ #' correct results. #' @name identify.data_request #' @order 1 -#' @param x An object of class `data_request`, created using [request_data()] +#' @param x An object of class `data_request` or `metadata_request`. #' @param ... One or more scientific names. #' @return A `tibble` containing identified taxa. #' @seealso \code{\link[=filter.data_request]{filter()}} or [geolocate()] for @@ -52,7 +52,6 @@ identify.data_request <- function(x, ...){ } #' @rdname identify.data_request -#' @param x An object of class `metadata_request`, created using [request_metadata()] #' @order 2 #' @export identify.metadata_request <- function(x, ...){ diff --git a/R/show_values.R b/R/show_values.R index 0c39229c..ec3201f7 100644 --- a/R/show_values.R +++ b/R/show_values.R @@ -12,7 +12,7 @@ #' * The `field` "year" contains values 2021, 2020, 2019, etc. #' * The `field` "stateProvince" contains values New South Wales, Victoria, Queensland, etc. #' These are used to narrow queries with -#' \code{\link[=filter.data_request]{filter()}} or [galah_filter()]. +#' \code{\link[=filter.data_request]{filter()}}. #' #' Each **Profile** consists of many individual quality filters. #' For example, the "ALA" profile consists of values: diff --git a/R/utilities_internal.R b/R/utilities_internal.R index 8c993a2c..bdcb6636 100644 --- a/R/utilities_internal.R +++ b/R/utilities_internal.R @@ -23,6 +23,24 @@ parse_select <- function(df, .query){ rlang::set_names(df[pos], names(pos)) } +#' equivalent to `parse_select()` but for filter +#' mainly called for delayed filter arugments on APIs that don't support `q` +#' @noRd +#' @keywords Internal +parse_filter <- function(df, query){ + filter_entry <- query$request$filter + if(!is.null(filter_entry) & ncol(df) > 0){ + search_col <- switch(query$type, + "metadata/fields" = "id", + "metadata/profiles" = "short_name", + colnames(df)[1]) + value <- filter_entry$value + df |> dplyr::filter(.data[[search_col]] == value) + }else{ + df + } +} + #' Internal function to rename specific columns. Note this is safer than calling #' `dplyr::rename()` directly, because it only seeks to rename columns that #' are actually present, and so won't fail. diff --git a/_pkgdown.yml b/_pkgdown.yml index 677a776e..aef60e47 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -25,25 +25,13 @@ navbar: articles: text: Articles menu: - - text: Get started - - text: Choosing an atlas - href: articles/choosing_an_atlas.html - - text: Look up information + - text: Further information + - text: Looking up information href: articles/look_up_information.html - - text: Narrow your results - href: articles/narrow_your_results.html - - text: Download data - href: articles/download_data.html - - text: Downloading images and sounds - href: articles/downloading_images_and_sounds.html - - text: --- - - text: Advanced - - text: Object-oriented programming - href: articles/object_oriented_programming.html - text: Reproduciblility href: articles/download-data-reproducibly.html - - text: Accessing sensitive data - href: articles/accessing_sensitive_data.html + - text: Downloading images and sounds + href: articles/downloading_images_and_sounds.html - text: --- - text: Filtering - text: Taxonomic filtering @@ -51,8 +39,7 @@ navbar: - text: Spatial filtering href: articles/spatial_filtering.html - text: Temporal filtering - href: articles/temporal_filtering.html - + href: articles/temporal_filtering.html news: text: News href: news/index.html @@ -109,4 +96,3 @@ reference: - atlas_citation - as_data_filter - print_galah_objects -- title: internal diff --git a/man/figures/atlases_plot.png b/man/figures/atlases_plot.png index 0b96941d19eca02ecd821101dd8156aa24590cad..d5e88e4fa4d3c4547f102bdd9dee52310c456b22 100644 GIT binary patch literal 336204 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%0AiH`FW!YSE280>1GJvBoTg?+f_r}q zdWS%{ii}Lr)Izp$28uJ0vPy*0BMB}0I6i{kfZx{I0Hg@&UZYiAj5g@R%{NF;Am{Ju z5ru`4o#`CYeMf!uGw>a-W(Xj?{r-*bhNmL)7POXk@7Yr?w7Zx|x7~Ej+g|XB6340G zlmxoaVN2bdhr0Ts0>+2$?VVlpps7wM=XUtBTbaka=%A~o*vy?H8g16XZEDxYnLjjN zN~;|f;x=eZahoyScP-#jTHr7%`{YR2kw>D=27sSWIns2TxSlq$82?=*ipc$dyQh$S zV05JX5dKuD>f(7iBqHW$+k}-0a}bHMo6w=QiE-vNPbEc5UEY`@!&8>==SKUa-Gr_9 z$mwgoRH-&aH|RMBeM>}c|I(#5THJs= zZ7iV9QkE@F;3e^i>n!|ftra;nTU801WP9pvOf|Rv@F~B}qFBci5l?r(B^zxzm1sKK zP~NDdqydqtBQ6R0abqO>s3kbq*>wZmSOYa*aq&c-`6?kOD9Fv-I6hZQ1^hLST5MdL zC^j_VKHQ+6#gH*ma}xE-qDQY{Mgo%)v#YF$q0ffnNzS+ThkO~e$!KT@n$9;oJqx!Y zfX^c4DSHFJCuBde)?Up0{yk9aWHKov?JU-`1wfFzoE-YD>Z_1Xn*%g7M}t93Y#o@F zv`8kGjDJHVio%UF(lvl=i52%MoVt8-?NN{LAeK~`%?I#ag2p$L76J4^d$y-<4BZNTms~9chW48C7o|w_->DmfA3iKm>R+RFYX`Wo1K-@)A(x zfK5KlAKuQONu3%DY#$&u78VwSSt2@_Xv1)EaRC;IIyXk_#yl!sKxS-(4=oCC2v{&`SsDv%chgZl$N>PnPjP`u8aH> zZ!7oguIM|8HLvC z_+uBS1!&xv1pLKZMaUD&Q^F3M635-(xyjs;!SWu=-YLr+N6EtMy*FQ zch#1(9Zn$+mUaH$+9y%_ckS9$_Ui+&0oAtWE|^&5?v=9Pk2P8!qN@B;!xMk}3P<8t zEgH;-dE6or#m9nm4aG;f@{FU~JV;Ysrx{S!d^svhV`(u`A;n!?X6Ac`H~2*5nwZB2 zwU|(uADfD^>f+PG(_Fu)lk0*)gVee*)4n$CjHHb`VHC1%bC1vH5aWfEsF(|Cqa5KI zd)VfC-amWkC^N!3r{17_JB;*BVd8p^a0#VXcw&cVi2g0gmAO3^Jv{2XRn4$IU4%;Y zqBW<~#ivG!pG?VKnyS?FrlWbL_NlBkjIcCSEhj2 z9ZIvx%m?e9W`n+bHtb8oeZ&0>CDCo9%oS2f!;H?)F8cB8&SIJN4|SL%hERWSW6f0>>iU-wJkqa z!ZFCUFg$HM*w1INM)(YA)fU{_zH{1MJ-sh1LYTBxMp*o4+Eo*#k+0M=$<@auepJvT ze^)<4QS%=xfM8u@^`pUgy2E_W3llwpUN==ek^8_g*P}n$z!(sGW$1YIx>(nDQejF5 zwV?(l0fh@X(V{hmlNW9{a5Uw#kTr(X?9)!IEDNmhSC{Oc?`P=XxyhEh6)Ys;?@~r8 zWkDIa!gH^_Y)kEDgOl*7$;rr?Knfn_<%XyYuKU?q(nQB88D~#j%E@Gzolz@eR_K=@ zf6R%QrD2WK?LESR@QP?Y5KXu=EeHvx983lr&R|SKrAhc&8F=Q>jvOirO%;r7L^#wURj5X~(q}@v~NH znp{*@2Pyv6et$due61y_=WBanEv2P+@(v-WnQ&jw#iSX7(1OJ*kr`M=9 zP3Nx7@7?dtd&42D?3HE{Q{Iv}YyQ%=pK|1;?j#qTr1>w8J`UArj0~82tQGPg0uT%o~%L_O0~J&uwR4hV~U0-`0_g zDPQv$OBcqfo*D<>pYQW*HbV>G&AY)~dai`=fR83-7OVdrD zIWl^iO}3N-JRtG-@qV@Hwu+$FVPevwQ``ojHM_djh{JZ<1c*NNYCE;wII~+BM9mg? z%@e)uLFJ)0(X4#jbvu#tM975AEGtKcKJmeV!W$AjRaytJXBAs;YXQ0*&)`Pg~2Eec~&ttM?bC z*Qo~i9zXff$MxO(3F!#~nkn1;*T#>&P@kw_AqiF*e-P1H6&%ud&_t3kt{9#L3Yihwr`koN8Y&>uC^85#$yFn>GlvIXJ(3SoA!QbQD`a1OE z;;#Vi;JO6Iuld*C(rTH|le8tJv9GLTJNA&4?s46xSj#S~_jz6Xj-+xY|QLlfriTPXer9nE;x8^|3o^_!~F+(6_u2fjFV|Amk46X-hX~&1?}xd(?WV{)6GR2?g)T$yOA7o zKL&F$z|#Ac=P_1Yej(>|x?C&SvDacQ)X!Ez#2(Vh>Y+VZV&|r^z zxbJ-TY3g9KirMD0-w##ECxkR)Pn4M|U20HVxK~7;#=jDi#B!YOV-o8LF8?KerJ8-O z%b!Qzr9Y3pu=b zW4lAmE!yn}#f7h8?#rHI%;$ZS{F0jdr;lHnv`%DqvOVmc95x|Rs>e{B@15-Ns?{qq zm-o?PY}AnsH(hm_Bi5eRLc+Z+l1a;j2_CoX+5P@OEsUP;Ye#O7Jo*^Xv%x3R;06A|CeYBBGwv8xP1BNS_Uru^Q8>zHg z7Yqwl?YaG)Qm|^DkGu?%)}^)^pWZWm=+=w$bt3v@?8MkRcjCmogvX2Dm0wxp?b`jD z>oPmA*gQNJ{pKf^R->Yhq*a^9=a#+J6b-$dr&VG( z#ZT8zOqEy9#X8^C6wu?O_|Z4N&iP_}iUDN!q5NAHOnaXzeBcy2m2#o%jG>3;o;k-F z2RZ^bT;W@Q@8^LCQ7!Wk#uOo@j(j)nIoH6_TaHG&>5|a~DwCZ3_j4VM+JcI5FWljN z;Xy%JYkn|USvFS7D(67czDhdgb*Bf!iNA$I4jv*4yfbgnaIbOb3>gO8<0C!#g<|17 zCyDl5YuP?jY&kRK+HE;E^lo+clTF_^oAB)+^WSTPol-MCb(`pr82;Jt7<@msU)5Lf zTb(1=`-vsy%##cu_xUs9R6q1S3$%So6(?}7;Z?FACyVWRxl&W(;W2h`QP@*FesJ0N zUTn%3$4kqVHT(CM+MM$q_aDED;7v-NQu3Ow`|6ze6PdkFI=)2bT+I_h8|7rcoj-5V zc`<_K@HwnaAN=bB4euvfbE6;WLM9C0hpRlV;TEEyoT0YT6>i^cFmg|inP}g0POEKm zn$BHj)$=t{PmWvYp6LmBf1@u@W;G~c)p@M`WygFCQpy8jK*ib&@xB~~j1~Jqpg*j? zzC=eSmO7|%2}_5HOV@PPJ2fQW#}R_s1xwQaHRYA`P9rPJHyR&r57SYfogz6O_;$4Q z=B{UyUt6^}w-uj*P=Sxn*zm{rJJtu+8y6PkwsV~+J?Hgn=$o9=9FZ553ydsT9K#kv|mTn&?=Gh z-XP*YvV!Yl`zJ}cUXN6MeqmK3Xm)o;S@ox#0bbf|nF_n(URRG;mcmU8M5rkJ#KX(i@XCJNQpt_`g-4rMwiBnlOs z=PwH1nh=XtQ;7BRRsZNOV5|GkQPrQe&6s1S9p81)CTF(Yi``8rH=aCTGC6TabhxJ4 zQZR1(5gB*pleJOuFK<=8tUh|Pc?-}C+zaL=&TdBCmbz)7+O#7Pu}P)!yuq=`KUsPs z-tA#?pU*lZPqpigxC1xI31|?%dx*6hJVZF04oMi9Kg7sMm?N?A{zJe{wzvZg zUlxzsGoICsr(jt(F18Vg5+>uy9CMFPv8ox3VH}->)&SKEZOv+lv3>>6e4?y%EnW`j zHpB+x1c3X$^=UMOQavwdXUj`m_F&c{+n2euBSkFtdVjAF8soijtsjvB;Sz8m;Vg|; zgy`v$Zf?7{B>?e$0YSZ)%t>2FsKdas>B|>^*|t%vG}F2;u&G(ynDhEmQd7ZrTmq~L z7vtx*^>1f_;=ZpzA_?6129|a~f#g=l<#Y|u_AtqM80=~w=#n@#9s4u)JYsm%wcedZIE_fJ0=nUYc5OD{}>uUrvnYifHN;Nhtfdhd^`t?fPHGj6~cW__A0WjaZ; z{cS`LMS$eBfPTx9SClmzjHUWH>y5@2KD%Uml53!rO9)x@c;jgFT-CFK?NQ65u5O>w zQ4=ZZBh$0179%rH7}obO<(J*k5NJ7aYqyQI#?2n)*6ydlveyk1^F1fV*zT@{pX==N zaoZ=A+L@pKBIXh*G!!{{2VUj+cks78knJ!%d8?oEL$)5@LHlTib)E9WSC*!_;=tgL zZGIjo7hP0~kW-knQa*Fp>11`F%%4)76PdKST^H>6$$g0TeSO2~S2XF?rs+(~)fO&7 zJGabmO~O99*iO+DSEVUHA!Yi$|D;G*Nfm>wO}=MVSsV}141NuLx9Q2wf>c9QAMu*g z^sSKYe_pyfJx@kMu$QaY#Qzt|!)89NbnE0DCQ|tEcYPFA8a51OK*l`XG&D9c8p@s; zIo*3p^b`?uGAlgm&hqo0{)jYoZE<|~&l?BF6A_;@E?;i@_N{KAtg_M%Pp0R&|7YHh zprr$V2p#0?YzIfjH^aA!rba^ceb5BedT9xoME$k3EJ8xx`t~{?rIMp}09YO{8dB2l ztD!}NU&fWJ-ThU=Z^--^Qi_g~!oPgxuf zlG{?QZ~PM}c{yX|&{GP+co@DPfNYXdQ+M~}2nDwpeMPwj81?Yu^MA+poojlrNEYDF zz-sz9kz%Qi>L0F(_EO{fr2l>?+Z<0=sPLjexrPofr+)D-62Cv^;qr)~HH1L-5h$w_ zV~JkICMG?Pv(wYD+u+500j^I_LD1LlMceI#N<#gD7^5ixdH#ErYS%5&T-z=_(8b|d zjh+{*av_C_In^?Z+fGaBfSR0JUXIP^aN=8p&u?=HHzq-8oH)1}?#*?&M`fY9WbS#E-j+z|XN-7r(bx3Zc zm)wkrNp-g>@w~2a?woz9|88N8Ly<`|MwBuJW^*45gk7Q)#V#FLTBCd%+3qTS>5^1N zF-;aH5fS^5u=4@_@kbuyH%GOuDVY2EeU1&bi#-*QuN>RCKtk6R(_}F!o)lopH@o63 z;q}|TJAWM4l##Lgv&Ez?j1g6MMjqQ=Pv?8QAVomz3!Ti4jEoF|y*O@D;S%l!Fp*<0 z{T+Zjcuzg>sRYvhvW1LH4Inb0VOD~o3R>&kJ9oyt@&@wklvH3)pJDxFo9)PSXR*5a zfFE88>$Z+81HDW~*sXx;XC=D&Z=M@J+HKaN=?f2S_g?o<&(g>auHXo&QMU^Y%DzK- z0)i1(a0vgMTLellRwMY@Z$0LQz(DAtiW7D=M`+%(0JRcP8ef=qQ*mlz#ZH`*>kL)b z)CA$J3`m*o4iLd1>?IB%T7qH&ba_YC|LaTY_#tkebRUqS8drR#a%%4$I7UuGgWS2y zvLzYYstnF<0AzMHXlHhSN+?gni|G~=Ltyinkr5jmO+eZ&p&yGkf5K;058VDL0347h z$bq~F!G}8-3TQ9er@9LiqegHqmnS=USXt$abTl;h;f$%=20Z#Q*sBsEB5#4EK(%O) zYkJ?uK&w%XNUcY{M7z%*tLzA5OFB?7aB07J3WW+_^Dy5B78~3QblI2D;9crq<>5&J z{}rAKU~NiTwWh*3@yZqKDeAG>Om>@T^OqoW(bL|3l(VAun=>M?Ti6Y}b&raw?t1dw8h!=TvngAkz+6GwMO5rDE)L`X?9m^ijytnG>_&ye@!0g4a7gl(f*xET}d+(3}C7Y4) zA~jVECM#%IN1tS4W&H^|$tRc{_!x9H)eXv*TlD-g|7@I4;{us9_UhD=R zU@7U4|}So_O1{K`GWPAQ~ANRwYhrGoFn8?)K7)mn8X zBW;h`ff*cN*+X3NrhZG5a`~HU9UTo#XMVGZmy2MAF$E%k*lYy}o zc;!CXp-kkox@g6C0rHvs<8J^b5>Rv-Zxvw`4E4MA4N)WJ#;?!y~oa8ln z>+)$u?Z43_;5P-~CJJb2DqbG%Yw7pf@>#2J0ua9x@g6&d$)vWO|4UW)viJ_yLVLGE zEaiE#2gU}EwO_nV)M-d;0Kn*%NHnpeiNtxdb#*hc`QyF_C_?+7M?pkwObsz>F*5c= zdxt}AW6EE@_1(T%79xWf9!F>>KpQ2CbO?TwSb6s$CoEbj2bQ(lkn zvvonoXEJ1&T~XpX zJUPl(jW&~Yls6bHA7OD1qvNrQf1S)XDS{&yzD6iv?El6n-Ksk+$ye8+@7FI!#%d5h zrZ4Vq=jRd-Xm|Y7{p1UP#HNfFzOVz-AGu*KF3i;Q%F7<;Ab3D8+u+7xn=l0&h3FQk zwAy0?ePYJN$mRX}_XBT;0WNyEzgVU3VYEfeh)-=`ck&yeC8$sR)&&T4P19|E?WA|v zWs=n|IY4zULNtaoJt4fqG7R>3$kl-e>4&{6LjWrmS3$5zI>|h^Zt3ov6w;_n_cKEL z29}n>Ff_{R($~;{-~;`}uPT!8lU){(`aI*+1l<};;s|;M*6kD6HE6mNX>$@ZFJNDL zgq&R6&~Rqvn6MaIQAX!pTTdTV2-B z_=+AX=tcs{4WB=I58j~)AVp6ajH#t{R)ULywe`e!TUs=y4j+yMJTB!nl|-=>*nnbd z;3Hu=*P(ix{}5b9RVIn}vtpfdso(V+|3wvka;}ncTbX}o+lyO4*Z8jqur%`)QuOzq z^I53h()67aIF>tgC&UmXWKFyr#c*VYO$IfK)E|6gBzX`Z-9Tu`%S0E_8$1w5I)F|9 z)q!tXMm`1b_rrgdK!!{5nh}-{C-C_I00Dto<;iJA0RdMq_g3rT-(r@?iSr~kx3%!& zQn*TWO${Qj19Vt1;LMp7a|f>iTo*s^^JWqsktUBK68m5=pnrT6F$KbqpY>|!SmFBGv3`Al1|qCG zpHCP}!hDzZqiQP)pkqI)KJ8Fe#iqxX$;ItY?H?rP15cQPyPEI@Gpg{fc&~}M5C-MNeW;aA}t>HMvTEzlF@+?4bthv3H()&<@3Jp?I5#LLc1J$HcS;G&F$uNp7Ji$S|}fu?BK zBy+;i(^uaivB9DzC{A6`)P#wu7lyOto=cv1CI{jlRYhEXg)?3A6$F`5zRaFO3JX{T zo=XvSetdj9;jF~y7Rrt7iB}Mp;6=THYycMv2s2>u1bZh)JR-0B=I;2KOPVT_oQmo< z&{en<$Oke3**ZW>JlT~uR_rvK^>%`tCz0#nKF|M}ClZOYqHvi~~Hw`~vj zn-4qbIXRRt|M~i#Z~f2RVEoTw*!F*{jq0!8wr|^>3A`R=df1H_#QXMH0l5_@%Eryj z&CWg#`gr`k(9qwGBu9dn;aI})e%sA07}}~Gu89!0jEzc2n9lTQz9)VDJamb~d*#vD z1`S$kXnPr}m{=L+T68n1cy}>v{**Q5sS0%uN z8GFu?nQn_5DpnsMSV;f{6+nt)1Q?fj z(^XJbSyhb+W0@CJq1Hl~~XKO-qCF&TY z&xlHkk`8ajUw~$&9e#4KCwO?j$-%!UrKI1>k{I%JGZ8|H&%DdY$nd1xFyg@{97xN^ zXg}N4s+g79HE}!h*#V8OR1>y?V3a}=a6FAI;HZH0V=htF__HBKr+aVy^*nEVUfU<_ zca7oExDiBtBq2_SL};T4Y9JefBz|wq{wttC`XtuF-|^p!ZigvW>i4J}`0#pIYHl>4 z{^|I9XpimiUvIpNuOw&Z&EHTpLR4#yqNS1W{zJd@=i=(2zi#ijKPDs}5cWX)19^|2 z&Sjog0vB_FY^RXp(`42qCn4oFf_)-xDT+%7Jfj&0!NDy+$6d_2rIp)Ya=VV2%6IEC zTjZ(#U;#FMBUQmVLFNt5I%jWhZ=Zk+Q4|@tzqSzz2k9leA)J+HvG<_h8fGels1NEO z4CLXuB>5{fYYy`kmnudH z3t=Z>MsTtLlCA_U6TS8B!h*6gZyY-)yKVd4EN`Xv4E1;WiN&DrBL1Kbh##Q_-^t4WP{w*`);jh-_3k_Ae&m5>(%efo_ZDltM~4y&l)Xd_>g#*H|Pk#zK;IbgdRI-2;>%+wV?590CT z!z}-;M){8z(Hm>D@k1Q204*LxBi|LXHU7wQtS!7tH@S(xtDdu{E3?yeK1#%#4ETnc z5cKUS7#-p_E;OGf8DGhat#4=m7~W~^M^rgJ(`$p$!tOya_xT_4Yg6Ch4gRxi_Uf09 z=_Q}E^N1Po+BB5}>m=IzBFxv2Ch7T&ym>pg=PSP*s&I%Bx!LW;qFI9mkC3;S9SbIQ z1hLF-u(fPJMl_4`l5lr@+?xA?cq*W|H|_YBO2N6|`6ar5v)M3D)6t0`H3m8f)jEW3 z!%u>NX8~P7aNrJ0!*1Fh+XnmMcl{uw0kH~i_y|KC0@@D9df}}CE-AQ7p3uvBt!V>I z1l-&KkKuz6>7HlcG0w`tp=d;hT?yZz_P4G!|7s23`@sas0f1@^@3)O2)rG=j{_|>r zX*&YR4T;>BIp&@Gc1Q|6iR*ILkxF5&o<4Cx`bzu5hYtx74Cv2n5j=W-6ese3$MmQY z5PrQrc$w!RMF~GidH66|ACst(G_DlDE(>z09-#eWm`OA9V>nbY>*dR^ljF$dY57d$ zAV7i*h};H`ojCwp5)bGOEOu=6&H4%=0wdMKmPEK9pi7gJrHSVHy=K28h<3K$-SeM7 zWXW35f4f;$^)gh%E@Oq-<8GPa%s^x2ITfPfsg zdFwu#mEFQ`2d3ESbwqU|ujvASS7X1J%ZFnC1{du)kQOGKXW?YLHEFb!;dbwGn#y!n z-bEu{6eMZNaYra9nB>EgS&_`~lrM&N?oC>U+~DT=m}HN|-Za&unHz4Gj@m5)FZgr( zJYzM^w59c!{vCLJ>y=w;l4D%o<#&SGnWbN%ZsZH?O?0J>pmq$TrKR<0Qx=5ol%A)w zAbJ$&qNUqOLbL8c^7_WCUnK}qR0e6EWz(r^m;O`F|s(?5J z!Qi|6oUU$cRpWn!8G;+2>n?o{8hk0{d!=O52;*pqcM5dr0_r%gLz0 zGsEF*+$+j^!NCM(($Wa@xvm_qSD!UK6>yZ_vKbFqKQoK7p^=5hMGLJy1O{6_LqbHX zKQwWGe~lpa zmFp^1x;9e^O--7pWBit3{rBGhhmM_5xPoyNsOx=#62eE zx-EV0+c)houR`V~+baPEo(SA-Diig&G{=sOt37oCK*e`*A6QA9njZLjl;RD0|_;SKIBX)Xkbjjf;k0dD`h*xw6(D z_I8g~<$tf@^z3hj#glnMsrI**I?$}kt?ili8$;k$6ey2c zoQ*?U)S+h)0~RRD!l-rU7m!1(f@*Kb2U5s-w%)|DU-geDA#u&suN6(|zxAK{{4{;u zm*fNTk;#N#uQguQ)_L~K z@Iyvn*B{8-xe|1!MI^tuTaaoqj{J(i_=PO*taJL@ogY4*I4eC($Kmhu$}3+lRne^V z5%Gy^4NK|#18pKjt;S5$qO6sSl$vrEx#dcjlNH)^1FUKFtnHHV_axd^#n*1 zKfAN{{vWvlz1OGr_NE<)J^3m50TWf~>l5k4>OluO$m6UG^oVOh2l`uAq{lBL7HhD_ zm}Z(i=@!?#(9Ua+qsoyP-pW%a%$(etEVJ82o-6+l*ZP|E;36{{lMy>>r?j%JZ9s>( zO2D7F_8q^BGEUJqMK)62^S{p3%QFN3?UFp1!A)kakW!z7pN?njqSG#kBEVbt1gBhpTp~?Z- zUvemx%+1YFFTJpWW-U}`)YLZD^|Gy6_@{A_ps9#w`yiPg?HlZg(zh$4AuMW*RKzdkPxK9y}iq}3KL&gfw2i{4*=ulc{+Yx zd-v`Ybkp}W@iXji1QReSGUg;3FQ=^rVnbC){sLqF)|K9jD;z$9xB_m?oTYbp?RFOm zZ}Fn#y4LI5RH9doGyqBShdXUW2WzapVa2F9kDy^v4%u_@i>3H3FtaEXy*wC-(1Lxh(Yb7~}`n5apo|pQ)rP*Kq z`EvJCp2+LlONkP_Y`3?Vl#b$icQ)N%03vXOs zoMylSU$r_QJ3?(9z8z*i=3b!y!faNQ*`I&s|DlI&MX zbx$`;yj$+@%I@gC?m)Jwz@GkCQaIIC?9~2xzdXk3)I${?t~=0^O%_!@wdk zltiKSdWOSzYqzHbkHB+rsw$N_N%IDJ+AzP*$rOtixs2CV z4;Pwf#unFXw!-EKcPgivRb6q<^Mj@CbnTAh;=>i6%$RI>iTL(C7-*e=FQ-Q7_aqoA zz##w}Nhm@tf;tYS74ZD!1r-0QZ_J; zer-)z$;YW?M1 zXq6^f?E|q7^BVuOUAhxP=k^bEi?w8?ts;{4jz*~>5nf|wAWdPPX}tLO>}g(Z{HIMU zbUMb5ZVg-|xi_1ud#!UqssfJb{iPd$Cw8x$n3_L7ONd@F8x3Pn9p5NBmJQfB_3po1 zdpvD%zJ!MAHyiV@)b&`tK{cC*dVYh`#puJp>r2eKr2HH-8Ie-x%$3c(FrQ}0W(S#0 zTU6wT#w>2{omZrfxP%nURrN-4t?Z96ba)+yKJSw`Hz+QZHI&CX%QQ{V%&c|1C-zZZ z5l%b1pQ|D$lxM|b;O<$%%kj*+1FHnqGsN&63OvUzoQ#Z&oSdpuGHu0t`3J?w)IqP7 z7sBdi@2RRHDje8|z!`N6CIH?MD$1(PP;?@d&lkkm2bQlxP`a&EEfoPC%WY>>C*S)S z+~j;fB#*k5Uj>~#6y?hxk_9c#X(W3`OUnT`WK--uQ&im<1lOrB3Nom8p%^9fP47i5 zJQe<)?7GHMZ0y^TmX}vg!L!-PZh9(c_spN$#K4cP=4`k8?g13%P$Dcc>8q;lfa%eE z8@$Cf+^WIhXPwI9u6!9?`eAn*x?o4jjFq-ic;M{yKD|-k>?+3)oFCX4^ zcg-^ICkZR_<5ELyhmW<6bc{b06k#Z2J3G)XH^#D_@zWIU6)K)Czz8=|jLGI2h^08T zEme+tm1X^ojbcxCmwq!gYSy&g=_AGG+|LXlb!viwg9^gu$EnZQ$F~2t1P*;aW60$Y zs1zHY-{*Q~+rq#htG)J!&rITU)8BoyUMw@;aE+T(_I^mcq9DA;aWRhAES+E+KHnF- z8Os@rxpww?J9uqHPIPT;Yfqx{N6tyZ8C!N4$Fr43eEW!cEpaRHT?y0%N#ER?56L;(^sbB|#VawL!p1bhT90IdbOz5h4+o-@fq{0)Ws_qWe z*WG*fhE2NpoEM{`A_1qkDddfSaxj@N)gG#})%OB>^1VNei_;f}twt)^$-ehn01#PW zH3TFMBPs*0vf3c900habi^eR#5O7DkerbuqTpp{gtb@j8$|$-U9-R?~9s!T-oI(xd zG|v+K(|Zk>X_=~7a~1n;SES%6-R{|4OwZ^SIbhN~g?7+bjT4<&oY7kp+VWA=Osk~5 z(UE{~lgxaRph!2KBswAKM2W%qs3kmrmZ~bgc?tgx znWMZdQ@j%0qZ?oMq)Nra+s2Y2e1U`OpxFv->pUUR^Wn5qd)U%wh7M$Fk8$0q3L-zODe@dP2i{_pV&>&qEZ<`#>?RF+uHa22~F5A?!1afaC&sDZeB~;QCGg zW}rh4Jrhh_LSl5Zk|PG_Xx_Di(nm zg3Rm?oVXl%s)`+`4J$Np2MRQ&OKhO8grO4uftS?mafAdk%l9jG^Opl7q`=ECur{T576293I-t)ne zyVX{Jo@#q4+SDbY=0~FK*$)SF;@T23DXYTxsH)d6L$*xYu3B;i;ks#?4?IdV!`&&=6aR(S zw(PuyNgsFv_HhmA85^S=I|WQ%7(Hu^rr)_LV{P>z1E@4=FMgT99(4Evim-Z!H;H%7 z&&#u#Ml4NxxfpT@MIti-DLsBw%=uyEho5ETEuNjOopC+xJcOhm1d89C1F^N?v!5!4 zr|W@2^Eg1)0b4r_505JEI>3(*^c6@7a2Qr&9Qhj==U0}H)oNh@Kj|U_m}rz+#MB)F zJ!yQ!aZ9u{k80GbNp0Lg$!I`8&@TO5PuW=AcP%aB!{6%Fm&`;C=6$PUqE)dLp5(Ci zcr1nOFaJ{6bT!RL9{a@&so2uSpZMsNQcCY@%T#<{92)UBpb7tdPLG-iI1Z%d$VmsUb_AZH+G&=s>bQ2n?R$Vc!qiW zOG@NJd~>&;8@;Zj2huZz<+fG!YPo&E%bxDSLTYA4oYQ9w+F-@s1~EQ_rlgq1XPLtk zAOQ*m0Pi6w7?@aI$p8(Hn^~fuQAd#59j_<4&!7K6rZiqWa8N+?1K^noG_c@)y3waB z50tPg)Bva`Ydb@cQHM|g0n)d&t%2Dz9A(G*hoTc6uuJVM^ftesTglH;;b}jy)lpTh z+v;93Z)_}8@{vAllCXU!ZMvmPF2BIN*``A-kz*be%rJwo8h)g29<$4#5N_f6mG}C( zE&T*(?j?=~3zjs=4WEkZYSCWxUr=$nbQB%C-NH4hvWNX$IG@=YMpE4ZOI^dZ+dF?h z!JaT(ey*`Q6f#Bh_0Ovjf_qXVA?PUnY}O3vOmXk)(W;V1KDmK*HQxpsOOBaESGk3e zBCBc|#M<10^P1;ZVI#%#5CrnV@WCU;&_*Bt@t}-l>nw!%@X^CO zf*(GAkKhb0KrBzeDcRZwxRGbcK7jE%e(iWDjKQV=Konw# zYF9H0KvXN&KuQVOsw$wYi_T{dD+JLm*wljH2MfquiOD%gS3x|F^C>DNBjwSzzWCAm zzr#-=IqMx;+HmU_j8^GkechAERw$M;Fun24Gbe1Q#**gb{ap{vlk*OVQDHh9yX#*( z?`uKRXruJ)*mk5INRQB+q{pnQCc3+7rx{l3<7;rnpESISgzi zn6~7A7Xp5BRWz+Rps}z?n^~j4z~pSVvL4mHR0?x$h>XZ;2)Xv3Mig_21=>>{%YLSj zWF&b@QS|N%@Y}bh0;ymLSGAo10t);?_T!@sSWI9Ms9(wFzJEW*B&{O~)2hEN3YIVambI4*X>jF z`-Ll?R&ssqXUu2g(#$ea6pIM&xP7i+d#(}BtVCPye9@cQ#~xG|7`T<9%Fp*XciOrA z)+de8C&28ob1czJ*fI{V3Hna9n2(i;Y+lgp<5BWI0z++>Zb6ya(b~#DPk$K)NBZg0 zc^IWa5DUy|4A^)=4E{5WcJ)pHJr4|T$a2PEnFan}hvYE~ez$;x3zN_)7#_oF3*$ct zK`^w0^_y?ahxqE%4H)r&eU%Tw2-;ogRy3{x){Ib>}q=hL&XeX58m zYzI&9rIO&Nrj3#zYv3!v?B^kv_r)hjNuO59K*F^f6`C>WHL0E&O|9+RUxh2 zSjlHq5bc4_C6gi9lU;*zT6E-Kc7$v{W+U8&I)gzKXZJbNxWTdE&-FbAFSg?g2lLIj zST7}2Wh^l#vdo+6x4kwO71<9D4EK3U=pT-H)Eycd9rKChJrCH;YFdBlb$CuI$5wXv z5}PM4EvLdc z{~Z}#+xdu(#DebP%s)a=y=h)HU;4zKZF8dHkBpWs(QCaQ8g%T{`drp=_&A;YH1T?(zdqcKx=ku>;?X+ZM4v*KTb6Va61({6-+es6>cnO2NP}S}B zUh`)G+N}JelX#_vaCbnOqG3VWfZqn+RTIb5)Q|T03U#eyc*?}z+hDtIK5lK9X@GsX zkA&1j?x%02ZugHtBV4WQSUrL!#?XO^>6}p!RF~@9c}1|>2XU@j z^<4YlOIX)2h)Rd+z)kgz?Kr)`)TfYx)-TyvaKt0Bi}$9-3p!5jU1m&uwGj>F9rdd= z=FeFbtgH&Q6#rOECuH}2OG{5r>||9VOSZ24^5qdmIjtmkF7J3e@Xq$@_TTVT6Flr? zBec75iM8zP`95&i(Ivm(A&QEPz32aqHurm$A+C&8*|Wlgh&4LS%tW{{S9Y{$UYcVJ zUv4B3TK$}~fyvu!7MyXnGB@1qI3%`9#*)l9C9Tv|5koE!>yR{h9^%ma#l}f^{^@2)0w9DdM{$Cy;;iqPO&uRLWB1a32YYVGw z$5Ti|G;AG@EQG7mzV>sM3clL-$eU95vd$N?PT4mgTW_L!WrNzdW%2ZqDaK_kP1}`4 zo4ZSe>bE2rxdb9h!d%ZP#|dfbvq=Ly97(R4xD?C1OAGs&=lw(2Q0aOmbd9V1RUmcv z+SvC11QN!#NfII>m8ibEy?!}(jfiNZVyAnKnRF0Y7HwjHV`($yx&0>t<_woF+Xw}6 zKnZ$?-L$rC(VdogBp`*=+!&E=@q|OL#ugTB>*3?<7rjuKA{<%7ocI>|tk3D34<&el z1D9ec0bItq@zwUu;X5+5Wv=knrhVPI z<-4mjUs6q$*1X)p!gC0h#=OSz3~KYadz-mbfh%p%|2wqV3nulTGEwq@I~JUN-pknN z;L_33Zm+IXCyyiCpRQEDCA6_7M1w?^zLZQz0t8;rYqVOP6KpcXHo8;!XeEI$mS8j# znjr}akRpK04{=)Rf4exAe7migGzRphOY1Dx{MQA`hK^eqiy!$6F99POiQL*}u*Qxo z`AsqFapn8wuX9xO!dduo4z=1D{a<6y1a(L6z_rN#1AyADJJe=k6@t#`BjKUk2EkVD zhvHXP7G>|Y5&4Z(2lh-Bq!)jJUy_t5w|%u!m%qeG`7NZtb8S z#OlLN1?A!-EeTFO#<14f=9ni2|8;V5PuPUNzJC>I><%sT)vJQTd=-+wMg)P#+mP45 z>ZX%7JRa+&Re4B{#bJv-2k};}e+Gx~r*mZ7cMZn9o>Y4 z#R5|u)T@X)k)9Y&o;>lN+hYOfwf5Ru$6-$EC$Cin9lG5gIzQHKQ|A7H8vCkfR`>&@ zXWzOiqc{cq9tTdYhqr%!Ep?X2gM{`ZeOz^rQvGbSGb-t?FZ{GeqPV=RL4B`nbO|La ze(%6iAyPqkSo$Edr-dKRk&I8%1ph|e$$F?QXc(^8`y5wV0J{q?m6~heM9>$&_y|JV z{I|}efHMcfEEzVAjT`(|$;d_k;Di6qZ(TuUK}(JVtm<^uauSyfAxLc^k7Lt;I#7*Z z6D@m5ZHM>HcxdZ$CCY-C(b0pwi|o4C0$@du4(ja)2{{8{gF2JQroLBgHRATWO(cQksE`LElTpCIivRQmZ^~ z&CQ?i`}gl}g>}hV4ywr?*FyCSA}X^KKLAWP17qa9uV>;HO{2y)`mu_;i>!hOjBO)) z9u?PY)lMgeb*J`MztEO&y%BtN{QNhovLJs7Od0z~soEkHZ0C(?&lWKgSZU!f@kS#w99H*Qc=L{DHaoZs06@&ypE3veJH3PzdR)AtOC$WcSCYA<88w>_01lyCN zqd6e7snf^j>r=((K@EATkldV{Uv2r*5jeJrs?N|o0jO2p&@2zgE#!fCzi#tt^ZX9- zB82b?WWD$V=wl#lC>lE7evRs+ps+A}oULt<`{40Uouqe71iO@u+qFAfXtuxSVV1N? zUzxf-#oPwOG6uhWyM5ffm=%1Hy6ZhDhwy%~s9Nh$r!rHnJ1mOnIyBYerZAJg92wao6J&= zRBeT>UnZvWY25AWBG}Y7RG+?JLyHb6+2pDj-)I*tmn-axb~$z8{+W~~DZ%m!n!P!k zOdwd?dHPcQYXrHUXMy~~A4AYOW1L-G;cYpZA;6B0H^Kwf`u2jVal_vT*V|LNk zMJtfl+nrmDsHa5-re{v~^cixxfb_A1U8tYt;ruFJ)RX4HzV0gpY-$zRLwbp!DEdT3 z!b@l^w|7AyCA*gCJCb(JU2;@3=$O{(4q%=@?2ia`q1gL*;&ixPmP09q%SXDe)(1pm z8R5(h8%r7)wEWEzwQ~n5wl)mJER9vs+aWzkGJuRQ6m;G3E8l;*63?a8=|;J{?muxC z+ZhJsFpC6j5)%VM4+jjWOaS|bSQdNBB$2*kK>DB;26-kVN+if2Veycb3-{BDaouu7 zPzZj6${CoyAf16C7sm1+YD6Mk-k5jYazADf&MDJi0@k7+WYq&(8L0R0NN7Qi4f8Ju zikMh?G~We4S2&C@ffn`W&-1b&@*6i$Kwtw7HJ9D&$l5jkDh$Xuhvr&ULqkb5^Or-$ z&}g-BTXoTwJKfWc_BfYC=s<1}ntSz{zc0=)hcNcVdpU>2^e&xzjJx~x-=CSC!+m(= z@BjXv-i|PeWX~B!a{%|hr4%@X27(`C0?@1gp5G%_9#9rtrFkLj1 zU0im7$_FI-Z6OHf>IG3$PYY5TL{hy z;ggKmR2Krr$)cQ>;G0D`2&?UCw}L^;nZye{y1^8ZDF?aKbFcqBY!Y{N;hd4r=23Zh zNbV2#Fu{5WWeM2XI03*4IyGkq!P~tHlUI@(H_G8S0BrgolLflxyV9}byiR5?k^zvP z;K~&T*FY46vmloU*rwoaoXlaa1o>2Xd4T#N3>P?Upj|Hj?R}_@Y6>QPMw&^$?*?qe zN`jS{`D82k3`n7%0CogQJX;?P~o-zH^(Jo>;{en~Oh%V7=NQ;{IVqqr_1 zh42m(Iv}|d2?7d!zjtkmk(8WVQAHG35sP5kL+!6Rat7B--?~ySb&r(k{(X>@?}BY> zZQZ}&*%u$d`@_^$pV{%AWaKzq@vxOA5J8GHYk}M=64aY5Pd1oZ3x#m4j{yLOmNvk( z3nBdT^9|81BkZ)-#Y**+{|S9(@DBo%uL4U3ww5+LR|8bfv!;ScX+g#lMHdHLnb)bLEV7eYyg3cKU~;6q;LFEhqTJ33UA zP?cOSN8l0xCI9>6WFHWnU{jw$S|0I@8@EP;{=T{b86tY7a@&H^gHs(;E;!UsRN^jo zB{7f8Js@+1R6Xh^!1dco9OS*uJ1VKB7W?wslhB_Tp-gSatqH@x!@V`ILjgB$E331= zulv{9v&Xnj1*yli?N6{8J2w<&dOErHo%4jieQcpfVM6Y;JM^mGqAD_l)ZJ% zXAonS>HP2OO1h zRp-B=V~Sx$@J&FZ^0=fNzYgSN?nNb)j)0P(T=onI_DgT6AtN2z9uaWh2gPSW%yB<) zSzQ;9&=H;&_6Ashj5JC8tC#0j_v)_=$S@yY6N&{0|Nss;~{_Z;0F#XHZ_#-kd&Z3CkL9$I@2WgSwQE*!@?-pjDlK> z7?_#Iz%vE923pBHYc64!wWSfJ6%<&3-v|VPLhAx!dn6VOCNT=8V+l4@<^Y8w#Tq=H z$jo9kx4{;O?|mPSubDZ48n{^kwqM{c^G^`?9%kK_0wkM^@5(tdqa^*ar;$IK&3h*c zL#8GtpLOnGxW%muImsivRX~789o0Fxn+eYOq-8_D8zp-uW5|BNBkq_KX_i;BIX0le zX=3sR64Qr4cml=1ih6~4uLVWoLN^ zNsumD23t-+=!r*QG6Gr75RQKBXcE!R!p$}k=-hq|fH-Ki5&Mq+fXgF`EE*-Sf?k5n zOfHPEZ4*J?WP<|4OogSstR;6N#iFSyaoo)zI#3grd$Yrr|NcDXyqm$ZldKg8&LD?zEbMc6g+fmA?+DZ2${78t(VLWb zO4vUILZj;*oIz^FE&70#e`R32!$R7rWGFjFh_>5ZkAN1s>^M!r*bJm741F*)5v>03yQ5={=kJH&0k!qt|2$?D`Jc~gUhIFh&b8S8iw^JyekAOvHtrb2v7e1_{}t`_|B!MTS9+dT)eh=-*=XY7zTww z349RJSu$ir`3CDTABb>9NBsKYpF^Qus<)TA_(m8Wyvx&QJ*KCwJ{RHERSSQG zapA(~=o^#;%6W`tW1l44b8kn57W?IDAC#rKx?^LC=h`n}VO4`}cgX7FxG#wtt?(~U zAj3jjRZql{Eg%YqD{;-A_U)7(tmpiZh`tv&Y;6u%MB>dk4J@*Z#idqLS!3gB@Y z2M33NK-|CYvQ4fhONh)Kp?qb>ZSWjwS$?LcH(u<$li$;$)ExPb_0tO^EDio%x7A)7@w#A?BTGXf;ZKO!yJ-d0rHpjO_&Y&`S~TNK2Uxy5(EYzj_QBk>v8y0Ew#9# zqho>z7PK=XAC@PfiveSjAr8Jl6%&(LXyFvWXheg}R9=c(MT9K7*4<;DMX>2b3;d1-bX7fK8^^(a~X3(Fuq{7Ay2%V+F5@mO$A3pbf?z z4zs%dZZF;`ZAAXS!y({b_nk&Vam5!!aa{!Aqauu#RP$kI6Nm{3y9z|WMMU0vlc)?y zjFz~-#?0K#^yu&cFFC&({N8;j*m2tjopf!rfGX29ig;aXQ(pc1QLVe`CftUV@Dvhg z#+@XgZZ-V`+A(B;XEK$QU~>fz&$?U=4t(ehSbih{e5>;acJeY9Y<~rBudJPR7C%5z zVUOpGyME&aXQAEpykZ$NFTXo3UjF+o`*AEXgb=d60ritb#X5*Ri;!MZJ0Arucb2iB zeMYHq%J$v^&+S+WqATLS6aD$~JsMt>hk=`mE57U+$f|kn-fh$YYEfwxpA#-GFYtTu z{r>%oj^#-1VGAA15J|WLj$-oNrV={#;fLgA+Au&^x!>H>RH4icW({O@9O9Fmz&unq-@1CU`vf~bxsu|WL@4FZV>q7j5V3V5U)w6%FW zj)0NKtobP;@q{eEfEdJX6gWuO4>4$~88bP`ykIF134)p!id^a^pfCmc90c9L?}3|y zSk#nwZ;bJg+kd$LJv}-v%Km;*zTr#5l>P``_5<8*5C_4(J7^fD8qUA@6qq^MWc?@< zPpd~Y`N>(lob}CX*Pi3w$y-7c*=B5>^T6==%}b*1S*rPf?jGE%ketl~?gCAr+<1TA zV7~}%g(7%-9}1_KgTda}me84DED-Y%+EvA^*hQyjv-2tVak?%aG#_mF04Qhj8m^z? z0W=A}e*jC*pHQh4vV5qY04-NW=53h-*5B7Y79qiZ$O|w~EDmB80jppc^;JZ}g9DI@$Pb5Gr4xhCWNkxb?aO_jd$VdJXU&>{0nBq|Am12C#jB zF--jv&PMkU`6O#!I!SaP9m;F2@wHZ?N!-I!ho^7t7akk-D_kY*8tdjM&eJU-{DF{#4zJ6UAja?bK`(^+b@DiyCPgY|L~Ff{{v@A6PGKoSW|V<KBFdLA-9dzMJFfS$KV>xL03lxi-&nWd6?c_7-X9XY5c5Vo10~ zX+-Pa)*{*T(SYsYr|NW!QDu!nYo1*1nBwayZ59dFZZQ$jgwp@>bFPAFKS#avRBwB! z_D6-Yv2S{`uBNoqr8a$W<1q}fw@+JdcvBcNeRr`UYH?sr%)U@*;(og5&&iw9JujP< z4SERHh@&kMs?h#bM+J{`Sr`_dnE3|baNomwV1gIZaOmXfO64Ec+Pysu2f0SbR3tAq_JgNxG59k^->;ZcZdoXXY2LzovJEBj$K9G}~tWeLJ4=0!Bare&C-S7ul zv`b}y!Tv$3VfcD?Gx=d%0B2e-`UZE(*Mxw?K${A~N>Txj&p2MVlbSBo0(5k9^WEtH z=Ynr;d=~VVc%&R=u&@BQ$3f@4$%;U501W~j2+s@|85xW~!KVT?03KMw01|}Y-vOxh z!3Jp^01kKhtghZ5l7LGUv;K;%Y0~0hpKHk^Y7usX6aC!?s zDr{7c4qR->@wV{NCBBf5aCOxf)PS}MC)Xo4Pnq8Z&?oJ#EcwI*P)gb3b}w87vtP)X z<7|WdH;t#mlpT)Vb<8TR=O&o*~ z-eF-`{l`KIr4F3@n~-G$Q96(6J|w+Z{R&MW4mG0FfM!k*_E4~*23!o#UKaAJcEZ-y z*1Zet)oQ{q=k;*N?sE(VQyPE~!5R@)@Q#eljK*8D>_rR_2nzI zfmUyWLxQZEaddY&yM)(~WhScn67}0#emm8y5G%T-c&Q4u*P5)a z&KnU*#MeK2bN{gqWt^TW%~hFBM<9Jxsb_ycXVMDNq8*UH4|l}m0hJ;E>e!@K#q;o-Bji__=vs&<^N$6Lo}|oz4=|1ONcd7S-^g%n+@Yq zRKJtn<2_Cejsoz$W74;8yiqvrhyXDD{+^)V5W#ME039`0e%6?fs5LwQ@RruUM5}N> z-=`L*zE?$600R6#;A?HY0v=&N;Xm!PD9-{IHanYd5gtH1;lZ*N5a#i!vVg{LIZ-l z45ZpejraWtMR-(;Y&xTeh)(WEK6&&1kr;;qx^-FZBlu z;VYb#+gyd{nDf2Kxa9kDT4%y{{1pm97sL27v%bIQc)ahu)Y$6LCQ4sRPlDx5aqW6{erfum%lkw1sIh~zP)v(pYE(nx;<)2xf2Y)&BCz>pSJNgxlo`(b z3i`>1DjNB$dv9`{vHH*4+c^=9ov>1E)%NkH^;ck6M0LvlD!I+W+mEvWb;1`PcB`_X z8YlLe6P0i2$qcWplV6nME$%9(DaM_473${Os8?yOQd-*Z*{t{bUnv{V<+5+X(mh+`HaqQEp;t3c2cF-TZ=@lEe6B6D08= zjv*CWI{Dyv1x}<;dV#2R$Hc0rrLFA+_{t5N^9~ITB5N-U_SHr-JyofbIFb zneA~u4nDq%kf}krmUC@+>^!CM(}THm34LDAFa6_Md|cH2gpE5p$sYyy9`Jj<8=-SI z1)tIGlS<~Xt%iSeho4t@VC;Zk^94VA&tqJ`ICD&P1*F`?We3U;76ccJyvytgE>ccq zB`|@2=wWQ?`1UuCEc))3y#x6$NW!U~I5>O~a&f^N&i=?y$-ntVsduAT+1jya_j$m& zQT-p5XTN;2D;Qpk%wHMuSJEL`4+$S>sXD;k3E^)m;~Vj;o4);e_mFa>fky9gtL=Rr zv0r^0ov`J?s`{yef2rJgR|W53RJx$V}i>pz~Wwlg#! zLxXmDRYmEa9S&6RMsmd@Bq04I{#kK#T=XVL1V-!Yr&-&f zkA$hAhb=#)&_@3oqP35_CV`n~h=^8pXeUaBfuf-c;;f(u$6rW&|OO}(Al>W+XDA!3AdR`1TbT%5ev7Z)t*a z!|Y&SqCwWT+Aii!{bg20ZO3CbI>sfX)5WD?%bK3M6xItBsV@(ASE`>kUTJQF*+_BR-lh{JA0bd94>YB~3Ah2W4MfkK{D;$l&^Q`E8uXRm8Qp1IG-@K-&y zs?^$3)i_{Fe)gGtG;&|>#R=nt*F_zTLL2^>KH}Ce{+*|$M8S`_mE@xH5)zlTQoWp1 zc$NyyJreJ}-pzB2d!E^vRp*!z^20v4>cGFOP@c(_=ime_TaGt8!Fv=noZcpFeBoKKlM$(( zdvf$%IUy$*?IM_pef9}#s9ua1_LdvnYyw=UnuU_%Zssuz1wIbLr1~BNqJ;dhhmoq< zhp4glf@jB$4kB3q;3Nu;4aK@=!N|nTbW)jQz6u=ClKILdp>L^113MY)=0vb%dspkvPGgVMuN%W8CM*twMm9WgQ_!Qw*CDQY)oWMQA1-3ag+GOY z@lm%}XLU3)oz6{h@smkAb*Rny#nF!0N0xaXOk;4y-!uOnxTQ2Q<8*cd{X}!FdfxrR zZp%i;UVxK==k7LUyh7rc?Tvu#Awi2)N%bPy#rkj;{&**DdvwUB-+3lZ{Jy4@3a&Hs zs&d?l1A^P#TX<$tC(CZ8m{C@BFp$0xle}~oU^%TU-B<1=0+j~_pd3xW6!Zl ziNuwmDwy@(Zw1JMy}2MG*=tv`>?{*H3zzX4cN2FQ!y&^g&H)`q@Tc|JtDZoN;eVT7 zdQBKEbm|6%N%?^=h;)qB4ijeZzX@d0bbg>mOeEjAVHd2VlK#=f_>mN6uLKzCOTWxL z>Zo3)ZN%v-a4d4}SE=9Z7JA1`|1~r1cg*E`F=K+m=_=0j516+KI=L{g$aVN?y>{Dv zm4_`z$@=xYxg}mLESBg|Fg=u{wK;@;$a4A;pP1pd@ulRsKWL{lo(Zdz6mRGLm{bbf zmi7`tk(n=UC?(I`#g*ZO_LeRpFjMI%r*8$f1YS#g67sd}{g<++zP{_%om_&fZ1Zw* z?5sVt1_T2sv*3ZxkRGe|-0gK-VN(M4`zg=o1#>NS3;MZLr5hz%lA3N(uJ?dp1$gv- zfV*O!lclUsuj^u(rOz8d>OR$+T;#HEuxmzLe~0iL&|CiTOI-HHf}V2%dsShOxg zUoJSG%!Oy*f+a_ES_gi>s%lYK(pJ zr0Px4R++Y8mzOuiG+Oo=m%`gbzaGwxTxG#Bc{(+dv+TR~)y-polOeN+BY)9?MPJ-= z+ohwruZ%_Sljn9Xdq$bE;rr-6q!U)SS6ri`zw|h@B;rj)Q|@9d8rU|s0r6Ic%mQU9 zBoK$;_}jieAu-q4{u_H7xBn6Ur~%8JJI~=vy@tajdO1unFpqGMSs9UBu2qx)^~iFuszj%7)5UJlSK3lgEtXUbYZeqi|QwY;V-A zzM>B2er))p9duT}?_vc~PBq5)V_A(9%MnR+?qvsqmucTKn$xg857AlZ_$Q zi|^ zZo$Ckc3k>qd*!t8_wOtfv)e=RooLsY%>V&OGvAPjS%V@Q>^^W+(pt~hyjjsiS*iRJAM^5*jgsEQp<+J4aq z)jpQpwnsC?q@M2y&3d{n3#Oz+)<`2c^t3Av?l%V>hB%?XBwB{n#_v_0TSkNe8*t5E zE9$Wk@iQCG_(iht?F<+VRSIlfNTZ^z@fG!vHe`0D z20m>18FE`)E3w*z*!|^d&%rYpnd_tzv+LHu5AIbtc(k<$9!@am9>zu!qi!ZoZaf>M zJCX=KjVQ93&yQ2)8Ojvun!LHtqf0a?8z4<0Zs_^S(p6Ts9sNS9A=77zvgu~>?eg6$ z9rF-N5=mCO6%!3Pxobt9xa$ZBRP!q=6wt&m1g$bV~a;jSMsFNRWFP|iiTiCrN@5yHTv%&r{UeK_lS6eLbOKi^i z1;Zl7%2dG@9vHGa@Xf64xRZ~o>Gygu59bNfgYD2Z)Q>r{2q4gu|8X+U;OkhTCMzm| z3E!-@LoB*-UOU^dZz7uRXH(I(D3-Ey$C%&=z&$QQHnS|w#w8QB)!5GNuY=1*<51xR3=D8F9J{fxp9pjj zzG_%7VEI@Iem2St(*+XDZVDmqnzSw*F`ZH19L-^cCxWZrpD2<*y;s1lZCIF)wcoYR z@E&_~Yaw0qW!ubs(`2@`eY@!^6Sd~-v6;NpZ|az0#6R6TIU-85<}PHd)wpP(dp$g1 z=KUkXJV)qkx&)hWsk|N&a2V_Gb`42-dIMz{@AOEd*zR(s@pfnWON zeQ3Dlb#h<}PuoKA@^57;4=heUrrp5VU-lb%mvID{@1*rw-y42*Q{39RP~w%jyh;iA zt$c5Ht-;V;#@3R@N5!)tse;29+d|hH-^UkFGAXe*9e3Xvc-w8evC}cZMlbaQGxtS@ ziw^t4o;r_IqQYBMV!jZTWnR50W-T%CB=5OCk8FpN_dC_?M*yTJ`c$A|F6-y8E7;g}XC{Lt zW$x1^ZZb7F6}b|uPCAY{uTJ6|JzbbW&w{Wc4gd5}xJwygx#HZR{dW3GXW^Kz92*xG z7oZkLI!Gc54W&4ybe#aYDS$D-A$+HWkcRvV7^Po)@FeEwgW16N7KW-Y=E7&p&PLeS zW6ejdO^uB}Sb&@2Q$swEwzjEpPSYSJkPgqEU+vKN2LVcNmR(i6j_&#L1p|#H zSzO{P@I0CPUKw_C>b0~en4*@|`oDnu<;Sj@K#sKHgWpGEzqr8eSsAt9~eE$|v?P3y=6aF=zJEdFxSW^E_|SVDB|2 zgN-IzN+Bu2D`LMSF^qJ+e5y}rpTiD*Kpgr>h(p4Ee`Z;K;@(jJX)hL|1k3$Q>+v_O zFS~c?X4^Idd3|teej`q{zfP~h+y&()CMA|2bYo_{ZdYHQCGfz-rR#diEysWm(u8~I(o9GyhAv8|ZZ{fkW0}6WJz>LO8jKUy8<-Oq z9Q}za*{tpR!(BR(U)}cOZ}Xei(=Vo`-JdOR223xY`*5K!525WBL*#?(*2;{lyiutJ zSkPElT7vQTNFEUs_#jC_n77Skepx^W0pcFqLoRY_P@j6Qy_ndNbA5a<}~odGRulH;CxZ3tx zYt!YabCce?EZby0PWNR=DYHHq7>vL4d*}%)`9A6jB`zRt_K$NeCW)D3_ic%P7BAf1HuALWz27@q&&~Dg?$!qJ{Jxj7`tfHYZL%EK$|mmEKIr1WG2-CYUlcpbFw_>$X|?Jk<= z!Nh%tPL4p+DU zoYVWLoJ-d_xmV-{Okb4ItH?p3RlQOdjZVyYRo1w@;{{&BPrFXvcy{KfPWD|%jSKtL z=>q1@@7{6g#9W~ESY2J@$_Vpuc2zZr+8TCjZVtYSJw3OlsA*{(DyP)ZHoPL+=Aw#s zJ9exVn#a9+D;6_Ywr(X%v-9)CD^EzyTH!CjvI3``tFe9%mUDup$Qy0=)tPaw!>SO@ zqM!{*sCj@9(irC|On#!I($3L84Ph3w7|Cj(25=Mxm-zID zzNLljzM6ox3=G&wBob0Mge+`{H@+@#a%{ZbN-{8go_15fXh?zwSZs=@*wlL>xhXqG z`f&TBe+(dnW`RLLomADbET>4FU+=|8K5RSCPNU=!zqW_^#yXlRrv>LD7yscwP^kZ3 zW+l1^C@#S1A8e9A;LyD?hH?b!932Q~UY_AN`_Frp-il@*JC%X0C6Me)*M7*Teaq`_ z-}(Rf`AwTI<41yeVIz5{dF~I__1ik+I6NIMow_dnFRspmEQEso^Q$glH3RN~uo6Ho z6Cw{F4Ty_J8lN5-djqWFp{yuEy>myP)F->X(U8yAKII z?ss4-3CSra=biVhjrAi<_+)@1m8Y--Aep9w_DI}|(* za&upVTM=3{@$i*@%pW3)EeL59@gPK_wf+mu_i}h8g8F>6(zE=Dx|&)eNI6h1>7F}> zv@SBL&eZ>k_k%id4!{ZgH;fiM{<%d>CN9#7G2!7efftsm#CDpaKMUm#-qm_|F%}h( z8;~~+5o<|8I;*SO22(A9dJi(CssN|K{mXSWa@}odG2n3PK0m8@<_v_B&PKSyAwvjP zrmui*{u|HKKBssQ2P zH~t%5O8Q%%ahgt5Sh2W#Ie9U+qT&#Ws9n2EP&|PjcNFVUOK6o@UO6=0h_o;-3`gR? zp@lErl5ve5CFpRW1#I4t^FA#-+T_<=YF1<>4|iMqzDj#I^bKsjgzzI^XFn9 z$cXJIJf~*VP6RXXRDltP_^#qop-&AD@2Hm8~W1{ zYVb1+d(4m9hV~(Vi{V17v?x@q%*}E5EuP4&Z}5Sy)YGgiI=+_>7)H2MDL|k`I84EXTzv9)@zr+WGiM0n*3^^~&}?IAGjzuxm9Q=9%BuK1 z#%@y#=|;C%9tPcHa`gGJWvYMJf;STy!yQOqc9!{GN=i;%8RS!dz7dCQQBgabG12?F zm=}waw^`u?{C$w^dxlB5CBL}17?B>-JaDz+kZ62*NYSJQ9QKedqJ8e%B9U-u z;iN%*7kaF?I62r-Q0;Kx78BFdWIA&OG6K&Zffvw}h2t6qEpTNuIJ3p=Axk?=#FShD zQ9woIEk+s(BV+je`=j6HTrlT&j^;g)er{0o-m(BOYyFs>5(=_vEubqbBOU|f1axdo zP-|J~Ur=4lOI#5Io$)T1XfMEfbt)r*)f5(9(Su;gq)bCccOJ1WE&ZqeSxTDfT?G~S zlZgNhGKt8YYw<`R?ZjIcFY4;mpuEF;U&W2LEeyvpu!*OWYjev3>$v^yTQF;g5sCfT zys(X`Pms+v z@u17|X+ML&5Y`nR5?0Wwd9QK)Afj=U{LtzUH-f(b9Dus5_`u&_Q1U>Hd9`p2=`K=T z|3oxUxCw=B5z{|+uA`}mn~b?*UZ{Ge6%w(b!9j5Yo1!kr3y{Fq0Y)yq19BL_L~{OV z_uXnA87?eFImXJat|g?$IPP@l3na5muBSivFul>?sqZCfA|9|I8gqwPX`;Ji=D5P| zy9>4~wSa6uvb6b}-4AalWPSQx@&KR@U4O_et+@1zjErZ`{#Fw`9nyDrUj=@Xlu=Af z%u@^f7GBH)gf3BUWnpQzqK{44!XZ74ird<2$3H9AH=%=3+|M&IBxoBM^ip7nk|NT0 z$bkr{Xrc20;mWY|`Y6%-T)oxZ(e`R$&AEnI&k7;DB_jzZ)zpIh{Oq1PCT&iRuy#}t z*LG6^K<2k@{YJ#RWaj4XhZzKxwG$^qQ+W>BeYr`hiqFXAJzjWPwPU}x`Fzj>9JrRm zq>XJH`B3t5R?z-)J7+iUi1QZRv!_-tUcsxPW3N#=hC}D|x1hP~BA-QCGxTiG5RtgJ zxHvcj$xkygzfT|jZZ&R)CyI5uyF2tTKMD~{=#YZgl>6%)3p>XNY8cXV-=ee3c@iOs zJO7RX0y)};M36a4y2ZBi<3D)FF!wt8?A1|a<@+1z$UbVwB|S+k21?v~pC*_U=bl3M zTbN!^(VO-|@6VL<#*}0wkwoEo#ZwIdSeI2RlsZWfYSA(Gvq89&G~!S8KnA4j!7#Zl zS?S+hkYe^?*--L>RIFC)Q!42z%vrDkVE=5~nf3JPXJp1^jzd}%;)C11E$}kRYa$(y zWDGj#@U^`6czY0K_uwwDvVPVTMJJtE2}@39M>@v;-_%jioR_BF80W+Yd;tAT%3$t; z*J|F1^`DD&cd_!*t%XyHYkiYsJHy*9F4onL9y?a$vvGSG?tiI}BaHC9d*tR!dKj1-JHj%9Z`*v}6 z@qjh6O~ihJpe(TM)iDGxuBoeYUN~k` zG8Bb^W)LG1J_lfTr_>n39~XbA5^Vox{Hl4U_&mrd7S@+TZrOYqvr2iNdsYx?jSCt~ zK5M!Z)6@`Zc$@4XNjX<7QgBs|ZdnM7a&RfR+2vxwry5He5xsUbv&0NLt63vbpA_KPh~lOvqo{Z6^D@>V$f`+7 z&tZ8^u*Z>;xj<}c*zr2LfVK`pb{IRvotK;2aBj0L(wrdKmyIGMA;ZN_aF^|asyg@Y zx{u?%XE8e+m=B}ZhZTH>H=sK-;d10^sWszfJl_VAj51#aSZzS zH<)On+@wsq_TOKh!sP`OkB7#QBd@`H$>M*9`Zs>N$DaTj`OrJ=q{r+;AQcgIiuY`@ za)lEmX(unQ&jj~BZ*zh)&w-VlS!o%Pg+q}0$s9MiapMLa5&ZakNWl2CrRx50!nt0O zp2**c_zAk$`!{dQOU6RvzMp;JlJo12l<#I1kK*SiCj@Z5RzNB_>PAdx5E3yFiBNEi zs;Tcy|9t)z#{X1V(uX#aotu#?VI6tk9K&*DVU!?~f!5L#ClS9N!* zphR+XEI9skT~kPP^--x`7mK=JmmtrunPMReO7MH+X zA)P?ltJk||V9|ofahx1{Y`}#5pBlKp&`@UwKFK(QjDFhnxK+9BtJQ(flhJ*+XV{l%H~yZ0wY^l<*EalTyYWi_59+(ima0dPedUh`+6 z>k(?8^TVC}=e?Q&gr^kGUbsMHdgp=l?~)f59JktC<)c z&d9~J26JL(vwg2cQD0O*f(~Bc?c0ATYr-%DlxQ&+z~J@r<>6oJ|44uJTZd?>h;$zx zbSqFh1;g}(kMB5j4pKMf8Of-GYw`Xf~ zTB}tn9M)R|Appd&5LN1jM1{Vec0$g=$yO!VDJjEX(%G3(83d%b3XB{c}UVe}h5^9ta0$Az6oI>{g{y@zNi2obX%Nrf%qQIK!0y{(wwM=CMvV z`NQJCmrSPSfn5yx@fV=!hTu_Ph+yWR_EOYjVxh}{bS|!O54jjMCK1HBV@Gel=jPWg z!}qT={KUS8tiJ?I2-M;_tUP)jG z532E}bHxm29*KbgzK*7OFYrDo4`vR&;po?<{sDt&*q`hY7uV5bQr3i25U0cC=941l z1h@U2T+i`xPB;(f z8|Sm*?K-`_*%^CS^NnJ@b`u^x~d(rxkh$s~e#2$&Lf^kdoCqVzjJ zsf!V8x~!%8tW{r2>l&(h7@}~=yIt?AxMA(+`UlfG`1lzEnegFgtU9A-lx(4&SAYcv z&t6r4ynw+VAIGi3bQzEpp&@RFCUm{f04lQ>UGKOYtrjCnWC4bp+t~AQZ1?R4JjozO z^kF%`W$bId_0JcY&J|W6!Bq(Pn{h*cEDNsb1YQOVG`x@quxbG~CkGSL;42EV4+%F= zwqc2oi6VlCVUwfe2Z0VsQ}|ABBjC*Y_(T%UOvmBBfi+1;Paxo4Y^=VXUUYc)z{;P| z-gNT1%IzkOL!YiAmo~rCBy04OH)dlQN?k+gunwx6Gr&T|VKIV3Mcm!HAFsRKxUqso z4|Vbs4yDU1l?%$iehsBFuVRwYm<)et7_)~`$6Ak>afEVqA6hDjCe{P0;n84E7+uC+W+g-%vpm0|6e%si%@-ftN z77|aC-^F(3ESO&VLMFZtp9rVe5I}etxjt*{ z9A%_&IpzAm?V(1A#;&nR^fTxtO@acL)2W7sC$j@%)zjx`u7CDnHZGe}-$a&wn=M!+Uwe8s;v6%NX`+VA z9`!xSj?NmMM+DL}!Pn%~eZ)>&{nmYVV0aaGDb7en<7xaeElaEY2Q_vdV-uj+S$xvB z>h=`ONa_*Em!8g`1W!mlUS1-iwpFqI%MGn}K*jJv>GXGRM$GgLDa-Ngw@v8ZfD@33 zR)Uokg8V{5LryuhonZWnYrPrcF*4iZ%50u4uNB!@}yRHW%~nNhuqsk~-*hVGcQy_kFOY z5ZmUY$C>Deh;FQEW->s<;pg&x$2cJM2U*W8I>Bgw%WW?5l95ATiJi+VpV0NOeCGpT zJkBp-Se%_<3I4&eU5d6TgG?5-W@%KLj^>@Ze%;Trk#(h0Y-e>M13kKPH?CBITVB(- zkii^lc}lI>RxQ$dWo)T74Em!<+ogyXKh3V18Yh83AZA<4rEq`JQVYzLPnDh-JA`d2 zQZB^U_eeu?wi@Z8@$UIp*HV6C$>ZMM>m~V5ul>Hb5~V`OOO4lepE;XRK37nR_sb~#jG(Kdz;$b5~UpWv6Nk7?o^RqW6hooK0SM5^r(sIJumtDXF|_w^fhQz z9uf(%wP6wGF*OadZ&m*7IIz6$$HPzJPuHX$db%7RVCDyZ#$x0^%>N zha96Sr}Px$g)43x5R^~`=fL*dghs;Nu_g1^S@T!-48IkI{4Cj#h1xsUUbd+ z)90ZijRP|Um6O^blD3&b1FwU}YuWUIqPBVc7Ia{TPs&!5t?kvH4?@~~Ga+k7tMpYUJ<0{EgM<~Aq;APB^Q>mrw8%z-9|Z!ho1GUR#MST@C!>;tYpn*EiaSQ$}W9t z!m|0kpo;}fp{L`&VQX+Ps&eMRRDPlh5!Q^f63E*Bj=r_sr}$YtwG`vKgk*a7@Jlqd zY)gi=@f+Gf3!3vyu5Yriw*ssj_EHrhFx7v7~g@ho4=OjauG&YHaw|63drD zLLWD*QLG+3P&WF2yqod_|Dz-PkH~u;Fw*-O%c+OXIJ=#tn`08>Hdd=>! zi;iXhceyGJNsHQdH(C7NVTNX@vbYDXo?g#0uf8+8QrKv7XiC9FDDT68nI5=aGcCob?G0z;voc3GNd1FNgOBbH zjY)-u#imI1GO{^mlI8Sv_C{ZHvD=+X_r<9=uO_{6$FAVtZMK@XK8A1TPE+b%J4WkF zXCL~^M?d3t<1XJXhwwZm)vcd;D z_e}B1{SS_o9j86;efmu65}!fJ#^Bk%y!b;E5^PAG! z6l#M>_~fDCi>``@+f4CMsGHEUcqVxHP~U%Z0n*buJ&KZ&1_7|MN?LtJxqLP9-Im|0 z0go5ZOGCROv(Xva=ui%sYw$M%NF{=h=C?TlTqY9`MT6aGJ)=^go+<|!8{Ap(Sv|a0 z4E6O(5ddY9HMEV@a;qm^lt-GrYpX zTY8FMf?wsVRdU!rQq-x?9u zYi?H39$xlVXmjbzNwvnY%kG`T``)&k3okRb&(Jo`rKyxZIoEDt4Te{&a;rOEiQ4G< zO`8mlJo!lE?vzhF1knB#UFl8_KYF?|=g(Kb{BSjJQjtW-!^@joKH1i`>T+&kWTgGELSkC`lN(;85W5D8pg*kk zwrU%+#in&af5l8+{a`<$Dl2@P`XlXFJ6%ga$dzB$dEDlr#%${v>*VupcDeg>*>`;8 z={Rb-9CydU|IDwkeAOpza~f36^$&u^UQdk@(m;{s`DNCdmg)+QKcX!0(_B@cV-@EHerjFgzId|3minm`Y%D#T_ zvr^iS;yEBHQ1m_G+LQBhojiN_mpsN!1wHMPSDcY`k+nTKemz}J_T9d+?eZ6@vT`J! zW&ED_%X!m>Z}L(60f<)`6~?4GZ$~>)Lalfs zIqX`7Q)^A2?)hgHTYP!@yh4}LuXaFL^wIp3Ovwh?ZD+N4#l`c%1E?in<^tD!>sweW z;Q!vx*l4YpQ&F)>C^H=%>^c@CS^+~>cQv*84q9rSpd2FI=E8^j2T~nR)Ux3B3rtq~ zJWhO-{IH!kLc*1<(L29Am~|TE3^|`esyZ%p15$ z-X%LX8Fq4%>(@+O^RCr>|LSst164-pZK=mn+ZRMyI9Nh6DDSR)bKe)88@?=4FKpHBUw*>Rn@A@uLs39-Xw0Yqm%u-}wtq+wFXbz-hPgkmzQ5Z^^O6)66|W z{%eEJlg`I=`v%T}dG9SpVq5DMP+{A@+QP&u6^3VNeAy(6tNw}}|G}ZrQ{5#D`?{Q1 zKQ_yHUQKyjmBTFIhmVQ+l;FE|wzHh4_%1qGDi888nB^vxHaJ~}#729H_9|Ran3$NP z1vw0QU{nYYr!BhOT>K5qeHc{0ef7=npBud|q0ChEoV@|RR!Mi>0xZ7=GWb*`^Ut4D zlQ^N0*8Ry+Ne6`XIncRS<`nwaF>?5T)7nL1-U=!?x9? zq?S>_loq_9loY~0xoscpQc~>&O}t-C<5&wWL^1k;F3lgu=Q=t%h|&m93=L02bDRGu zZ36Ex_}k}@R`^2Ukzc#Kyw(7f4vZ5rGWgWi=A13iw?==J7O78DUTbkA24?2Vf?#%B zu8q0(A@YmxLa>EanV{YAOjWmUqo)jyu{GZN{Qkt1FGc$tpUNJS9$nmP<4}5!D^`y2 z*N}4BnQz~@PGq!gef2p+?i{ykcdEWnC6$S`H~W{0`bR~wtfM)}xw4%fzrD&-VxL=< zy61JyWi(YVwO=avq~(Ld)cxu#0#4m#!ex(N-zqbBxbd#`^brRN#_xtS<(6l>wlk(% z=zjOxzH?xeZ^d|_UOX*T@N|uJc6ynwjOP+>`lsQ`vlC?CIPpcV`&Gkw zRjeUK0s+dmZmq^Yfy!*z^{r6-Wsg*Khp*K%X&)}aIVfYIf9wagH(p1MipP(4*3h-+ z1ECq!qG}P&=bnlfx@%)D-k}vs$7=n;sd4M8n~@Xuxw}gppZ%!8cC2=Q+KI4$O=&00 zrpkw1J<$}bZC6uM1F(l-zP)wguo5xGJ~@mWqWX3uY{ol z3d;}3X~2$;-S|Vp@{i#)wDdO9&{$~R0PHcm#;pAD{JB!NDp!g3^;fRWWdTiiA15^h zs}V@xly!Bt@eJbFe*uY!Ifvm*w7fWj$!c9@wOv-E{N)3oD9zBN^N%W9W{czdHm{oA z+E3;5q}}H2o0?`>&c^HBX*)SHb?Y6)_0+C@;(oXE#O|-}M{fn`>*xwm@C{sEd2XlW zrYy}Uueb9_ux;~|MJ9#8;AIH{E_eadZhU&?KmTtH7Wmd>7Z{;+2=N?fy!{#v<{PGl68?`4N z`6Y+GT*93*9W;jf^6t5J*S34V3Jc$rnx^O^sXxw88}mYD!(=m0-PXYy@!j2qL#Ojc zDdS8Xnc7%4yRzoBJ5EG#Lx`PIFVV*n)qjp>gUA+?YoB7j5sxTc$22zIkUxtn8Ln)ZbY zeUbb0dg0WGu5^kznWFHC>zSi`BftF3RNt3LQ}GonJ=AoSKQqYxq?`A?>!ID$G(6oy zaX)`n#B2RZc=0-}KJDd8)rZ%2hIcQh;&cTn;42e@OD*AbE3nbw??#FCuEp2;kZ)* zJ&n>xSWzOINllHaRfYj@?QX%;s}dnnN+;0Gi#y=qcp}IB^!bZ!ktS8)e0C0f{Rom+_ zX`K(lHIO>s0#8J60ZKVCR7cT>Q~(p;?_R)5GMTcz>#yun z$Gi>M_sPb0SFq%(zAjzpklH2}wz5HBf8VHSzN*b}4&@!aIv11lzfuGp3ktK`;=eiH z`6$J_cFR%9bFbwk##FQq{5aDk$8FFtJ+pWGL-(tLQo?zd6Q(jDI?~r=b zxz-bZO>|SXIpvCs*omE@eiWJDxbKlglvIHvGizGQJ<^4;Z z_-W4f`Ro#N9mT%Mqw_MT;Guc$=^ip^@7^ENDY?|QhTup|Ayl%4(iHbN9PSg-!0!ZJ?&sxR>7N$I;7gy-IJ;8vvm^7d?^F!{-~z4M{j?xIdS z2eP@jF4Mi_uXVnwrANj3J6zB{vP z*zV`5J`IoYi-rI?-ucaU7f%&dGAPjTkZbB?KP;u4JC$8-vM&3;eLdR#)!jP&GCUnu zlxxnvmthPq|Mgqo+LY$~M8CQ3RW8csg>P;_AwN7A!0kV=!okUD-FISQ=l=c8(xH($ z;66Ih*G^JWQ7!#`XOaBqb2;DC^suxre*FDa@q-2K`!f=!zIC=%+`T!6Ch)YW;LDR7 z+j&!swN95&nXo*5{<7L$evns{Gk8mgsbX{2o2N4Nls4|v{&dFnkM@uG4=}m8)Gw8V z|2n?zi|PIky(&3Qq1D3GIr65a!sm*(+YVH(>;K$voHr!G!7H;{qb$f1VwnO zCXd0?c-Plk+kXF=5_zJFBFg+}yn)N{nIAv=gCiYQ?-p?#++1f5cg%22%^Y`IxOeTh z1XB2Wf&W_m#*xx8XIr)c9S*7OG)nES?9U;S;4zw_qZ*Xkb6idOZ&SH~Z_(Y(gi__t2&*i&#%x;$$+M}_m$ z@)@tUN4_tm`^XI*e7nJCKw4NpK*cfxRhWoKw}9LcKw5CwNp9N?3~Sfz99p&w`vnN_mbCDScj~ z*bx#uA)MyVKakenFBWj)_FI>|f^2+g0iw40@B2iuQ#PoE-C}q{u zF)@Mi^Jn{nzdm=Vt1;Onxmz{9`=ru_knO9|x;c8ZpP($UPpi!EUd}c^8+U zr0q(%)z8#M7^c{2OI2eg?kF$N{yOnKz;&;^lS13^s;5g0r-crRHrxOB?6<+izcPGV zLbkc|9`0EkYy^>?x&%=)VHXGqq|%Q$bfYxsx)qptzI117OrE2yeqwB6oqac2AW3F5 z2hZR{bv$@GZ`wOl$wOPl$0#G3!O2qDgpI|s{PJA<%n6aroO?&YWDZG_i4M9EpAtFh54JfQwRDQoQv5K!nA{JyvfCC=*j*U`(JnNxMlf1 z` z+qNsUv<}QS!e8!Ozga}Rz-f7mbm8^8QL^5wPf>rGf7`Fp0|)m!9G zCk(2QH~s`nei`_=uGt%){OHQ=;7x7ePe!V*QR&&P!LQ)ER;Rk)54Q^)HP*J zk7_abg_(AhC%sxZ8hyj`rEn3iLlPu%$kQ6!~~&%NLGYw+cgI_XQZ6JgMQLE_)ae9Lg)9 zW@r;Rbi3+-w@+QIfW!CC7rt%VP3QOJJ<~_Ck4ggD*o|GT(gkXL`%JFsLjn&q4|c7C1CR?V`wk$c^`b%j!9w&vNhET$q> zrknc(qR*R^#7HMc_t&Hy%~<#-{Tfm>R^j9rNeKh~n$iWf-uT9^4?ad|X6SZ#ial=` zjkg!$_`(|!IwTy`tbdZ_bwl4tm&+SZPJEwsu{uh_-tH|f;n2YxMZ3v?%J1~)Yu?W` z4E^}|Gxx@0LA*n^)hK^}Cdf44>IoR}^&PnWyp zoPBBE{ZP>xz1#G3!JctVRU*;W{_D}9)D|Rxq5wxd2!Wmm;bpHJxNT8|OGkSnxU>bG zZSLEGkC`mu8h!8hXsWi@_T(C#YP5e=7sNIek)~2!UY=oP-%JDH%@`ZSN%38)9XU*78Upm`P@#CXCmaLM-+1-yS zzBah*+%KFJK%F6c$VI)lyt3aXE$ND%LzR6hB@fT;mnY-dPJ8GW+Av$#I6gY!FI{kY zCa@@Hoz4CMv1BV3DaqaUwzV}G#I{Z4#c=n(rKu{XA?y9-m5{#EMO613mrdMrD~!Gs z7+4>wGcA9;`NJs&FZnmt3BMd^jkTA;+&J|#dX8;-rzg}MeQ{xCH$zGjVXC6*-|{<5 z^n!3vfO%K3{zdqoq1Kyu2I{cqSx0wT|aJ6`Jrl=e7u&LJ~XFroWVs_ z3U@;Fa}&S4i?~o`a-{WxmruMmhlVnG$~9YeGC2NVyfUA>@$TRm&77gdh%{+Q+pvD0 zF26kA+jf6{*(QVdkI_$E@v*G>1?T#oik4?(8>c&5d*<{~6Wcup=@qk1%b4U2%e>E| z{!#z0Mn}4bEfp_y^DCa9rlF>0J>(&6@*EESb0%&f1F#>NDjm<9wv&>1A+PS@vVhhb zaw+!h?L0B&?(Tm2{{A6QatkUe-5Ub?hlV7qK8xb~1gfZH=rlTcgoZ?&{Zw->19>oY z^Hnr&TwPt`LSH*RLt(9>%-YiogzHQa`99C6g|e2d>Bhmo_h ztEZ=D4T{FP7IRp8lj{`_GiG(DDuYbENn}Q`$Q{V9&~bFGIQ|J zE8LGNj16O?GpRH^KlnN|Rc*&K+}0WvyXWgv&Evm1-mu!+>G^~Xw(aPAnmTgwTQd7> zl&@cq&*I%%Mm`^VRI;`2Fwgz?ft=zzdtRui=jN;rhB$4kDzj|eBdF#;++w4ouB&FX?cir}#kItXrcr8ha zlPc@n;Qn{tYP9q}9*&JwdCCS92nHptqM3t=-I9UeTKo;kJop6`)=$}9Jix)Q~%Ty7l+j()9y*I zq&}K=n%94m*3Xa>5dks%>7z%AOf@fGz9js>C%f8ZIGrCJ0r?LMDcBg}rXTzO+6yuw zK=(b_e9-6JB~jMD`5m1Fz)XjS&47CWI|DDQW3`2w=EKb0yM-ZRSuLZ2zu=Ol$%OR{ zE2E{QWxl@k;lqdj>)eWEF`mtr^9-E#2L}Pb13kimjC}3?il;umcZ4p84L;3FY+1Rv z%_vfp#YZ%Y54T{nr-#{v`Y11a`LVSn_hb4%Df`axf zNC*#t;stYfx0yyMHQG716=>5#cF4mw{h98wJDb_|12TfYrG~1(^UBgvH$bV_(l9nh zK^F(d0DMsW2(i9u!pI-IYkF4EFQCR-YCeDX@C&$r^YinoXlT18NC}$;Jy7uS1YrsB zU)z-Jp{0OIFG2Lu?KBFWBy<75*+<`+U>bS==!5%(*ZP|DP zmPeRe^L*}ZcZjXHwh-`?&F zz9+{olYn2)qM(H+5A5aArAI|Y&bThb8pvX+)^4!2Nd}T*?di>#*_i)Cb~$M>$M)sF z?kdGa-X%Wj`uci81sEUCG2jJ6tP=S$XiL1nr$gW=j;~8|`=Tgo!&Us@bF8-u7cz9+ ze?u^kN=P54aIb#N%}GSU_0|n0plT-Sf2$+S0A+rN0T-gL8fNFvZ$?E%!VTox)RgV{ z3Gk*%uzPqO`!*T#Gf~g0XPYx13j}_Sm@Q-xt^Oy-v%jORtAXqcA z6bqIAQgy;Ii=d&y8K|wF?LwT$l^{??=fh7Q5)c*!Z@{G2n$ZP=&nBq|}{*qVMO z!l1ZFNX$W&vIg~AP+Es%ky58|W9Rd=q+F>>H~#bf$m9MvPki1ABWOa@vR(W{ zvK9z16(D3m83YnnH;9YSiR&#g?GSh%)TZOv3@7b_b@EorfFPLIxn0@qrqSO`#J! zM&$Tm2rhjM6mc4D_91B*8I@!C1Y^$w{83CEE6!em9*a9dfS}+GV%Yfy1&Irzt>6y$ z4+9rRutt+ZF4NpRrr4}&d2e&S0CkHTc4vp1IcTleG#>L45q;CIsBmvEjTYfNENGRy zVPE?(Y8gbleL6knck>U+k%Hnd!_o)EH}>72%|(QTJ=A?BkPMkP7`>aI6ScBBh(R59 zhT!Z49}9;pKsR<6ZRRz3i_z)#7y$yaSajG=y1BZ- zu3{3vFZiU%*MY*5NhY#QyOCv-+Cto6*bPL(iy6l!-aUJS5mboCrUi)^B&PmR!0eO*-*B}_f?6>AkYh00My9iw+M z!0AWg;^KrTEq=F~qr4`EfwXd;1=6JS^I7!t^iypaarP?E|LQ&ie`X4{I*=O$xo3gG zNU$ZTd9Wr$6*b?UyBfZtGRY3hfj_Sk#%q-Qcn$`eMbdPiP2pIl^ac~rAo@xWq{rc; zpJjb3naVp5J9ZUae{vWWrtK%UAMhY_t%3LO74}9Z_lDJmc;f+W53bFh23A21BV@Pv z)zW9bfgRXoyb{rjRwGFXj@FIQ&veppvu1c}KdeGo1DrNy+s}h*nDbNJCE6Lf2GqGe z0QH^yWztJFN|6gsPH{SFjf=R;ol1j`78$R2=Rg!w2JsGtR+3QmNx!Ll)>}U&Gow71S;N~l1cNM z7;39mpOUj04_)c)?M;E(MR2W;(6wvFgi3`9qp-rmogR%1liSlEe?R6&v+H9-)BfOq zx2#vQxC{bAn@YQk0W%R=BHT8pWtp}SFYuo4l3oaz-w)(kJtoG zkr4edK1bMNK>rZ?j{M56E7hUZJV{%=NDqIY(q z{h?smE{+@G($b$7zJkM((FbMPW*&rKz=&GAs04YS+iEBk5nU88^zxG>vgqnMuPjVkQmsR=qFolL%rXbPw>wg@|tZ(ot|M zPj*Hn6?FXk-!1~^3gZah5G^hx3-BCcGr{G!qiG6xJT(tIL4aJ@n3xPeSsi>*AIU^Zm0;3C`GW8( z6tqiAO9yIUIKB2Wx{{;>xQc;|%|dYT&8|?$dso5ohwT>nE3?0TNwIE*-kjiw-|+B2 z7S_;bh)yt#wY9Z_2kxrJNoFYDf@JgtJ|5N+1h@JKy*v{KM*}MgA$K?*Q1cL>KI_-7 z7qb0|2J9LnB1mK^LH&%7fRKUfvPPh3q3ojJ0nx%d>#;pWE3%4BM2<02n2 z5Xhi}D2>Q__j0j0qhu4tb0v{6#t8W-*xFYNv!@^-;N->v{op{x4;N!o8n+=-VgGLtSBkRbdDp$DX1F{T`tjHgEe?hc)0R0; z5$A&W^rOd*S58;a5*kG)T=Zw@z<~IM+(@-GxCZOPk(DsJfcp9VFKNSkb4}!ffzl~h zcTAvsJhKe<0+DE+!E6yWD$VErGC8>dy8>*;6R;FU7>JnHRp7m_l_(mTV^ku@^kC5A zPJzdpUSmLT7siER!JPPsXAc91y~(rKhz>YrBbtO==9($aaR?O!5qbQQK9}p@T|8#f z%a`TRH%#$CbA<@LMWF$JVgv$meDK_8OI?+&>GQ^Sr%OE+elt*D2Hr z<*4_-s>0Im1rGz1*aRA8QoFfcuNIpssHA-`G{;6<|Lz?|8bZ;bKWFYP}R1Db55bc1#U&JCyVK>5d z%sTSd1nI*JlsFeo!#D-&KE4(hBsfdsWBS0C$4NR{!~VzD8aV7=CB4m^&-!H+YowI= zs56$OqTXOEn??xRCW!SY-|@>3*ua+98i$pyuB$`Q7my)ea6Svw{G{DJt#-2^K4xW2 z66H)G&HNVDKQ#yi#qLKZO8FDjQxBvZkh1zrf<$7>?%zLu8T?2uja`&=^T7|NAILKQ zE5QwaEBh{|S?@+FG1kpNV&&)803@9uk-?ehYu)25qhN|48kTHe5^d=k;n|4 zZ+q>(O9zS_6f{ahKY#rikrtMgc0s)Hw?ltYyzyC1ifksCA^(0`3W|8jf9p{3m#**p zw@mf_{K5aZAOF%R|35Yy_UchlG^LHsxxuPtjGsbZD@_LsW68{;sV$?|Wy`A9Q*498 zy`y-eCKLPi?Gq5a-dutmqK%=E=!`Wf?ca|QOiSg0=J$o%uij~shm zaFZGv8=d^AC^}!;8y-G=~e?)w!ITEz#HsJsOSF4u=m;U`jit7wc-xbYmpaRA* zRcPD(TGRqC0V32Zi^J>#RX9Ks+%ec*hS1hl+(QE`JlyDCN?g3zO;frF#S;S74v(;* z!Gnbhfw@Qzj*H>~<2m&z3P1HOn$;u$T6k;+9x@>9+3!q)l z`0U?LWNV0f=`q#dC;|xsKvbx!HC3^fZ0F)qFl&eb4ge5dW*rTD3 z8W`LH~>W?!4L(yu)U9g^3@Ad5YgbE($ds)u_7Cr&(o*SbsXWS zgDsjVwnU+}3))Tt<^!y|i#1bQwkpBC<1VUXINZdTNiiwg1NW_MM*%@N zZ-EW{qP(1j2XhIA6cERqIDMLr3{E#;*MN!oQ>lhK`QM-Oh0PGGLDT~IRWUTD`o zvj#fUR(F7_3iXzHuDIQ}AOI1wyR`Nj?0ywV*@~TOYg*f5nhLMHL`|~B& zcwt=?dobCbn;KXa#uJ$Y{L5>_5w|gm?%(hH(2fqNibiFUXp+__4e&ofw*e&`VVVbj zvBT6dC^YcRXA%3B&wB6E)AH&^i?S`v&G3OWVP(E4~=pSQ!%uiUvxs~^_HnZEl zc%)V0@_|B{W0#I;knNMytT^!Ve{lSWq2Il~inU)xudYAg=rOie{ae0lWPNn|jZT6? z=99lnB1rGGR}JN-Xsbq<4o0VP_U_zO?X8}w?rT6ZZCIP;BTaXwNXh>(v1IDVI9Icp z1ogXw*GYejNjko=Y2?zKqVQ4WyStA1RPGtFzp-6VQnGDU|H((njT`mPfn>M?tLnn< z{ySU=2d=2c)v%gyW{v^*mD-~98_6q$39?&X9ucidj=WZvX+L^-!c3l-m3G>={PpMJ zv-W0aN>7e0?0ym%+#THwKnVK$Vao`K`RI4kbKFPU`_w>l8{V{5hLBugPb>2XX)$$<_7=`Wo0g#c-^~w2>mIX_ z)bM_@Tz(=;)ZUQ(lG#C;d3(p}%{|T2R6mRJl#?FDTE_avnrvP775!05TS@8W)>rF3 zlY7m-&<$Q2Q8l;U@=+X@ro16nQw;o*=e8_*$NAVK{ASM{axV2N^9W}Z@jbFqeQT?+ zmS9;%5ub#v9rayYRdM)+SuL52X42ie7rEL*48_lHUMj{ybJOZE|M?1>&4%iUQMzRd zgyLRnAWy=u41TSj!kg)BR8w29!(qBaM@9m#x94DCWo2b&C!+gM(iGLFh}NPDgaapG z%F)@WugQetK9uKFn^`gCzKoBLk5NQ^N8JDj2PyX%X=z(2Uv+gM%=#o- zgMb5xcvr4m=_|HB@~rh52MY&B_cOgLXickYYD#+gCrw-RC+j*oSm1>n8y76IJ)xPJ zD>3ck$I8&qW%UlHEJ-CIT8op>C-N3% zw8{^kw6stWDjLX@&P<&?%0TIVYG?82KugSPV3 zmh6$Pc_I^;9;*Ja``rb>jFxLGnc=kUiz33?Jrz{rTl%a!KV99j-085Ku&r@o4_la9a6+;noH5Hn%RV=#CF7MA zzqY^8Zc6mW|6uPe!>U}@woy#L0zv6UL6Al|B?Rg2ke2RFrKO|`U31R$9^dgD`_KNj$B(tc)%DHUnQ={ck zZb*JU!=~}*l0hwYYFN1U+Q>Zcj{zwK-%}>-k)9rey(JtU1Y8bgaBza3>r?d>_Wx}PXXAmQF~qjWg9031h?on}$jZXj8a?`s5}u4tyO zzrnxtqVwESPQjByVE;YyjY$8YHHB`^X3MT)VTbLQ3TENewCd&PJ4UxD@Q>_2nKadX z8hy#UZ|J=m(s+J)_uHIQZ{h<-8tsdYP56PWz1kr1bPg~3NswE-TrYLV>F3Gwd}11* zcEZZlQafzgY-__ez_CxW2Pxo*xAR6)o6zr#RwcgL_^G#6Ci=Sp!B@@3mzK?*;e_W; z<2-{r57jTjeuJBi-lBbk&1C50AzsZ7@fP z;?SNw$k2ST=Er>F#&_>HioqhD^TS;Dk!K*6>KCo5pv7q$Lye-H_&&wwN9CYDe4Znp zH_>e_=BO)U;XR(5qjqTCzW$|hmBQn|`f{$H@4>{KKfF-l?g3Cz*76`OCo2oA)Y_pH z$;0Klw=Vvu35rE9ZW|Yv4hEPHARko^)*J$;x&bdX4<~#9jUIG1-ysETZWy8QWZZEyX}U7*dw8+5kYhPm<>^##oeQc7>q zQWaVm4xu)zipNaZM@D)Z*;+#z<^vx9S6e#n#cR2yyZ3ILue8g+>U6x{f6?VFx*0;;%aV+_KG)ra4)S8kmP}`v? zsL?9bP4+Fh>D32PP?shTwXw@aZTZ#)6VL>Ilil*kH2dl4@NW7-(7!43$FXCWtx-@S zdIarqsmb7Tgtf7>g{e~U>ki|-CaDY!elyDFQ+KktIw@te!m&BwVF(IW>puAH+m1+IUO z0iF}!%Bqnk2uKe+MHA66V#fi!1nNE)4FyUA=-~>$_Xxfdg2xO4eD#*wpJ61A27jJk zYD@#rPp||ECPz|NQc(e`W5YT9)DKNK<3Kn-Kh%V=D>VZTzU0>%eC{>ES!~3(wMw}yj zImpQwkx`wNl5|>_*vK&c%YDn(gGnLD=T#5AL^y zF#Lw0f}wl;57=G7RF?h46MVR6npOJdYi@elf_Bs_C)yq!4tYMmW7ceKgYcuL;yQ~f zDP97Q0M3N~Y?oP1^Ug)Qj->b(u}I7%HbmAHu+*Vjbr*-~{qygAK4Q^wAwX>K>Y7_a zjN}0yjVOBn!nw&&Ib@T$VQraB{;z|Q=d8w>%b;#Pb$_RqT6kj+8#{01;*dFfXRV8i zhJdZnyz`4aVfhwwa{*Vzh{%tUlF(^aVY$bfW`({f-q)hM+q34SutalgNMf)`_|)P| z85^!d)WY;Z5ofNj+2S-&N1#TQcbBl9v@`A}s>XZP<;svgqR}4H!SnE(2Op4sguWFf zKk77ityrqB|uZ{h} z;Q|}Lx!G&Bsn5hF$D!E;s)&sYcNv%GrJH0($q*%=^)IDbo^f%hn_wf%5Ll>LDD@d| zAkdpEOAK(Plf4Rd7?*{AqG7@zIV^}OP7 zl-Om**I2c;`I;njo0IH`BN@|WwS!2xB^qsRN{r=qQrXaemRA#(8n~_E9$SwaWuAHv zfS&=8ajT^OijL28jNb8_3bK#h^Ema_1!9{WorEoEo;aNf`N`L7Sa6l^ev@iXq<&L~8#70!LCsYE;R0we25)W#IGHI<{z^1) z%+5HpGWyU$dmnWVVw^2}@PszK=4C6PaPQ2~--K8#1%*c?I36&lc7+tL!Ua&QWU zrZAM}AS1#DC*%QjHtzIFn3+7=n`0lnTpPBR8*{)}7#ds=?iCNG^zZud?o9KMa0)H$ zhe;p;zy#=%*i%ZylV6z9u^!4GOZ24GsIY>G1^8rj15+n>^e5aLPaxC$z7BH+z~nrk zu0bUYX54$3hO1}pXF_U*bR?rhSt{uD&`~NSFWC4)!VamiTGl~hItQjCZS;`tDW%&7 zfqD|7S)1nzA;Zm7_FL9QLHYx!R81c?Fj3zK$mDt@(k1x?uE3iXaigeVSr_(4m4CP#!qt}S(j}Ljcd2W9uGH3C2=)Uvb z0rjCS&JRSTx10W~y;E`Dhl)sW%EQ9UjOKZi}2smIr-ybSswt9osa0sZXu#nI_fQn!Pfl?2E+34DA z;K~o?C?hCTc^Mx8TF@*rQ-#^9UdKQn`j_IAr$b$b1KFtwC8?M#cZ2T)PrZFpNa_&Q z{A$0d<3ggK{oIfzaQ&;op@kz2#hz)}eV4v3NthAP>Cv-I2X4!jnTe zyJs(qSqI2@IoT2YcCDX4twgIWBOwv&?p;V2%BBvOOpEx^bd`Pk=Fbsd+GfGGZ!_J! zt+^KPLTWy6$feWXJS(z&{~gK^aQrH1&;GaOZ{R@?c;q55R=|P4VHj{qXv{$A*8}V} zAbo#T%0cLt;j$n&7C>$R;v|g35eSW0ATvTN$f4@cfg~kpa8N+z)3O%3aFf+7l|mpY zhGf5jz9q}V;BfB?%ZCY9PVeS%vl&wRucl!TCo?R{?? zZ;hJ|ZBUuaKEh@Xx$A;H26T`AgIo_O?c?~Jq+8GIeoJEgd0K#f2UTHe&p;ryzT+Zy zgudl-Y`vPHu)!t|ebSD?)V4NA=aBePuI*Oq%X(Tqeei+uXk9YuWSF|TJAsKWbjReS z5=P8z>RqQB3^UL66Q}ixuS8$goSmSIgUPliGh$FiWszKIuQvT zO35t#xDeTmHrUy#l=o)_SQyKZ_oi|8TD){Cev(-H#^=Z9`*8jlm8f4>1 z7JXtJa9{i5$l!EcIMPURZadCz`Jtxn&BBp)>+ugp%Q=XAgj;KHK6yr>{Govvv;Wo4 zV~?&G#I+40uTG@|)A@9TF4}nAqonn(2+~Dec*R#M%g?lZx);{ui%c+db7C4|j`D5i zX#5a3xj_`H8Sr&JSgHHAFcaVB`(}h5vcl^?5@_7|VlQ-t(w=H@Q%1Av!PWDi4|h*Gbs1}9{65FOqs)$W)c{k2pCYt<6Ig__U$LI*Yjr0%pxw^(fw_vv zSD^&b6aSEtEY%2s?)KRMUM3sTSv!vU%`WuC=2x%lcn#gFz9)&9IV_o(NG0^Cn>-zA z3VYhp(assiAlWsU`z!~`D-yNOGZ=#B?R<%*X8+_fxzq+=&IN=C_#a^#iPiHRDWPKO z5~2qY21L%qitU<*vTLTGQ6Y7J1Oa_z<9(ECDf;6UKT!*gpSMNYh+j8E9%nZN);<~j z@IcqQijxEn$rX5Z>yzrcwOTM2hS3Y*L$s0}KtF!)w_-Pf+t!Tp(=w%$D2gXfMDrGa zq*|cOC-Q5~0dhs?O7ApV!=JDgVkldiuCFiOx3GeTde=qHCH7WfVa@&p`o@w!=_^eX zpf#gW(h*SGN1#IQU$pew_5~K$zsk<6&{gBfVYjLH0k)I@QC%%g1 z3-A%c&s7m6${pDWQLU(FxBOB<{qVAZ@CLynG%v!!5t%H>X%&r38%!S`91p%WR7LLS z?H?#vde-#b+!>pdx7-%Dl=D zlx`wBzaK`as;wolO4TK2Q3lriPG$){I`1xO@5x;SG}*o4h{0g3;q{zgASrNp`~Y^62TMR%oZvC(IbV zDq~}DUQ;0kbuG(wC`BOz97Ud2mqRhhARw#J;ZAI9ZZ-k}+7M+d5bMIxnP!|X^Q$+D z>RjIn3H^eJ2_arY2#Cpv@l7{WsG|Dj=k@i2B$M!%o(Qx}#ZKjoUlltp?Z_%R&NX*e zI#QfaMZcmmS7?K&s{_`-8ga zaV`ed(>FKYefQgpQ~uHTPRZ$Ax+!r93ADqye7l%c&=R7pmYv37=?lP^V_ zYso=|SH)!~TQ!$7nqIJX?i-AkOllHW60vF_9%po)*^=-1&2lFiEOE2k`U?TIf4#0_ zgyBwP`Gk+h1l=NxcNmV zP$Io<&PnN(v`G%Lerm(|8XwWhn1_~_aoc|-_uz`em&4%ug!i)~IA@ZX9_?%m5;&U;~gSa}J6`TT8 zq@*wV&)wgbrKFIO{H(w(7}P8=2~F${iS*a7M6Qyj<;a*BD+DziRd{|ww=FInCgRZD z+SG>(?6mYthu!%vzvmu7{Nei}Gas^-DEr@foaxRCx!Nfm`X8Q zBvA|W;WNF5utAjQm=wQslcwm@%Ajv?`M7tRaUDA`dS?;|?ZiS9jlw=6OSiPq6+bZ1 z_iPrl9eiu}!w>yo6a?tqlaOKdYvq}4r&d=MR?zaBtM8H*Y~bQr*6-~o_P^U0RUOt& zXJbN5HCAEoSIEHDQS2>cBE$#3?y>R*KRUkJPFq`oqh#_3vYBQ@wvBfYDvkTy?pjf2D3=PF!j7lZj9@SD#gxNE8KE|Ra1q0Mi=KL z)=N|YqdU~RyqAEsYEJf+snEdOIf>0O>5BNz_NOM@R-iS5&nZ2F~lILzO4!pmVq!y#y3B_K>86C9X)EMc{zj+I1Vto zFRrfPUx(QkOlDyO(Hv@jf+7Wq7!XnluzuG4hPc0sRhQnRf_1#sso9m4IZSeGzJh+| zrzPl;jW6H#+#emJWcNaUYi71Tuao0t%(!2_(aX#C#E}qlk#G9U*m@DyJ0z$%OGk(E zyd2+ViSgoM(nkFA`BI)lnm7xocqgt-2hqbWZz_ASq0v&7>JoaiR&SR4XlkxjZrjXC z2IaISUz!M*ioB@jIvz&69H$E@-o&XL967dkqnWtoXsiwVxM{nJz87Il{oDf%KB;%7nf&HY!~`UWRwPkb6@*uevw|NTw?hMG>+p={vc zf$YqxB#xeJBr@*atIv_h!a{T#(+WfCN*jH5(1P@?PJ86Z&*J<2@$UY9^*fTyT+~0y zU4?9TYq-wbw)7)`o)n=j4Ni{D>A_pOx}HO&R`@Ek23M<+nWlzXe$IzhEROo@VS=IP z;?mMbZg#m+RB7BRD;WG^Y|&$9)eA#R!}4Bjseys2zX2TYkSE(RnbD7|Gl?lHR3N%k z*3!QJTGr@+y%V2eHlMDZ-bFHgR(?L65UL*F5Pd`0d829Ws?_3__KY-47@fv~U_ph6 z`_PIFE9D_rlq9V0BoJz{JMVsrC*c19QG$R0xd9!{oL=?pV2ur!5zJleJu>XwYI*T!pY6fzSH^jTxwi8!D+F2>%q`4zB!W&V^va2 z+-bYKG$DFJuqO1D+E|*<5$%7Ip+wPCyC*yOY{O41@L_9Vxa0FOLixZ*N$}DR2*bFT zRiMXVZ62b|F>g>v^-Nd|S?VcvzEB*wsJwPHRG^G%IRNk#lVy z6fiI59?hMlA*0n&AwPE$L>JP1zfCOhRGKv?Qj&~R?AyVIpqbxTs^*vi0hc@3l<9l{ zzi!UdzbI|Plx)yJd=B&z>_(IlQ3>=aFqJ+%bxBizugUD;i=U@$xAysJ3zchA#P;Fz=Th%GcY_nzHtaaR`3!fzNSKDP&^70QGml?yMMO# zo-Q0xjF@~gzmLLoZC-Yfl9LmY=6=Edz9*!m-uBuLWZ9|~iB%qH~M^E!{#ODvN?F1vKX{Gkl`|+)MSqAyE_QSST*slxSiN>MDaP6&l3V&U6T~YaN zRw1@|Vkkgg(W574ma6;^u4Mzveml!8uSbsA(wsHO;9sGj!Ux|jK z{|IjK;(aoJ+5n4=Mx$aIEUawvP5{#eNp6M1#+b#Lix$)D{%rbRY)O(_AI_JVQB3z5 zXRJ!p_2Sa~?RlthtzE49|Kd}iE76cnO#_D=#$}kZiGP12E8Pd6f&8D>|NcnVh;$XG zN)b^BYeR^E$&3{OMFE}-5KTfLgcEB7wtYlm3_N}afyTxvp5G2g@bcBmH(`ed=?cPc zA0&8?58qr{L9eQ$R0e)(#a*0hpeYyuBNiYd6hbNLIOQxNHfr1fyE|x##=h$aLOtQ( zaS4(eDM(KN-4cSp^yLc)6;-QZQCgY-kaB@hLQh2n(LP|Bm<-l>FqzR*_gG1bnF3Wa zl)4CM-o~K~95sm8Ax_TIXl1Ql``VlvBO5Qb%k2O2ossy}-$86;`pJ(QXpgY5tB2M> zcL8)SM2G?G8Ho`QWIi%txPCKM>|=v@xw#0n9sGFh+|t;11ro8&AzhFcVzgmv*vbB8 zi|tT-0s0#a@B%xFMI&Ihf-P)(!(qu2J|TExEdpbuq_lL9A=u#zLC6N^F!9RV!U6)g z;6(si4@}x&h54dG7LPN)b&i`8qa!0Y#KhBOry0o}COE%uVQc)M;972dxt;xwFTn+0 z0$~^rI~l+<^{0acFi@xT$;`^4diLxX7!*k^t64zrsmcO*(<;bIff5sj&(@_|sGz9G zv-1lR{vzi39gmXItnt}WQo_QF4 zoUcfGnkW}lR-TlNTNIC)!IG$hR780>If;In=g(cwcbegPox$bh-fbb5YlAErAP=}* zT^s?M2g2#B6xk$gff*nW0CYKe66BweLw~JoB*I%Z4;nxphDwSsrU&|-I8|B{%ou=( z)N6kM)uM^HxjnXjI?u&^KU4=0+$P2WB?Q<9GcYoO>Kx&3#v7#|Hfh_<1BRTok$kRj zQQ=#FZHdSE95KM&VpMv%x}c;ehn4B@6oj}Ca0=eXfZmpY6oe`*e+?F!>wd2YxHurb z2>AHlA^jHhuP7q+{&#dhl7jp!kbeSw;Ti`@Qpw*ersZcJ!4ixl6_GRn$CKuBdrePR z55u{tdZlWU1Bp;D6;}lfn$&`$wl6xczj&icRA@j7=1?q>HUM9O#HRviJL9!4phPZ{ zKcz~8#~0EqHmlagBoDA3Keq1`#zrNrJD$C}glE+J6T}UWTS9#Z5ub3uAn|~0$7$+? zwA$aCOFQb1s0}&!Rn&BJo1iEIirWS~L>?mkAD$9eh=<=S;ME)r*9}tumhTV$2&nEY z49_qHfPukb>jcAgv!jUoHFHW{kbIe$m|zG15u{|!;Z+zBt@BtASWxm*25PA;`e$a641NCSV9E6Sd&Q#$csNP{7 zH{qhr5(iglbTV*fFOS%)booP-Hg=zY+6OixE86NJ>%>ox(o%s=#?0JbO2Nmc-Cu%; zL}XzBCn4AaA)*AxWRCc9U*i5IZrMG3!-E2UV5B^eb8d0`F~8007rhPcY`ofEIkI0j8Lo7DlkF1pyX;>T`6H~59pJvz9 zXB3wnzX}=+<7y#IQ`0qISooqB8+PvIeRIVMdWy(**+Iy{Tvxy=+5Ajfb2tN$t7o;D zmNzyAL<#}YU%){f&*uXPrY|}`&p?H+^hHM`VQ@O|tUr^H{96%Fh0#L#3Y%W*93~aC znpOL7#=*f7n{EtwnD9IyMjPn5z^1iWrA~vzo)4mDqml1A0s#XdA%Z4Ibra zIpE$Rd9{FmFw>32NlSMO*4-7P} z+h_?B3_r4g9l_ra4g!P}2F@A8CgVVVv2Mf%r&)4*72A~4+*qk8LfljK9K+oxw2}NT9*S@2bz2j!CG?*wr+n= z5OqRcED(FgEx^yg5)3jo4xgsg?DvTSiJ=L|51{RFFyF!nOqLX!hgfZRZLU1?`Vs(2 z$B>YXm1jtH(!&Svh$e+zM_IKhi z8`i|LhGbMlR>S7vp`TDC1h-m@yEn^dtww9Jy2Au|Y?hxi8NA}E2Oo-2pm^c6* z7on<_lIjAo4x*?Ct+ehIFN11E2WR6IXDuuPC>wI2QdojCxw7#M;A_B1)C*~GFvR#( zZ?u>p8LuQGvkiA1Y?_r66hQ9>C)_54EXFhF2xNrCb;Yr)!P)#sK*M3wAW3Le`tSM( z3GHH>KhDa;*jTmXZFrOo49%|L8vXs$qF}mPcc{i7t|%m4o*vJIjpsqo5|%&$9IN`s ze;rso?fWY?XE>G;$bFnZG3U53W`N^|o(gsukS1JzxmNFkj>xt_`$wAFO|@i1nRS2= z1jA5jP8_iapzskO#`vp7L053+eEAIO5C}#E!b2HY7z649!3&_)g1KIIN5}d~&tHGq z>&M#KKMPIimHvM!Y25Gs^N0VpDiq-HzuvUR|DU;Mr|4j#@t+qzvA{pX0?UH`ROz@Q zVCDb3{&#)(UnTGV|I7YAxQr?;oJe1!<;AGdTsZH1;eq0*uRFxb@ zY}b(akXa;crLZLz^}yniUdAF5?34AJecA(XAGtSsXz(pV6LC5nsh z*^)!44*lGe$H4y8U#1VPbB_%*iDD+CzENPrBOeBe&aEki_E_&g=_!> z3JNUWzC{t`=0}22Btk7(84i@t_)e^kPDpmb!v~OC&c_BD6WAUQest<7h7AQoq308# zU^)YVwfwVzG~j#&FCIeSSls~G|12$}|Gl;*>o}5?J?9p^VcsSL3$rgVrFg* z9fl(WIu1I>mn#43g^i6}49;FtQMgW<#g*m=2fCZNkg zGePQOb@^8~oR=0!fM77BHD5vzbkeSU*+<7U652T91ig$!)k-?#=KZ-fM>&@qCCBs6 zNV)1a2PE7;vL$q!m|1mZg@1yE!UB$aAS}v>`8Udfzyr4b8mKF}YTiH`0&V%kE~LF7 zqPVG^{PmrXUaq`Uk(ajxVR2QFH)4M!zB+CO`2{HD-^Nj-fiM?pK1BEyBIIe>X)dHo z7n&T<+d589{;d)6;=4ZWEkjjmHp=ARNb1w`Vgrf=(5Ajs_DzcdnZtJ#nr_~+T=Y~3 ztbu9=3V#$Q1*vpnFkZ2Re}`HpH#76291!oUAY)w~sFp8`<)9Tw9#<9vdXYJlIq<(Z zd3lHo`zR{;E{-LFf^=HwFwP(}_7oJakYHq}uP>3zd-47*ov4(Q!-g)H5+d9!!1xx- zgknB_<^T_PD3O4QR5@z+5szqn4)s6R!OJW1TCzd`Y|~H(I?%ycbO9e?rR;;;D$GL0 zAa4&*2Cjimg{d;aNg9T4SOPR!HOF8VvSmjOS|~))nlHLHGMdL~Rt6^)D4m9q3jP_ahG2Ltdnol;|}=y>;LsM>z0pm)vAUB0D%?*8+&Q#6)b2Nu3X2E z)(3D1Uh6B*8Wg7n%;cRu!@o{+bTdFfN z{+0MbRBT13sjPMftc#z$&-@)9C<0q@EjL-q|fsh7E ze5v^yJUq(BH{{yXEh-HFdx5SNMklcRJfKtZL5D{X$f9! zom-`?Gf0gP8y~<wk! zCo5OU^bOl4Z1qe_4NqeW#bj&LH$hPe=U^juu$y4 z9iz(^de++x?U(Fu2cg%WtCO&`Ew)Vo*e55X983`JH>uFv=#Om*z4>Nc#GUwDsv zir*0z=Eo;`-}(3Vz`6dJ#4H|jm0dYGNUm+Lmmw-j4ye9YAaDeZHD2a^wC&+bQXv%FGH2bB zG%!a2Zl1idw`Y~Cme!^QJ1~n8Nj_0_X+40(anZghRjZw>rhGrJO=6Dq*Y)^`)pAqE z&aTAt1om~!$Lb{z93ULTna>BuE+UwFZ)!3)ACMytFa{bL9_G5rF^2uOAAw%N!(ybb z4+%f7sSvRM#29MkdtZngf^u_m*6iFF{1ooXHF`U8|6|7iEKem|A+UvL;5?Euo_6Rq`!c# z8!e}G@PW%_K7g-!le&InV&WVQY4#Wd)(Kjx`hHytPLitdxVTj?X0)CK(!AStsg=$(~9jMwleUQRQkGRW}M33yy2ip(Q^m?IfcU(|;0+S*)O@ zA??5L{OSBV6Z2tfS#wOWtO0gL2QQAI`kw34trggE0z_GZAD%ZI-A^5SbTK;+;9X;->CaqQFXr)Jd@&m8!EFRmjJW&){_b=xjA}Nn)-@+HR%@GY`@lVe_mw1@w zsLjkhN;noOrm99R#R}-Yn6wOh;;ZAN2X~RKi=jGKygECKJ+70*Nf!%(aavYkxEtd{ z4_P_k;|2SoDLRa=iiW9iv3U`x)dhiCB2tlwZWMz7Cp5iivS9M9FKszE&|jYju9G+?%?(is?{IXW(MCzTVN1bp!d;YT9edM}Kb4 z!0w%h+5pfGY=964)xC!G`0+{c8gL#Rs_M+AtO0n?R4gAg<6R#ZZ3MsVU5O4&|%?Q2{@2JY3$3Tg?~&Pg1eW;d6ty(Ljk7D(H*Kg_4Z9Tq%!6(2K?$Jg?> z@ZsQaWop;1P56WrKi<`4jqjtysGq+NA_u87g307s`yx-pwM~8feNVPB%JV-n`{4~s z$&J3|c@!tT{@`LV@sx{8SkrcVunOJ8s%YnmBF-ogLlw>WEf@9v`+|l1IdkPzfdHJK z6XLIgL3_e-LOkg*{om@VG_}jiCkNl73xsrAX*kBUwJ(QPAd!5ONOSj)@k|r_G+5?I zMfLmmWqu)pvJmp|rL;`zS9_PA!(-U~W7@QD`x01xRX9Z8#q|lMkPb-nPk>L-G?>Ex zPFv1hSl(aKNL;fE@=Q2UE~~Ka6Tt6x*1t_k`O|xWz0Y zA_C$MW3a)1!<+{LU!VZY4Iz~57@X^XlTjT5wu6Wh5%x@>o)tn}N_91lYQ5&+!9nx0 zhwkd~d#{}<75lO75xMQfGGc9s7zzEjb$opXTuK~i3&83Ma2_J1lJN~dlqa!l`{_5J z^+?PIXB5wQ0kCpKe>}LLw^An=3l;Ion7FB_sY*x*?!T8pfZDxwd2JKot+jJS^_)wk z!pq*eA>_r(iT$2ei{$2dSqtIa!t$m|1)G^Y7OveDG~LHD^DPy$t__A41=8b_A@<>v zW}lW=bL?H!iMf@(QTnl$p!AIBI?vmh_s>XaM;$m^ESQh^yAsy(D&LpBYyGyamzTPb z`ylNt3zxGbs=4jwvV#EY$tQEW^5o^V7^lyk?kjQ~V3(EDk1pxQj`?}-1_}P|PXCM@ zjOuJv_c4v-lR(qm zbsUBshHnM;!Y5pQ?QR?O4V?_WKQHTZ8Ae`gnj_%*j8Qphip0-aqC%uaU%xqeLwZn;Zs0c&D}lKny=Ee6Y8$)o zHe;4fM=^1}yi6y<)BWJs0GIO;wmaMOGaeVG3kwSzG2q-a2Vt~m&+DL2u+Xx152`4V z-*}eBYB4dgBA#D~I;p<}UYw3wo3ly*>QL3@KN*F|%Vd-rG^AKCgtVGz0L1R>J2~hiM&Pm@in+a6T4=-nUUx*U6bG`jt@zPNvPWSXn{K~=2S zReb@?RWXOc%WLma3Xa9Dk~YKH&p{L8^GvAyej>0mWY{YA%xF)EUdIBS{8 z=DBIi=u73oUCuQ^&uC;R9|~&EK-V{EaSo*N63Dv35(XC zD1e6SN?pF+g9i7ey0h~MJkBGE4m}1ywFY4r^kyl6ZAN}Nh-h9-17uB@aNoUi2fAJK zxXd1TNIlnF_E;i!j_ zvQ>TpTLM@(Re41~vqBWClGl_)YV!JViIuOGqj>+*%8Ab%EJ-KYh7Q!k1E;)yUZ!Zu!g*V- zcNqkVs%l@aHj5cN-w4BfpI7Ss-N6>UaD>l^+eDYpa)Xj4%wtn$O!}-y;VMWwk`Rf% zTSnk&(nWQwbMnOVtyv+>naYs=fjK*DSeJdQ(s%3Y1jm2yoN7T6$)?#qW^88W2FiM# z${T^8fPL!(os^n7kIU_RNTJfdc&BJ4?f8lykXYr2fcCqi*YA~iz2Mf57v64(puTXY=d^44 zjn$r9e@Bb^QcmDXKE=*yXENm~O;pK#uSFWIkC%F-Z0n7B(f+qfI>F;h?+FK?v)PZ> z3(_O|F^xx9-(N&6NXOTRkuKAlg(odgdJzo`n4BcjY7O=zPYzi3Rcxo!R%sGhZl|7q zBA=O9llqi2Fuk+bo|ZMZ>xSc~tJJVNVOY>bd=X-~{1uI4_AS0R3tI991TK+=vAms` z5UFp1H=0+x-)UA#H68eSQDNq zs9|~Nr@>KcMN$^FME8B!=GVxRz$7q*Ve&SBgY6$a+_UVvMyaND=Wuu3Rr*ewK0ZS7 z)Es=$+`$OI&(+q|MLhxsf$=A6eCKC6rh8w(K@VoUh~^!OdaKZ7q%vwCPWrxtqVii%^{ z(Jzv(PKZgtC(y6B1HMI++(w^TMxsNUtqz#w#%S08FHWP#beL`_4o33Cm)rhb3y5TL zm{R0kE=|OPeFuPE)iBRXoHJjox+YZ5!l?MSdS$E{PJtDZC zFR*D{3b9FCbZ!&bAR%#X&%_IB7e6=Sdbmow6*=vCqHiP_dC0a&eJCK71v8+nsUd`2 zE6y_g3jjMle5mo*FYC8>tONgzn=qrz0H8X}P9NWZ!wXpXM~TDr2X4nLtI=(EAYz_W z%zbVd{pmJ2j?=Se@^4Mz4uKAX$jCYe$G-$qUND%0_WeuFF5tq5I|sRX5LP{D%&0jG zC+Z@&*NLI@4h-xA2chIScn(+r_yayT-}*NYkY@diAASPQRT2UwOLi~Qdzu2lD3p8(>ikM)~_-Z<(`JIWbXUC@tH?kVJPi1;8hr?r?C%SC*F2C&+uGnZ_Wa$ z-ubLVitqHobtJp&qN)Dy!*LUqg&}g2t^kso^YklwgAEN6{LSx)l(e+8)YlH)EW#p$ zh6In1j6ixz3p@dNh(hZ|hM=nj`zs8LhE3gy($dm7C;OxkAkKJQyK$We&E->Za#%Sa zWI)UYB>cx*AdMv<_p)G~p}(H8`9{V3yjSxL>6>ZDDd+;j2kJ2I+-W8zevuikJ^~YG z7Z}VZ+_02N=B>@qMLeo#9^2kh+AutH0)~sG?VNqor%#7qC<$JOj&4+}tYDO!24SgP z9UXS|E^DBPJOc&n88BPwcTd(4md9lvb?1BK0fU8~-*63fPYv`1oNj=a3Nl#K_=#=Ny#`mZ1J zh&|lAC-1S;-keB}cFo7fQv21V!oB=HJTyvqGs@sAeqQ(5A=dV@a7icQ^tSlxvx z;R6vEQ~&S|WN)IBI#$|=+}-{0>U#GrxN_n!Dg`uC9@HY-QKQVY$&O$UJc9~aHc|BAM?qo(wZ7>R#sL}B?2x7EzHmhNtBuN zP4I$*+63`b0lfz{Kz5kjf(<`{%f)2yfw`PM4FPy2feShS^8gSnE|qVEFoG=(f+<$& zPPO29-t5@{PY@VakpOU64dtj4kZ1 z<`g&_MDX2D1~dsQU2oK>`b6X&+COXhE|GysDij??8!^brLc;sK@L2kkb11F*u`A|u zSW@>^(3^+{@&l}ADwS2T`V_Zb5RiP-&zo%Ka+J(#7^0qS*kqJQ=6j`I>NuC{=eH&6 zyU?74UZ?#V*pw`>$jaA*{InbxY0PL-M`Q5!8yK2eDZ+z)2ZpHExJq%=mt2b9mU8V+e89`>BG-oVL&{N(ZgFEnq5gBtf!-ou8lxsKuIUMPLl zbbc4O(;N~7SbvZg)>Ky!pF8H_or|jNAIdId&to?fb{C#!DuTh+QIM%whMGTIH0)oK z;8UB|Yx=W$!tTFD{Op^{Xm7Czbzx*mJWq;~E}jnE)1l^Z580{@_p3L7GakOE7h{)P z-}JuiXltm%$}AMUVaemk%s3g+a?6tG*!LPc1#4L{v7@wf3Pl@-cmO_ z=OtcZNbr(5eyHfgitI7#wk`6$pqSx7@PfZe7=jaP2n6?(vt3r@uT=>H#YS0-yq#AG zc+YS9%pH}itXV64jYS?mDjv8u{*`Sk=Ep$KqlcIuc94*gsWh_*w5PtCbnw@6KDrsg zF*Ym>wQ$pWiTmOQ8%ep{)4D$mPYtIpS$l(}W&OYt(bow$kE*+k$o7}Mh%oL-| zCwN_A(r9K+wh5GF`3ta!4WwH3Q<9t)d%T)4&=&_td58KMc8H4cxMYrdEXl>#D6Mg+ zfdS5vnoR#V+e?6mldGn--x zw*suL{e=!$qkFP0>{_%){22oz$#qIEjHPf1e5|hjC@+Zqe!=vf?=~6{0rA#>*L|fN zdjOJu1|V+)6Y-4ss{scS#(~$+etSY(W?dp!p$4NmF)Uld-U6YAM1&i2Uu0@q7cvrN z1@oRsa^)q{A045iKS6n?)m1|_y+)m3kQ1(RqL78YSVC$N5F>qL+NTnQonUpoC~x-r zY2`kSEmQvf<$e;6K$^dvqa3M^br(tj^4*X!b~-%#1S>CorlqilJF-)QuR|G^LheYh zU&DJllly)CrsNc&Hyf}hu1{$c9I zf1CtXf26^CuRc84x_wvCTFI7|ZP}GnvcHRaPr}kv5(3g!*Onc&#Y{|ALDN;6XFsHB zbc;OJ3nmYK<%*z!+q9Jy^C?Dn?$vbLNG9|0;QSOyNB~1{_5yn5#a`TUk#LfmE*yIU z_x5siv=jpRLhv(gKF7OSiL>s<)e2!ZIww&!@ShVjk^23<&?FO^N}7da%746~Hk!jd z^YfN4vFc+Zdks&u?b#jWlBSB*YR6>2r~Ec(iN4TAKia4mg8wil0zOOIwM}{&u5n_! zvkzr$*ZK7h1O7D4QQGb!%}o$=w?Ef(IkP8_i?f@s@l&ql4!Qd~Q(n#+`ytXzZ)d5{ zmBBxQuNi-rVtQkRPS1WNV3Rn^_H({?lAN*gDmI?Upv~)=R>BmRTl3C8nknY6U341M zUX7BMLS4FaKp54sStKki&|{=PQi`n3_?j0*3u|d8mr#}hX1nXl>Dom4mt;xaK5@_S7D2W5d_vRLvxoH#P zEhe~GGIx;pW#0CwxP-1Q_0H*v#pE4zAG*|h=wa$6HgK9C@8P!mR_LYvp%%m_mC$nQ z>G8xxkW*8ez-)<;aq58Imk*`@*tBZ%GXw5j4RU@5P1{g(Cv5H_{%b4ek9$Dw66(WnL{Zw9>j~E~j{cb5R6&V_^q>J1(0vF{u{CvYkP{pw(B^7!AM`V0 zd>#DUSYj?>kXRl0@_v9Lw_BZM@uqXTZlTk$ct;4}y72P)H78Zll)$0k;VJWNF~QlqN#L>Peh2>flUaQivDbzlMpL|BFeC= zs+2Q$6{J%=Nd#g7l^jmf*h9&*Tw@z$8jjzE=iJdt?dSWX=~Bc`0FHI-aA(w$O|ouR zp!0J_2LYNVSst`frKKz+I~5PXQVdieFa%>`spP7KX>vYHJcEN72v2ITff|AjTnM1l z5k7FlZiH}|s>L`{w^#+HUv`?r&{9HKgct$q!u|+@8+cPvl-KccjUT~o# z0%?Vq-RjNj*MY`y79Z{d+y({7gU)qtn3i3JrJ5Cw-fINnip4y{9lR1Y&&#&arcoPC zj^kvZd?FwDN<@VDwZ1YuFFaI2r~;%yaF3V;?u$ZknU>dRU-M{j{UNMT**UVsYtLA+ z>zx;6oNHcwaFIlVeZk(WuvMt`bfao=e#a*4bxV^bB@mlAl}f}9A5@ZP@9no!SLA!6%&Y< za3`DN$FPD7V7?Zk3yBbx>w2t=n>h+>_#JL&gB#M5R3o}b_aGWwMb_dQPp*D9;Lv}nnh2k9+!Q|1|i{E-jjJ=Ccj z6>{*$;2G&wq<8~! z;F}q92C=l-mpov06)eWDh*Bxa_IBbR1)#v}BP;7Ny&pKe0*MS3ETxvz`qpW=WvGn` zS%oGFo-EEEKZFh7-kA?qGhcgXT_QEamgDm_JX&9h=$qd+E5`LOKwjqcJqs$pyv?|{ z694DZKhSTY5pk=)1Ws9*(nnN6qNA&eh*1|pdcGx1)k?tN7pfsxK`>g&UjwutBf+K( zu)GV;-E@)IbZ1~50e=TZa=>;2E&aWJlaVen)@oAMs;+K)9@{vCPwVxo--zYK3!obV z%95hUMK_>$t7l2Nc zA%9(!XaDop%)3_lm;xnQ&QFJ(Ak@KBqJRBo=INsc?Em>W|CZj$3R2Yz*pwkjVc+f% zO1No$WqL}=41&qQ@P=Qy`LFSQPrR=sn!r940L^|WQL8Kb|9KN67moj8l4ATJeHAVD zZ?g6zgeiekNH zYSslBj{k^~z*XiqNN1}4jp-T~BHqjvXe-d4P5K#?k0X+)0G0(-$nT~mn2>ZP@ti^9 z94S{uJJi=#1an!$PotuMUJr0>cRZUVuqn)goTtz;z}hMo>{Nii2FT+7!`_=mbGiQi z!kU#ri3TAmWiE5(B$YzRJVwcsA(`hQL@Jp-W|?K4=N+0*WDFq{LLyUU=XLM>``W*C zp7X48{(RQ6j@8<$)%LkR_jFy?`+B`!0}53evP-@M<<{~obZaU~_63U5KQg7gSCON-oz|J%_avextY zv$i_%LpX**d-Ue@>!MvvLEhxd&}z;BzbjkC0gCY)@dwlT2xMe-hTTy;w~ya z^J^)c0{@6OQD_s^CVeDf+*xK4NBtF0E|FQ$Yhk}?KaHwZ$g&C)cP%Zg=mWyp-9Lsm z{5i>Jl+nY@*L>nX@14}-X5KUN_!5{j4}br@JgQbb3JN}T0<3+J4}{0&3>IG~_U!DX zxZAD#M5m`I>NKB-NEzO6;3ovFw3cc0%9Sf$+;~98Qb2wmoKj2fe8VCIwk3_C*i~(9 zLc$88A|zATY9fGl0h$O)Q&V(>JD#}BJco><_9S33!te|kcwnV|2tQS;@$>&gNoBtD z$+3mH_N3dN^8W4279|7TXy@_3RhR!g<|w$<+xQ!=g0S=D{~#FnZ2;z2KShki_09n z>9Q;$2nTg?*0W@jRor=ziHd26Jrjd5BNzADMgIL1;6D`UD=>~n^r5M#STj?p9!sqZ#0m657C+NF@FGN#NNA+reZyc6!s-P!>~C;sL_`g99R!BjuWk!!K}l#{+#5=ut3!80DW5{6IY>^G)0;Zvu;@};5SgXRxOK?S0F zbqhZhZ~EmZrtl&7X3D0h^?C8I2gph`lq|%^iWPHgDOBp5C1iN-Ndl;w3wZ?v=N|EW zFjnh~IAnVJwoP>y7=EYizZ(>4Vi;I6%cw2zCAFb(eB7%9^=@}k9IHZ93C;o|Ow>P9 zBVU&MPOoieAqR*c>sm4YqA_uk3$TaBTL@47-*#6SPFu^6f3*9qm;&l0NN7c7Uue~| zKi-)b@1B3TjftE&3vEj~9!!iq?T47Uf7Id)VEoTWBJ)x|?6bdeG(7`4G=JPQZRhbq zY&QubaAbQT-58IyJA|MDm+uV>=4EHEwf_=5K$${Y*$_3|)m0eEj-FfmE9G@lvpaAu@hKX++P=;8j6pt(S( zh+)i)R_#A3G%so>3PAvD1=~#f7(|((s(*6SGzcwg$cIBW)oO#vM5b*uJ6}aK?~yJT z|M_DN14HkfeKGuXT_%=b2%C#iP#)m@j$MQsgkB=56X93yp0gQB{5N$ z##*A_qYv?7te+8it`sZbXYjvMsNFM5lyjF2zb)#vu2IWqWiQJT-3N{=m;KG$HjhO48Aw3By^rJ_Q02ITw0Ax%B8ZTTogPI7vFa(rY zb6-u>QeLVlD+}*)F@8?9?enz5`YBQyY>4#CDJ^vbf~%(Mn4}~}zI)$3A*{C$WIEx> zao^<9FpqD!dHx>_TZ*SXyeNW#qA9Sxj_Y>A7X;uhk&#o(1^!(w1nj_xsSWTX`?fBF z&q~f5%Bf)u$~1y?z{wOX@5dTrujxi~i82Y>Up4287cN+FgMM7-oK{-eB@mTH_5Xah z*SFW;>b$%*N51di-hLb^x7(fL-|sUnf-2K97=>yP?Qb?O)Gw+jDx7o;;FWt(BWTL4 zJPd5>owWpm^8qz(s{iTY4pnH9k=#jb0KQM()}NPOVUJibE|=7XvEOU5fB~6@vT^)i zc`tzgQ!o>9rK~xFTy+cqFKUE_UMwfH$vT~R|I0IlehzAZ2h^9sxW%o$c*ziNK_HnuVl {wVhz~(_oRnfVle7m)7)BD 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%0AZ6(QkTbckCD49+HN^J2% zqjz&Cr7X961GP_!#4Mv=DTkQIf%kt#e0Fe$-N*(W-tszg>Xct&L%p_|QSaWuBu2Bb zoBPz!UOCj0#7VoZo{VJBan{iqC&5(=tLcD^l2YmexPnB+_SV;SIB*|= zV(HS8BnXcmpLj+S4LqmoVIeeT`B7oeC#(z3Ii`j|!7kO#6Qg53 z!r5cIZ#P4>%p#GNvTvN2Ro}k-fT~6Y`7}4!i`Xmv$e@k#g2F-qWeLULBysz?)5rkO zz|qCULBc zN;DEpHt{~eEB19Vnhfb8gtn7Oej*-)2Z*p8Y4Yv21h(1)w))+V+qtps{V>n_4R?op zbv|Z)oCt*ek?O5Bvh8do5Sh*|Pus|DAqBg5DES`>*kH<31hTp=T(AJhr@MYOn&r;D zl9JL5e2`7|Y_*2H0X`LNbHiHphP{(;0P(yvSu z3i_CKo-Y%=Rx!>@j+)GOW`*BHT+7A9#k6|WJIT60ZZl-hVYkj(=in-Y@c0&pngJF8 z8Z>|A95U}1r6kV>zD4}DU-^&z|LjlPj8I`^vE|w`Cw7!>$t+%}L zg~J1^Cc4EKTs(^B^kex2rRH4Z&tAU#^#1*z`FBrYJ4Ao!=orQdwb4V<&c~z{Af4oH zmnBzZs%@hk<36Tpd1672xa#NdD-QK)EZ-tPT;ic9+m3;tvA@AsaI@H%j?l zwaj*zI7UD4EF$a9^oyj?fo)@|hPSE6S9f0tuddPj0_ygEtgcC&?X&90ty>u-1~M`7 z-8|0%at|)hkVQ|dR9u+0I55ysPS}SL(w~S$O+r00{IpOooqHrhMS~u;S~9`S?GITf z>LS5IU;hpJbKL#mf-pgCFoIun?|<>U z94cn956y*jno4fS;K80+sM1DN{6XKv&H{gEqo&6GngMZ415!OkDE#7c+O#joO{c!P zccZ1%<{NdkP@!U^oCp!=ZICuJr8{gMRGoGnKsdFr<2f0FT}YeJ)z((}^!(nveLx@E z&Rx5<7O4#OlK)+WfGRlSsO6!8OinMd_!YEv-MZ^Qef;Z$giaOuWQ394K~X{v%)!q8 zF0mpK)F#o$rCLeay=iTYQu&B3%2P`rlsjV6X9^ z*=v}@d9`T86{PdxvOob(S7pY?Am-HGpT|{Ur85R1*Vx#5w1+1xmd)Ni6wJsqH_BzZ z@2vg#X)-lv%8N8^3!4!ve|G2gA(b6fa!ldvpIxJC7>5a$M$OL;lZS>%Ic*Eee)I<= z`w!*=1}yjUV~r47@$|t1is1$TGMWvezE6a4)Nu}Snsudw^L6=}|n@d`$ypEf!x|-n)sG0!A9SW;%`5%9ZDFig11?2xrYLsoRY{+VG$)A zjV=P^FZefRe9lTK7YEB2?N`gyyAi=x9>v@uoDXWc9W^z{Xt&~KBh_!l);pfQE;owm%Q-V3l@xYDMd>FirIHt-mKHbfb%a`xrqA_)p+Rj?*dFJEuB4@2gkj8 z_x5dO$Tx|yIsC9`b4V-jju{vIT3m536gn)3xE)=%?%;Qv-fHHu539iUt#kzVarWtq z-p0m>n|U^s{+0os&9x#>52SXj9d+#N+4h}VqxVHT-FYi61hgj2CfG@m!45mNZ@;0R z=@qX@wv?=pY(k#dU$lAL&R>Zc0`U-E88!LX&uD@5uxaUua@&FX|;1VKx}tesuU zEXXxmPMYH3hqA8gazfi^EV_y>_Wb$(NRNKmlH2_M(RAK{RQ~-Nm&j4lIg*gdj*`%l zZ-bL$C247+T`HAIlQ>q9)lwuP?PxEVg{HP9(x6FGC4R44&(B}a^Q}1U`}28^>wR6< zo2(XF66~!xD+|#BUvScKfhoP^Jl-DTdM0g*#zH=JOMi};hi#X=^e!lSKnA8>Tp2BT zEbN$gd~&42p_xXeUNQuCEepuE1Wu-=`{P}8 zwZVxy*tD?2X3)75T#4M*O$n%d7b;lY^xzt^6~fZ71e1 z8HfMJ!)0JUWd5`fPyoH>oWO_GDd9-QF^aV9ppU}>o8Ssw=ApZ|Y(^vT>%jCd$(vWN zY@d)g91(u_fBp#RhDd}Y?ttx+?3v+?11>NZrWo|^ymPCBs$Z}TMF!Hd=R0A|U+BVb zJ)NgbV>&cd~Cj zJ(}kcr=_>~O8n^`Nx3d!^-4eM*rR>s%9c%W51xEH#9?K4;DxT4E2=OCf!d z3ai+24Inimy}RBv=T2=y%i@n-xbWo4mDz{X66pd>wDF(Vp8QZV{ytz|zSUVeT8Kdw z@+boBF8A5Ei#CUab{0#2ySTZ9bz4T0GWL6HLKZ9irw6N!k}AS3jzuyjmsUHN9;;)=EHw+$9JdS)?*a5V z=U2Q(F-{mG;n1M^G_9tD=yzcy7M*s(o(>yN1jf*Ttw{iGj2obNE1wEW?iVZiI?l;S zx!jFFbj|U4>5k52`84k3pD5bz)cP|#<6Y9P<#~zpQr#UJJGO5h&9qBx(b&x0+$&PD z$iWk0@PY+4rXxFh%P7r2Ltfd8JHJ2kU$*MO?nGK8W{hM*iYIEmWrMW~EeQJHb5sR* zXJ{VOb9zS<=Cc#3%rsnP%%}imUelw#dEDg57r@t>K+EOafM;bn!G#{J=rmNiq zTLpy?b~@*t+SnY~H~#Qv8~4NdX%mi~KDuwksRgH3yp$@A(fOb^#UXY`VVJFBiFNL< zW1rtPeo+;l{=Ccn=KJGOtz-Te_V{`J^ZV7)j=P*_bBIsQ@!^Z$ziw#Yys)ie1NBqs zIP++R5pBSNgg6x*UU+8G_NiZge0OWqW55Etk2)zvJ)}*GZkyCK z(F!Y^WYe+xqh@9I>PAomYvT-`rUv=455SjHPLHZMbF?(dg1rkjZ$?M#DXaYk-DLxo zWQAD>v>Y`u@#(K7AYyd%;>A$xwWX;?Krw4>wHk=OQk^!MkTVv2$D(wB(e ziAMA-pk~6USbIst{2GH^0|)*D{chQv$7>@lpQ~WF$-4BeUmjCwVRK`C&Z(o1utbtQ zT-!e62y_Qb1(>!|iSRdgDpVKzAWj9LjBjf_fZBRSA3$o4cWExeMCK3vx%fycMZXeV)7WQFBWfP#T33kyY>B(ZWVXTnU~(dlok8IA)zTH^OPTI`(i=e_NSh zkG%8K`}gn(A9EJ!h1Kn_j?~5kFs4SYn>z3^w+Z%2a87gDh|RJK!A_A>kx1qv#PB(& z`cPxi8oTd^W8h{n9S-=`FPrURRXt3$ve&s?T7POwhE8i%uV#A`LvC1mt~)KNu;&SO zON;N)--c`AITD}aU%!9*XgU}tzzFp+@7{aPGCOTA?w&U_dJN=?Y!UJDk(vlqWNQo)S`fkjN;YF$$GMJsdF3A z?D*Q0_fT-S9+$9@`q%NSS@WB*6HbaKm=(sVTdKg#-8FdiiG+l)(Dp#m1rv@guAN(& zU`jN#nAs=~i;Ifz)GHi|+dERAjNjH<-oAYc)W0Smpro`ky8E!pP`yu_1qUV@zy;+v zQ`EzV5`8^|o6_U*a>sZ6xU^IRHvgrTp+dVv&?55zg;J9Ycj*>-Z4^i)1~GgOZV1yI zFO`YL;*k9ElXDU1mbzzN(etq z19t6N*nr)J3PQiQIo~V7OGdFs@2+`fsz=hM=xD`qU6XPPTx{&Bee47(87uA~kCet-EY1oF?Vbx^l%UoG{ zCxcG2_73zGKxSIstzp-z5#pEKn9q5DwNvz-Hk>}g?<{hfXB3uY(K`8}MA!YZJ|7jO z660DcsfwqM*VQQoS{IOcczdbtOcrNc3TS1Q#LI;&Fn{5~7_Gi-E|*cP0&h0-W3MiF zpyAHb*bbmM*?@aKIfE{j7=>plAT}cqkcr^XOTz_X48TiMlLK5H{%Zywl>0&497$;5vybuchzCX4x$^)e#<)iqnzR|Q0wyZNLH#qV zg&WB3tIW)Bd;RsSUi7(qmV-fe)UJ2+$Y|{mN0elmsm$rXr9hDwc%3ptMKRFQQt~cv zB`$734nkPJKj7t>E@-|0LCDBx9Q#?H6qr_7Kh1D*e-xkiF-@jkKpw*E8 z3;ohvE^m?52-~?3JegU4wOzNOK&A}X-M(Eru+}^L0k3-4DHk74Wh=JqlJiIOmBRc6 zeXWlfYiznzMSRWh-Y`kZZB{0JL#IIClD$BMu$~u&)}QmQ>nVTlC`5W3GTMaL9dVaP ziPEgFiS=zb0gs37!2VYO1<_^fZdT(%x6>juGgHaNHR!)^i2b~(e`_?hzScu?Ist5T zV;hN7SXIFigmkye)2CN}|3Gl_bD$|&1DB($sXJMihQQqX7(x2op@TbM&84$ zO$csATL8pTpJ$=P@W*2X%tDYraI?5@S<6^p1bBazf0KT7>h$SNXxsNSf9;~4FRV(K zI&E6_A#31JZ4_3AC~ukS;?j$u$O*jcuuf7<3&i>Q3SfWGRQXC!{f_y9DL7&>1gpX_ z7UkSoR;%fu;`{d==i#B>wx9QOSm<~NXwLW9YFY4eysZ*Srtn?e+d7djN=r>!)ZZ~h zB51!|BoaNN{beNhPw(F8$2nreptmwhSWdZJ6`}_qD;@&k6*+mr@Z0dIdL|nv2qG3| z6G|~%@DheiiQLF2@vdQkL-T}_fs@Ge>8kBQ_*MU*;eavE;e;X2(Dyz55U-=O{`a3U z5qgZGWX_y7WoKEyhpQ6erKL+ZzJB&!zG$N=X6!7Di>UEbj*89Nq7x~>jXG!fo^e(Z z_&H1xff}v1WM*VU&XEC;aNT1Y2dWuteW2R5Hd-^a=sNwM!;IS#>(%owwF{~#`CR{Bvlcu88(sD2svV@zfBIi zgsxl}QBp2!0kpBvuKQRN*^zH?Wv!ZrUQJ)6DDm{#ckkMYL}JMygj|(k`0yr6FRI#A zSr4_gcGQoPwdrH81QAkjWR)D#~xMtxo#`(}VlP!wo_ z?lafv`)!G>z&9L~WIbm3^lpU~v}sNW#%wvKZ!e#;5m60$E)4TWbYW8^Gyk^zAd|7> z0VOvP5Y{;{XnLuL`E08sU71iwiY5>BqnWFe4=>dEZ>3pwr&%W-V_vf{r)@&1;fG2B zG)963lR607jVPT(a#qU-XLS=k>+v7! zmt!da3nD;ePn~!Ig+-W(O4eM%HyU~lqUdQE3l_2}@+-z|xWrZY@f5j=RLxaaG1fy_ z5UR<7rR;_<^Lx3y{gk-dA8Tt@cXhB{{LH!_CL5|T0*dnJDA!k2jiu| zHI+SWFU4uLH~X4y-8u%6wmr1ZjLXa^zyJ6VwuCr)8K*XfeR@KPUhBUG0FpRe*-51k zqdLP|MJI%b@=i^mtmSEtNh+{Kr7T>&{Nz7dI+|@qu&}Z+-bs%hN&{ArNwK$K?h?Iu z^V<5CioTxo7F2V}$^OH{OjhNeUcY%0=!a3JnVBo)ag7Bpf({?_&ok*>?Kg^CvR@At zJqiS=T3gXgoSML0Ze-flkY8i948GaK6!$VW-;Vw3vSl9Q#`!N>=IrM7#?RV%3zaYZ z1N(5<7}~ioi`t5;(`qj}eOX@+e9#4FNoudZ&=RqQ{pUgspT0-AR0@%B@6b?bBV=D){cgYP4Mu`p9WYeb2c3hx-^o*RARS9^Ovm5N zTXO=s%ub!I_+*!f=iVVNb84!rUoa5KpWK*w4JYPRpSwxfO8**ub#PqKn0*e)#vvhS zp@hd)Rdwg_W2ek}sh@=9ljWp0bSfW^WS`0J&{otP8hRI$tXQa%@NMUteXs%}tGrxs=MH}Q{@gq8;|dHHJTfFw z%zz-%B;2pXCR^CIXJ8Oga(aXa>(-I>_TtUvD4~=)8k3y^L7(rAX822(%u?!OZr*0e z4N85Ln=zEN6RF7^L}3GYL-0^vkVV^XHkXMb?Kee5HRg4dY?P?Y&nIj0Obotv#y5d@ zHbTVARe6ob#U5)skR+-*9D*#uvpkPb+5lK}tT>1Rslp<(r@|`WA*=4La`Q_Dy)D&D zbvFq{h>+{xJ>OtKa1;U{SpLvB^%At~fU6H@{2Y_>|6G8>hgttY{9QF~pd|6dh#ZN5 z!jeQtvjJ^|nb*jo{syLSJ5bc3Rt7CWyeBN1-y4E~V6fg=UO4Cx3Wqi!mUD0EWRG}D z*M?%~WlfFs-Al>%&b$s1B-&rTsQs;g0Gt5`LPCJ(_WAR$nO9XPd^&W90dv7W3uIE| z=s59i-NBG{{XahGFLU?w-08lGMNlsx9C&*!U1}*kz%DFlY7~~KFFN_iXi_~MJbXTYk<2;>M`@WZ=u%n{g!_vD~MF0aZ zOCrSRw1>6qlQXTKfWXCj6RVt06Fz55)+Oz`UvXK9#GogQMpo zGm=`C3XASBVOZhmfKFabEUsi(HC{~YRheaFs=kT7h;*9!`(3ZdRT`alBe2F8xI;!7 zs$~VNzd6Xfan*~juyRTUJpxyt%Y+4<)eD3K7#gOe^5U~sj^{Qvb+J<9Z4hDpn|)24 zqzx)i^IO<(b?ybX!NMbgU*b#xr*b@H)7FZx238&c$TdX#5$ zgvu%tl&%cZ5pGNIh zFyI*tQ8D0L1e8QOz!nEYAv%W{WfR8;>AN(ebSLe zNK;v%(P5Dcn@ubf;v5ie2uj8LX-aIArECJHEcg9EfC$UEJ~XgNXW409nE}@_pdM!g zg|5^ojGB~^e1&<&(J9Dp_%owrw=rbsSic&^Z>JhFwx>b0s+@ut7!+-+hev@$)a|!) zqHOk{i{v~QK8+uB2>mMD&&v32k*aFJ0T|71Qa7)io+7~>`t_SPTsLq6onm$+8GdR# z73nPxp2^><*RRQWxG2~R8r1%1uEbSic%h|K(4mu8?&d9CszbFS{ivj%rI!E`=mve1 z#!V6M=g_3eTj6^L`pC+9Iw5sojqyJm8`k9Zg80sZ2A$_7>dW}V+~$F5T@~z07w_}H-_;$7?*_g>Ne$gvr@O|Yjak;I{IK$D1GY|)~q z+3~^u+AJ%?h#AL@xfv%gbhK14m|z{uW~b89$m_3}^yxy4+Vt%CbLATj7PwvpA%%5) z@@W;f57>uKQykp|jaXQ905d--(w$0X_oh5}-&aaq!f9A`{uxn{hq9N>dEq z8mFaL2@Fo~V#hU8MS9;rNTe(7&rMlxJa0;JY^>jha?1eU7BRqLldw1S0e3(G;|x6dG0DOe zkdG5K>OlON(C~T6pYkQb+TpSLUA0zrSnTB`*48F+k~3N_s%^2q1mxj7spmATOA#o{ zlpk2xF^Hj8B(Tss3$v4LD}$r3Td^=Tg_Us!8}f4&s8AtDq-NQ18iHSSQSaP&6*Tt% z@>vcg7t=tTu{-RZnF$ka@w6(BURH@J)A!|x5KMxN8i#3jB2ARarMg<>B=UOm_q^bB zmCYE#FvPA)hw?nZ9Pe@12zO{J0d;Uj%^+Us11)A!gNRN2VBi|KbA!KwQ`8PumhnQ}oE=`{{Ee9=2x0t!Sebz{j zsOa4ex_18{O-IB6t3g;-8rakbN1xJ2ao~-z%htVnw=b?o^Ra1f$lc=Lxpc8?Rga zY13NX1CWMVv8(p}K-^;yu(!NbpI-L^(A!W8OPR*v7`C)~sU{)zonJne*t=lCp~y%q?>GRrs4Pd?yucbED@$BL+TXqRdNA=l zC8fK%_NJLNOAb7mE2F4t8OUwO4_4&g1V;wq|oVrwN*k7P|6*`pg>w(IReI|RcGT=vobyLqn z^-y#DOg^C;0&OemrP7ArSb&XBG|D-!-Lnwb7u$7Fg@lI*OvQd(L0`|UU^cH_|0~wf zkbl)SRKI-+@`Hh=dW{`F{&|*nNR1x<`7bo-aXmW~(#M8Cn{&W20(~ReX@*7LzkUT+ z?D!}zd<^(X`SqEa8iWK^Fnsg3)^Wqs8`Cg)pnmI`3~;~7ZTjtknAT6J_c^7{@87e| zl8pme8k6E24MA^6X>KV)hgewnF;8aoQ9X@@(VnBd228`xh)ZaHu;02zh{ATEudDoN zK5itW@Sps59481VpsfPXRcRLOT~50tk^jBO2XNQQ72p-r}1LM6K%-`C*7mNv<%ufu={^?&Q-wx7-Mx-e+SkoATRJCl-B zqWeKJip(1s7}yNDm9IA!l|zE@s1sRPa%JrVXFF)7x)9)6`{uB}6$*e~O<5|310{Lg zuSQ0vFs#|z1L_)lF;5A^wr9<&uUp=2i^31Q9@NHq3+kFbfBv|vd%dQ)X?sRnt$DPJlYCM7ui@0F>ppng{M0@Iwav>T_Qb(wI4tJ-F~&P1E#!G zIx}Y)*n6;pq&@%+b(h+QG2T1RpC4p6x&Od{1hjVh9h~MK9h~~e8-3PbHty!AMIy;%wKh002cr7!KBVqP~t0&jWjdj2cZ8S86$J3DU@IW*WQXWb+CvA&qq zeb7O4ceXJs5iz!Z`s9f{i=Bi^@v2``w;?w8#TUf^wJ}zs#)QBD8LuvlX%h*ax6v=@ z!-K;U^RJMk*3oN9KVvH;@HuG37^>6A-7GE5&dQ2Cq+li`n%b;*#~CLk0VNw)7Vy3B z{RSdPJ2HwNK-J}ZSvMPmx)yw-&U5tW&%(2ZveehRKt>snni8T*ZA=Daf<{i~A3Fx3 z3#;JD<6W`yw$u?r0j#7PL}1GT4Yp=Zv|HwkoHgI!2*8WtL-XFd-`~-61}!Sb!@0oD z$|-YJkh^i>biJV|70;g|;;EcZEV2K|WF9(e#4vUygK8~av}lB&UUIy4T=|+7Lmzxg zI615aC75wji4pClcXy_}C0TfVL&EnSLfF-@cKk7gD_A;DA5Sq^A2eV2=X8qe8FuB@ zQV$)Qa@GwUVMz%ac==@vWE^os--+y&-ggH&f-p8*?GfLJxdSsz<{HY&178;4O3YlB z!x}6M^gXOv|2=C|Btjzz5Ts{~Y+9YL`#Kk;W#P+#OBayuVJ%cT%mYx>)?NTIJbt|5 z=B9!IDV#&^g{!>_X{;PA1a5`Pvzl39QQIF$)G^+?yIf`TpZ!mt^UBtlgleEuO|>3gPi~2L;Ag1 z;tegBeR??|_V(M>gNBP}B!%IT%}Dxz`j;c}#)wimRGV+6s_OIyX<=1MXBXju6% zuGE^dGchQeUL9u zNdOr0?;C+TQbrYX-DANpAI^E`|WLLsBUhbstUu|L($GPC>WV5v#{IA{}sReiob6_z`0ot z!-9dM4vbsC=|=C(7&u~sjJpWht5E2gUp+f2)*X9r00daoQ1bzKTFc9a?O)s064Rs~ ztAYjZ5vmYf8Hvf>UKY`c`AOkTdJ`9$96u(}d2ItIL4rHK!+M>9fkI}+xN${QewA0~ z$xvwG3hNjdlf4(_FZ^=^qK%*IEd+-Uj#=D0(J)txIcfEW&u~TH2lq`{!AjcbIP)dt zU-(I_xo^BN1`#|+Y^B!&;;2Ww4rU(Y# z|BJ0niF7q~nd`jqe=`!}F~RmtOl9DHyF2p5ts+ z&2pjrb_Nt&8VAcUvl%gXAUB{RBQ*iWwVLO`ey&e238TO z2cYFXLDh?t7NrWieM9`G&zWHX^d5S)0*4Wet*%5|qc+6CQUNUY4CX!?<55>n;k)t4d zGTbfs>F&UmMT|?YSJ&gsxCX9#w29Q~ zsd@Nqm{?l}q#J8YHSg@RQZo#vu1gP228?A~9+1Ionq?3I04N>+e$aW!sJG2&u=uFf zL`OuH1w0QXf(+uY%xHv$4@t|GMXF~OKev%rEBQ9GmTNK$$I@-ip^8CbgBy6+I6DIa z3mVsK#Rh2Jv)`nBK1QC#shWyCQrCC=`XSK{P>^|{y-iIe=^=b-WSa&}6YT7&;O0?q zqFbPHyQ`734I}!@v9egGsre%6k&!+!^tTg@-$SF6}fNWV^M3eiPONyTTn{bUHifSJ|Y1%1wrqcfOeki5wH#+ znRH(H50DhYdsE_tp+Hm3LuT@QITqg+n! z+oSWVzwQqYSBhKTCd!XqUNG)~7}>K(#6~1sT}1nqBfbIkR>=sV4QE^!`sXK&yLr=T zVdzH+Clx?iIi>EC?I|#Kro#=;=-0?HPKcmvraFuTfMGwH|td zD5AWTke0WdAmy=n%yaEsB&zklz;TSYn@586>Onzv_ufQb!yGKwt*q1vUJnMk?>y!h9wUVJT5?q@vG9By24v)-x5aqB_^NMU2l?C_=k}7BwE1o@bRo@TT zEzBA2YAoGe+n*6CD%ZQ@-1vA>m$cvN;QI;GH#pyT)&SdqhL#0)Iy4BmS_+(^et_=u)1V@-i%W)W{lgx9`DIo;$ z9~3K~ndJgmuuCTv@hj~IYyAcl)t&4#ONPh@NR9Gs&4rtMhJsy`6So+$Tz}0^@uzU2 zW|hlzGGxG6#=SV_|aWZ)RzneSlQh?-cBf=9tzxR4y#IdolL1N$l z4uZy=@XshFynJduer*51*Qbnng`))x5M)3sjOn{ZN+4!Z`V}5h-=j7nLDoh|Ntm9x zXkc8=JBWN6CHv;=Kss5-I*}M8^LcJX?30DL6Z~G#0VC6)5vEng*NOEBwAzlBo+ep; zuZ|fewLr&Nhi!=ZvnCF*UB|WGKJ}IE4Y9B2!P#AY)y1l79J`DPe-3Ofw1 z3?4_zAoRwFaV9e5;Nfv4xv8<;w@|x4i_taEDYaqc7Ul-SUp|7D$sQ0hspF_oAL&2r zUD@gmEEBRsPg(+?04UMv$%}SYQVh9ZPpqG(T-kj~kSbpD!G%?jF5UJF*2?N`!PL%f zUYWiLl`QC)ww9KY&!y(kNKZr3+qMlA>H_%Ss5IdTc37KQ@Ugt0AdP8D=`7VDx@Kl7 z&$24s?c}awW|Rj}V(eHL0tDwR)n1`QMOp(=T~!;bis8__roicUpWZ>(m&}%U!O3@U zt?$Af)bZ`wE%Nns=sDDS^%ujU^Ym~TgOh*J_R(_SghVvv7}&2A=2h^8hzFQyl%-G~ z39fFRYkE&NmdM+PoR)#x^8M`>N5{u|*xK5nrD+u@i0p6>UW;$p36qZ(55xNO+DLVW z;GixVin_4P{p{J+zlIx>*~!XqO33dz8Brln^~Lm=eewtYSx zV?mwb{HdLVeK8o^#YtcNI)i%}f`Xm#@dYr%+9x;~N)pZCG*avF#&&5D^7Opo;n~-z zbgwO;^g36)cJ*qfklM6Z))zn_hXBPaj-W}X9XC}^27vkS_zZzfIJ~nrHx}&KB4p>P_Py2Cv`NLKLvj|ElAgxNBS@f;Fb3o z_&^irdgjJ~6DOF1BxH%3ao-xTJFaOnHrF>wO1!KRms}IE>kn3j;RhTB3=IvlJk1~w zi5lsE1@Fk7-7Hj|;(V)c^1<}th3c${>NL+M7Z<<$mq82=S*Ca}H@5=(g$K@;Ec?$| zQ`Y0W4uSu|95>kF91tnXE&UdO(V~d>WF^(Ju|}LAFum3b?I$@0qUOX26Bx+|K@y9( z{vHdC2EZUztx$b6PI~Wj>T^qK52A^})5HENS7O+(`M~*g6usiD6l@*e%KM9ti0%a5 z%f~)}gzoc3!GUZq!OcAFYohDtQvx9ygQuRF--A5ZH@KE;8r!n6gU7jDTTIJXjpYm6t$nH9K}}Jzx@lou`xVB#(w(v9kLEZC8Z83D&h%lKn)Oi z8~A%hd+hY@<(k{s;FFk3xt6-(B6l`CyqF+IMC2}mp5Umhg&V@=+n4tRUk(8Ru~>qA zh`>aC!SI8n%iOrKr8U|_Jt*Oq>Yxcd za_}IxLDr^Ip(-~n?fMttbZ?Z5~kp;YIBlH}yx zi~?F0as;oRD;KeZMT0pE>8S*T^6pS(=0M-(fHooZ#8kVZccG<(FRCz~M9&9yY}BJi z$L(%{ljdp0Ag?eu)7x-sFQk~PJj?$5zt`1?e|m=3kdQFZ3@YX);MOwff`2W&2JaLW zgr5%1$3YeQq9mJy%?uRqQZDgrXgkIAltxVRt`-+})au53knj$1AlLvM1tS}8iFuJ= z^PVn~;AjKdHoTS{4hCk+=|}O`ddgXBckCF=B)^$h3WYNMV|Tp-*X~x_#vezf>k=O>sSe+k6KXuvdv27apuy*P;5&8^O>kJ9-8Z zbI4*TmQp%1ech7kdRT&>^@#YHl1F1NTW+Z@!>QbS(&nR38PR!gu7nl6E?SWiL8rgz z6~z!km&iwG@8k}@WBL?vpw;@A2W4gA?%iX=R?xD|+Q8C+_QS7;so3Px*Kjv8Fc=;o zxm){fRod7%an~enDR!`7SMp|AJ!ZNKgSn%zYZj*D0SCr$s%X4|yT%4N87DC24eMrV zBn-b763@U6I{W)|x;s?1xAOnF06b#7<30={#3!#VZDg2IcsHNy3-$$fyW!8Dj?H$^ zTbO`+E;`-g-mqA4*G2|2xTaZt6M%Qd_+an6W%EfV46Us_Uv4-lz^*gigYXqn=f3FO zv*&Ms`etaWNT(&#S##HJNs=Zu9OFfON0I(M!dXVl5Pg;nd?{THZzSTB~+vD4=P zMHU^}d2>!PO*Db5g%H-h%K%5e^P$xZ^l7P*9eV+NfT=>Il4UXt9~n1{B8N)`$mMT? zVc`3aYpq6z03g`EnEz2|iErMd=w!8u!C$3dp{j#KA^B@&BskYVGkp$|J1s33djW<3 zuU@eiTFpKm%YXg8qEwWZf;`yAwowTMB9_TP5dctvg=K>gMi!X2VNb)jJ0c&+4rRT! zpv$3#W8Qh52AzYI`m`Us_YIBKNlV<_W4Uz-Znm}*D)2&r)=|SiDy?P@Cl3WbCd!Ss zZzqD1+__V46BT>=t;=w`bOlV;RkAlj<_9#x=l<%IE0QtRRH6R^LV(C=$WRlxW%f

    >HY|8`8NP`*-74 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 I`INu^A9pw@^Z)<= literal 301630 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

      $awWJ3 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

      Psptu~T#6Mst%jpF^2qb56}oGN8)%$pU05 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 ClpkLJ diff --git a/man/filter.data_request.Rd b/man/filter.data_request.Rd index 2a2f236c..0e093221 100644 --- a/man/filter.data_request.Rd +++ b/man/filter.data_request.Rd @@ -38,8 +38,8 @@ by \code{collect()} or one of the \code{atlas_} family of functions (such as \details{ \emph{Syntax} -\code{filter.data_request()} and \code{galah_filter()} uses non-standard evaluation -(NSE), and are designed to be as compatible as possible with +\code{filter()} uses non-standard evaluation +(NSE), and is designed to be as compatible as possible with \code{dplyr::filter()} syntax. Permissible examples include: \itemize{ \item \code{==} (e.g. \code{year = 2020}) but not \code{=} (for consistency with \code{dplyr}) @@ -48,7 +48,6 @@ by \code{collect()} or one of the \code{atlas_} family of functions (such as \item \code{<} or \code{<=} (e.g. \code{year <= 2020}) \item \code{OR} statements (e.g. \code{year == 2018 | year == 2020}) \item \code{AND} statements (e.g. \code{year >= 2000 & year <= 2020}) -\item Field names can be parsed from objects using \code{{{}}} syntax, e.g. \verb{field <- "year"; value <- "2025"; galah_filter(\{\{field\}\} == value)} } Some general tips: @@ -114,11 +113,20 @@ And finally, the metadata tibble can be used to request files: } \examples{ \dontrun{ +# basic example galah_call() |> filter(year >= 2019, basisOfRecord == "HumanObservation") |> count() |> collect() + +# Field names can be parsed from objects using `{{}}` syntax, e.g. +field <- "year" +value <- "2025" +galah_call() |> + filter({{field}} == value) |> + count() |> + collect() } } \seealso{ diff --git a/man/identify.data_request.Rd b/man/identify.data_request.Rd index b8861310..9bb5cfbd 100644 --- a/man/identify.data_request.Rd +++ b/man/identify.data_request.Rd @@ -13,7 +13,7 @@ galah_identify(...) } \arguments{ -\item{x}{An object of class \code{metadata_request}, created using \code{\link[=request_metadata]{request_metadata()}}} +\item{x}{An object of class \code{data_request} or \code{metadata_request}.} \item{...}{One or more scientific names.} } diff --git a/man/show_values.Rd b/man/show_values.Rd index b15c05b7..77a98534 100644 --- a/man/show_values.Rd +++ b/man/show_values.Rd @@ -44,7 +44,7 @@ For example: \item The \code{field} "year" contains values 2021, 2020, 2019, etc. \item The \code{field} "stateProvince" contains values New South Wales, Victoria, Queensland, etc. These are used to narrow queries with -\code{\link[=filter.data_request]{filter()}} or \code{\link[=galah_filter]{galah_filter()}}. +\code{\link[=filter.data_request]{filter()}}. } Each \strong{Profile} consists of many individual quality filters. diff --git a/tests/testthat/test-request_metadata_unnest.R b/tests/testthat/test-request_metadata_unnest.R index 2444ff1a..8efc2ecc 100644 --- a/tests/testthat/test-request_metadata_unnest.R +++ b/tests/testthat/test-request_metadata_unnest.R @@ -17,22 +17,29 @@ test_that("request_metadata() |> unnest() works for type = 'fields'", { filter(field == unknown) |> collapse(), label = "Can't use fields that don't exist.") + # supplying `filter()` without unnest() works + x <- request_metadata() |> + filter(field == "basisOfRecord") |> + collect() + expect_s3_class(x, c("tbl_df", "tbl", "data.frame")) + expect_equal(nrow(x), 1) + expect_equal(x$id, "basisOfRecord") # whole thing works when... - x <- request_metadata() |> + y <- request_metadata() |> unnest() |> - filter(field == basisOfRecord) |> + filter(field == "basisOfRecord") |> collect() - expect_s3_class(x, c("tbl_df", "tbl", "data.frame")) - expect_gte(nrow(x), 4) - expect_equal(ncol(x), 1) - expect_equal(colnames(x), "basisOfRecord") - expect_true(any(x[[1]] == "HUMAN_OBSERVATION")) + expect_s3_class(y, c("tbl_df", "tbl", "data.frame")) + expect_gte(nrow(y), 4) + expect_equal(ncol(y), 1) + expect_equal(colnames(y), "basisOfRecord") + expect_true(any(y[[1]] == "HUMAN_OBSERVATION")) }) test_that("request_metadata() |> select() |> unnest() works for type = 'fields'", { skip_if_offline(); skip_on_ci() base_query <- request_metadata() |> - filter(field == basisOfRecord) |> + filter(field == "basisOfRecord") |> unnest() x <- base_query |> collect() @@ -54,8 +61,14 @@ test_that("request_metadata() |> select() |> unnest() works for type = 'fields'" test_that("request_metadata() |> unnest() works for type = 'lists'", { skip_if_offline(); skip_on_ci() + x1 <- request_metadata() |> + filter(list == "dr947") |> + collect() + expect_s3_class(x1, c("tbl_df", "tbl", "data.frame")) + expect_equal(nrow(x1), 1) + expect_equal(x1[[1]], "dr947") x <- request_metadata() |> - filter(list == dr947) |> + filter(list == "dr947") |> unnest() |> collapse() expect_s3_class(x, "query") @@ -70,7 +83,7 @@ test_that("request_metadata() |> unnest() works for type = 'lists'", { expect_gte(ncol(z), 3) # now check `everything()` xx <- request_metadata() |> - filter(list == dr947) |> + filter(list == "dr947") |> unnest() |> select(everything()) |> collect() @@ -88,6 +101,12 @@ test_that("`request_metadata() |> unnest()` fails for invalid profiles", { test_that("`request_metadata() |> unnest() |> collapse()` works for type = profiles", { skip_if_offline(); skip_on_ci() + x1 <- request_metadata() |> + filter(profile == "ALA") |> + collect() + expect_s3_class(x1, c("tbl_df", "tbl", "data.frame")) + expect_equal(nrow(x1), 1) + expect_equal(x1$short_name, "ALA") x <- request_metadata() |> filter(profile == "ALA") |> unnest() |> @@ -126,6 +145,12 @@ test_that("request_metadata() |> unnest() works for type = 'profiles'", { test_that("request_metadata() |> unnest() works for type = 'taxa' using `identify()`", { skip_if_offline(); skip_on_ci() + x1 <- request_metadata() |> + identify("crinia") |> + collect() + expect_s3_class(x1, c("tbl_df", "tbl", "data.frame")) + expect_equal(nrow(x1), 1) + expect_equal(x1[[1]], "crinia") x <- request_metadata() |> identify("crinia") |> unnest() |> diff --git a/vignettes/atlas_stats.csv b/vignettes/atlas_stats.csv deleted file mode 100644 index 6eebb165..00000000 --- a/vignettes/atlas_stats.csv +++ /dev/null @@ -1,11 +0,0 @@ -"region","institution","count","n_services" -"Global","Global Biodiversity Information Facility",2457308452,11 -"United Kingdom","National Biodiversity Network",298318126,19 -"Sweden","Swedish Biodiversity Data Infrastructure",153805087,23 -"France","Portail français d'accès aux données d'observation sur les espèces",153402324,14 -"Australia","Atlas of Living Australia",146132636,24 -"Spain","GBIF Spain",59673279,24 -"Brazil","Sistemas de Informações sobre a Biodiversidade Brasileira",36509671,18 -"Portugal","GBIF Portugal",16043865,15 -"Austria","Biodiversitäts-Atlas Österreich",9173597,19 -"Guatemala","Sistema Nacional de Información sobre Diversidad Biológica de Guatemala",4225159,16 diff --git a/vignettes/atlases_plot.R b/vignettes/atlases_plot.R index 39941760..93325796 100644 --- a/vignettes/atlases_plot.R +++ b/vignettes/atlases_plot.R @@ -128,15 +128,16 @@ df$type_label <- factor( "metadata/lists-unnest" ~ 15, "metadata/media" ~ 16, "metadata/reasons" ~ 17, - "data/occurrences" ~ 18, - "data/occurrences-doi" ~ 19, - "data/occurrences-count" ~ 20, - "data/occurrences-count-groupby" ~ 21, - "data/species" ~ 22, - "data/species-count" ~ 23, - "files/media" ~ 24 + "metadata/config" ~ 18, + "data/occurrences" ~ 19, + "data/occurrences-doi" ~ 20, + "data/occurrences-count" ~ 21, + "data/occurrences-count-groupby" ~ 22, + "data/species" ~ 23, + "data/species-count" ~ 24, + "files/media" ~ 25 ), - levels = seq_len(24), + levels = seq_len(25), labels = c("providers", "collections", "datasets", @@ -154,6 +155,7 @@ df$type_label <- factor( "lists-unnest", "media", "reasons", + "authentication", "occurrences", "occurrences-doi", "occurrences-count", diff --git a/vignettes/choosing_an_atlas.Rmd b/vignettes/choosing_an_atlas.Rmd deleted file mode 100644 index 2491021f..00000000 --- a/vignettes/choosing_an_atlas.Rmd +++ /dev/null @@ -1,51 +0,0 @@ ---- -title: "Choosing an atlas" -author: "Martin Westgate & Dax Kellie" -date: '2024-11-05' -output: - rmarkdown::html_vignette -vignette: > - %\VignetteIndexEntry{Choosing an atlas} - %\VignetteEngine{knitr::rmarkdown} - %\VignetteEncoding{UTF-8} -resource_files: - - '../man/figures/atlases_plot.png' ---- - -The GBIF network consists of a series of a series of 'node' organisations who -collate biodiversity data from their own countries, with GBIF acting as an -umbrella organisation to store data from all nodes. Several nodes have their -own APIs, often built from the 'living atlas' codebase developed by the ALA. -`galah` enables you to download data either from GBIF itself as an alternative -to [rgbif](https://docs.ropensci.org/rgbif/); or from one of 11 living atlases, -as listed below. - - -```{r, echo = FALSE} -library(reactable) -read.csv("atlas_stats.csv") |> - reactable(defaultPageSize = 12, - columns = list(region = colDef("Region", minWidth = 50), - institution = colDef("Organisation", - minWidth = 150), - count = colDef("Number of Records", minWidth = 50, - format = colFormat(separators = TRUE)), - n_services = colDef("Number of supported APIs", minWidth = 50))) -``` - -Choosing which node to query is not entirely straightforward. Broadly -speaking, GBIF is always an easy answer, because it has information from many -countries. If you only want data from a single country, however, the nodes may -offer some advantages. Namely, GBIF nodes may support locally-specific: - - - **fields** that can be used for more efficient filtering - - **taxonomies** that reflect changes not yet adopted by the GBIF taxonomic backbone - - **services** that are not offered by GBIF such as error checking or large downloads - -Ultimately, `galah` aims to provide _access_ to as many GBIF nodes as possible; -it's up to you which node organisation's data are suitable for your needs! -We currently support the following functions and atlases: - -```{r atlas-support, echo = FALSE, out.width = "100%"} -knitr::include_graphics('../man/figures/atlases_plot.png') -``` \ No newline at end of file diff --git a/vignettes/download_data.Rmd b/vignettes/download_data.Rmd deleted file mode 100644 index 65f99ceb..00000000 --- a/vignettes/download_data.Rmd +++ /dev/null @@ -1,280 +0,0 @@ ---- -title: "Download data" -author: "Martin Westgate & Dax Kellie" -date: '2024-11-19' -output: - rmarkdown::html_vignette -vignette: > - %\VignetteIndexEntry{Download data} - %\VignetteEngine{knitr::rmarkdown} - %\VignetteEncoding{UTF-8} ---- -The `atlas_` functions are used to return data from the atlas chosen using -`galah_config()`. They are: - -- `atlas_counts()` -- `atlas_occurrences()` -- `atlas_species()` -- `atlas_media()` -- `atlas_taxonomy()` - -The final `atlas_` function---`atlas_citation()`---is unusual: It does not -return any new data, but instead provides a citation for an existing dataset -(downloaded using `atlas_occurrences()`) with an associated DOI. The other -functions are described below. - -It is equally permissable to use the `type` argument of `galah_call()` -to specify the kind of data you want, and then retrieve the data using `collect()`. -Here we use the `atlas_` prefix for consistency with earlier versions of galah, -and because many `atlas_` functions sometimes include shortcuts to make life -easier. - - -# Record counts -`atlas_counts()` provides summary counts of records in the specified atlas -without needing to download all the records first. - - -``` r -galah_config(atlas = "Australia") -# Total number of records in the ALA -atlas_counts() -``` - -``` -## # A tibble: 1 × 1 -## count -## -## 1 146185520 -``` - -Group and summarise record counts by specific fields using `galah_group_by()`. - - -``` r -galah_call() |> - galah_group_by(kingdom) |> - atlas_counts() -``` - -``` -## # A tibble: 12 × 2 -## kingdom count -## -## 1 Animalia 113408280 -## 2 Plantae 27572183 -## 3 Fungi 2448600 -## 4 Chromista 1057157 -## 5 Protista 316541 -## 6 Bacteria 113480 -## 7 Archaea 4120 -## 8 Virus 2382 -## 9 Bamfordvirae 210 -## 10 Orthornavirae 138 -## 11 Viroid 104 -## 12 Shotokuvirae 41 -``` - - -# Species lists -A common use case of atlas data is to identify which species occur in a specified -region, time period, or taxonomic group. `atlas_species()` is similar to -`search_taxa()`, in that it returns taxonomic information and unique identifiers, -but differs by returning information only on species and is far more flexible by -supporting filtering. - - -``` r -species <- galah_call() |> - galah_identify("Rodentia") |> - galah_filter(stateProvince == "Northern Territory") |> - atlas_species() - -species |> head() -``` - -``` -## # A tibble: 6 × 11 -## taxon_concept_id species_name scientific_name_auth…¹ taxon_rank kingdom phylum class order family genus vernacular_name -## -## 1 https://biodive… Pseudomys d… (Gould, 1842) species Animal… Chord… Mamm… Rode… Murid… Pseu… Delicate Mouse -## 2 https://biodive… Mesembriomy… (J.E. Gray, 1843) species Animal… Chord… Mamm… Rode… Murid… Mese… Black-footed T… -## 3 https://biodive… Zyzomys arg… (Thomas, 1889) species Animal… Chord… Mamm… Rode… Murid… Zyzo… Common Rock-rat -## 4 https://biodive… Pseudomys h… (Waite, 1896) species Animal… Chord… Mamm… Rode… Murid… Pseu… Sandy Inland M… -## 5 https://biodive… Melomys bur… (Ramsay, 1887) species Animal… Chord… Mamm… Rode… Murid… Melo… Grassland Melo… -## 6 https://biodive… Notomys ale… Thomas, 1922 species Animal… Chord… Mamm… Rode… Murid… Noto… Spinifex Hoppi… -## # ℹ abbreviated name: ¹​scientific_name_authorship -``` - - -# Occurrence data -To download occurrence data you will need to specify an email in -`galah_config()` that has been registered to an account with your selected GBIF node. -See more information in the [config section](#config). - - -``` r -galah_config(email = "your_email@email.com", atlas = "Australia") -``` - -Download occurrence records for *Eolophus roseicapilla*. - - -``` r -occ <- galah_call() |> - galah_identify("Eolophus roseicapilla") |> - galah_filter( - stateProvince == "Australian Capital Territory", - year >= 2010, - profile = "ALA" - ) |> - galah_select(institutionID, group = "basic") |> - atlas_occurrences() -``` - -``` -## Retrying in 1 seconds. -## Retrying in 2 seconds. -## Retrying in 4 seconds. -``` - -``` r -occ |> head() -``` - -``` -## # A tibble: 6 × 9 -## recordID scientificName taxonConceptID decimalLatitude decimalLongitude eventDate occurrenceStatus -## -## 1 0000a928-d756-42eb… Eolophus rose… https://biodi… -35.6 149. 2017-04-19 09:11:00 PRESENT -## 2 0001bc78-d2e9-48aa… Eolophus rose… https://biodi… -35.2 149. 2019-08-13 15:13:00 PRESENT -## 3 0002064f-08ea-425b… Eolophus rose… https://biodi… -35.3 149. 2014-03-16 06:48:00 PRESENT -## 4 00022dd2-9f85-4802… Eolophus rose… https://biodi… -35.3 149. 2022-05-08 08:20:00 PRESENT -## 5 0002cc35-8d5a-4d20… Eolophus rose… https://biodi… -35.3 149. 2015-11-01 08:00:00 PRESENT -## 6 00030a8c-082f-44f0… Eolophus rose… https://biodi… -35.3 149. 2022-01-06 11:47:00 PRESENT -## # ℹ 2 more variables: dataResourceName , institutionID -``` - - -# Media metadata -In addition to text data describing individual occurrences and their attributes, -ALA stores images, sounds and videos associated with a given record. Metadata on -these records can be downloaded using `atlas_media()`. - - -``` r -media_data <- galah_call() |> - galah_identify("Eolophus roseicapilla") |> - galah_filter( - year == 2020, - cl22 == "Australian Capital Territory") |> - atlas_media() - -media_data |> head() -``` - -``` -## # A tibble: 6 × 19 -## media_id recordID scientificName taxonConceptID decimalLatitude decimalLongitude eventDate occurrenceStatus -## -## 1 ff8322d0-… 003a192… Eolophus rose… https://biodi… -35.3 149. 2020-09-12 16:11:00 PRESENT -## 2 c66fc819-… 015ee7c… Eolophus rose… https://biodi… -35.4 149. 2020-08-09 15:11:00 PRESENT -## 3 fe6d7b94-… 05e86b7… Eolophus rose… https://biodi… -35.4 149. 2020-11-13 22:29:00 PRESENT -## 4 2f4d32c0-… 063bb0f… Eolophus rose… https://biodi… -35.6 149. 2020-08-04 11:50:00 PRESENT -## 5 73407414-… 063bb0f… Eolophus rose… https://biodi… -35.6 149. 2020-08-04 11:50:00 PRESENT -## 6 89171c49-… 063bb0f… Eolophus rose… https://biodi… -35.6 149. 2020-08-04 11:50:00 PRESENT -## # ℹ 11 more variables: dataResourceName , multimedia , images , sounds , videos , -## # creator , license , mimetype , width , height , image_url -``` - -To actually download the media files to your computer, use [collect_media()]. - - -``` r -media_data |> - collect_media() -``` - -# Taxonomic trees -`atlas_taxonomy()` provides a way to build taxonomic trees from one clade down to -another using each GBIF node's internal taxonomy. Specify which taxonomic level -your tree will go down to with `galah_filter()` using the `rank` argument. - - -``` r -galah_call() |> - galah_identify("chordata") |> - galah_filter(rank == class) |> - atlas_taxonomy() -``` - -``` -## # A tibble: 19 × 4 -## name rank parent_taxon_concept_id taxon_concept_id -## -## 1 Chordata phylum https://biodivers… -## 2 Cephalochordata subphylum https://biodiversity.org.au/afd/taxa/065f1da4-53cd-40b8-a396-80fa5c74dedd https://biodivers… -## 3 Tunicata subphylum https://biodiversity.org.au/afd/taxa/065f1da4-53cd-40b8-a396-80fa5c74dedd https://biodivers… -## 4 Appendicularia class https://biodiversity.org.au/afd/taxa/1c20ed62-d918-4e42-b625-8b86d533cc51 https://biodivers… -## 5 Ascidiacea class https://biodiversity.org.au/afd/taxa/1c20ed62-d918-4e42-b625-8b86d533cc51 https://biodivers… -## 6 Thaliacea class https://biodiversity.org.au/afd/taxa/1c20ed62-d918-4e42-b625-8b86d533cc51 https://biodivers… -## 7 Vertebrata subphylum https://biodiversity.org.au/afd/taxa/065f1da4-53cd-40b8-a396-80fa5c74dedd https://biodivers… -## 8 Agnatha informal https://biodiversity.org.au/afd/taxa/5d6076b1-b7c7-487f-9d61-0fea0111cc7e https://biodivers… -## 9 Myxini informal https://biodiversity.org.au/afd/taxa/66db22c8-891d-4b16-a1a2-b66feaeaa3e0 https://biodivers… -## 10 Petromyzontida informal https://biodiversity.org.au/afd/taxa/66db22c8-891d-4b16-a1a2-b66feaeaa3e0 https://biodivers… -## 11 Gnathostomata informal https://biodiversity.org.au/afd/taxa/5d6076b1-b7c7-487f-9d61-0fea0111cc7e https://biodivers… -## 12 Amphibia class https://biodiversity.org.au/afd/taxa/ef5515fd-a0a2-4e16-b61a-0f19f8900f76 https://biodivers… -## 13 Aves class https://biodiversity.org.au/afd/taxa/ef5515fd-a0a2-4e16-b61a-0f19f8900f76 https://biodivers… -## 14 Mammalia class https://biodiversity.org.au/afd/taxa/ef5515fd-a0a2-4e16-b61a-0f19f8900f76 https://biodivers… -## 15 Reptilia class https://biodiversity.org.au/afd/taxa/ef5515fd-a0a2-4e16-b61a-0f19f8900f76 https://biodivers… -## 16 Pisces informal https://biodiversity.org.au/afd/taxa/ef5515fd-a0a2-4e16-b61a-0f19f8900f76 https://biodivers… -## 17 Actinopterygii class https://biodiversity.org.au/afd/taxa/e22efeb4-2cb5-4250-8d71-61c48bdaa051 https://biodivers… -## 18 Chondrichthyes class https://biodiversity.org.au/afd/taxa/e22efeb4-2cb5-4250-8d71-61c48bdaa051 https://biodivers… -## 19 Sarcopterygii class https://biodiversity.org.au/afd/taxa/e22efeb4-2cb5-4250-8d71-61c48bdaa051 https://biodivers… -``` - -# Configuring galah -Various aspects of the galah package can be customized. - -## Email -To download occurrence records, species lists or media, you will need to -provide an email address registered with the service that you want to use -(e.g. for the ALA you can create an account -[here](https://auth.ala.org.au/userdetails/registration/createAccount)). - -Once an email is registered, it should be stored in the config: - -``` r -galah_config(email = "myemail@gmail.com") -``` - -## Setting your directory -By default, galah stores downloads in a temporary folder, meaning that the -local files are automatically deleted when the R session is ended. This -behaviour can be altered so that downloaded files are preserved by setting the -directory to a non-temporary location. - - -``` r -galah_config(directory = "example/dir") -``` - -## Setting the download reason -ALA requires that you provide a reason when downloading occurrence data -(via the galah `atlas_occurrences()` function). `reason` is set as -"scientific research" by default, but you can change this using `galah_config()`. -See `show_all(reasons)` for valid download reasons. - - -``` r -galah_config(download_reason_id = your_reason_id) -``` - -## Debugging -If things aren't working as expected, more detail (particularly about web -requests and caching behaviour) can be obtained by setting `verbose = TRUE`. - - -``` r -galah_config(verbose = TRUE) -``` diff --git a/vignettes/download_data.Rmd.orig b/vignettes/download_data.Rmd.orig deleted file mode 100644 index e3b306ae..00000000 --- a/vignettes/download_data.Rmd.orig +++ /dev/null @@ -1,175 +0,0 @@ ---- -title: "Download data" -author: "Martin Westgate & Dax Kellie" -date: '`r Sys.Date()`' -output: - rmarkdown::html_vignette -vignette: > - %\VignetteIndexEntry{Download data} - %\VignetteEngine{knitr::rmarkdown} - %\VignetteEncoding{UTF-8} ---- -The `atlas_` functions are used to return data from the atlas chosen using -`galah_config()`. They are: - -- `atlas_counts()` -- `atlas_occurrences()` -- `atlas_species()` -- `atlas_media()` -- `atlas_taxonomy()` - -The final `atlas_` function---`atlas_citation()`---is unusual: It does not -return any new data, but instead provides a citation for an existing dataset -(downloaded using `atlas_occurrences()`) with an associated DOI. The other -functions are described below. - -It is equally permissable to use the `type` argument of `galah_call()` -to specify the kind of data you want, and then retrieve the data using `collect()`. -Here we use the `atlas_` prefix for consistency with earlier versions of galah, -and because many `atlas_` functions sometimes include shortcuts to make life -easier. - -```{r include = FALSE} -galah_config(email = "ala4r@ala.org.au", - atlas = "Australia", - caching = FALSE, - verbose = FALSE) -``` -# Record counts -`atlas_counts()` provides summary counts of records in the specified atlas -without needing to download all the records first. - -```{r, warning = FALSE} -galah_config(atlas = "Australia") -# Total number of records in the ALA -atlas_counts() -``` - -Group and summarise record counts by specific fields using `galah_group_by()`. - -```{r, warning = FALSE} -galah_call() |> - galah_group_by(kingdom) |> - atlas_counts() -``` - - -# Species lists -A common use case of atlas data is to identify which species occur in a specified -region, time period, or taxonomic group. `atlas_species()` is similar to -`search_taxa()`, in that it returns taxonomic information and unique identifiers, -but differs by returning information only on species and is far more flexible by -supporting filtering. - -```{r} -species <- galah_call() |> - galah_identify("Rodentia") |> - galah_filter(stateProvince == "Northern Territory") |> - atlas_species() - -species |> head() -``` - - -# Occurrence data -To download occurrence data you will need to specify an email in -`galah_config()` that has been registered to an account with your selected GBIF node. -See more information in the [config section](#config). - -```{r, eval = FALSE} -galah_config(email = "your_email@email.com", atlas = "Australia") -``` - -Download occurrence records for *Eolophus roseicapilla*. - -```{r} -occ <- galah_call() |> - galah_identify("Eolophus roseicapilla") |> - galah_filter( - stateProvince == "Australian Capital Territory", - year >= 2010, - profile = "ALA" - ) |> - galah_select(institutionID, group = "basic") |> - atlas_occurrences() - -occ |> head() -``` - - -# Media metadata -In addition to text data describing individual occurrences and their attributes, -ALA stores images, sounds and videos associated with a given record. Metadata on -these records can be downloaded using `atlas_media()`. - -```{r, warning=FALSE} -media_data <- galah_call() |> - galah_identify("Eolophus roseicapilla") |> - galah_filter( - year == 2020, - cl22 == "Australian Capital Territory") |> - atlas_media() - -media_data |> head() -``` - -To actually download the media files to your computer, use [collect_media()]. - -```{r, eval=FALSE} -media_data |> - collect_media() -``` - -# Taxonomic trees -`atlas_taxonomy()` provides a way to build taxonomic trees from one clade down to -another using each GBIF node's internal taxonomy. Specify which taxonomic level -your tree will go down to with `galah_filter()` using the `rank` argument. - -```{r, warning = FALSE} -galah_call() |> - galah_identify("chordata") |> - galah_filter(rank == class) |> - atlas_taxonomy() -``` - -# Configuring galah -Various aspects of the galah package can be customized. - -## Email -To download occurrence records, species lists or media, you will need to -provide an email address registered with the service that you want to use -(e.g. for the ALA you can create an account -[here](https://auth.ala.org.au/userdetails/registration/createAccount)). - -Once an email is registered, it should be stored in the config: -```{r eval=FALSE} -galah_config(email = "myemail@gmail.com") -``` - -## Setting your directory -By default, galah stores downloads in a temporary folder, meaning that the -local files are automatically deleted when the R session is ended. This -behaviour can be altered so that downloaded files are preserved by setting the -directory to a non-temporary location. - -```{r eval=FALSE} -galah_config(directory = "example/dir") -``` - -## Setting the download reason -ALA requires that you provide a reason when downloading occurrence data -(via the galah `atlas_occurrences()` function). `reason` is set as -"scientific research" by default, but you can change this using `galah_config()`. -See `show_all(reasons)` for valid download reasons. - -```{r eval=FALSE} -galah_config(download_reason_id = your_reason_id) -``` - -## Debugging -If things aren't working as expected, more detail (particularly about web -requests and caching behaviour) can be obtained by setting `verbose = TRUE`. - -```{r eval=FALSE} -galah_config(verbose = TRUE) -``` \ No newline at end of file diff --git a/vignettes/narrow_your_results.Rmd b/vignettes/narrow_your_results.Rmd deleted file mode 100644 index 9b80a5de..00000000 --- a/vignettes/narrow_your_results.Rmd +++ /dev/null @@ -1,366 +0,0 @@ ---- -title: "Narrow your results" -author: "Martin Westgate & Dax Kellie" -date: '2024-11-19' -output: html_document -editor_options: - chunk_output_type: inline -vignette: > - %\VignetteIndexEntry{Narrow your results} - %\VignetteEncoding{UTF-8} - %\VignetteEngine{knitr::rmarkdown} ---- - - - -Each occurrence record contains taxonomic information and -information about the observation itself, like its location and the date -of observation. These pieces of information are recorded and categorised into -respective **fields**. When you import data using galah, columns of the -resulting `tibble` correspond to these fields. - -Data fields are important because they provide a means to narrow and refine -queries to return only the information that you need, and no more. Consequently, -much of the architecture of galah has been designed to make narrowing as simple -as possible. For legacy reasons, there are both `dplyr`-style verbs and galah- -specific versions of these functions; but they are largely synonymous. They -include: - -- `identify()` or `galah_identify()` -- `filter()` or `galah_filter()` -- `select()` or `galah_select()` -- `group_by()` or `galah_group_by()` -- `geolocate()` or `galah_geolocate()` - -Below we discuss each of these functions in turn. - -# `search_taxa` & `identify` -Perhaps unsurprisingly, `search_taxa()` searches for taxonomic information. -`search_taxa()` uses fuzzy-matching to work a lot like the search bar on the -[Atlas of Living Australia website](https://www.ala.org.au/), -and you can use it to search for taxa by their scientific name. - -Finding your desired taxon with `search_taxa()` is an important step to using -this taxonomic information to download data. For example, to search for -reptiles, we first need to identify whether we have the correct query: - - -``` r -search_taxa("Reptilia") -``` - -``` -## # A tibble: 1 × 9 -## search_term scientific_name taxon_concept_id rank match_type kingdom phylum class issues -## -## 1 Reptilia REPTILIA https://biodiversity.org.au/afd/taxa/682e1228… class exactMatch Animal… Chord… Rept… noIss… -``` - -If we want to be more specific, we can provide a `tibble` (or `data.frame`) -providing additional taxonomic information. - - -``` r -search_taxa(tibble(genus = "Eolophus", kingdom = "Aves")) -``` - -``` -## # A tibble: 1 × 13 -## search_term scientific_name scientific_name_auth…¹ taxon_concept_id rank match_type kingdom phylum class order family -## -## 1 Eolophus_Av… Eolophus Bonaparte, 1854 https://biodive… genus exactMatch Animal… Chord… Aves Psit… Cacat… -## # ℹ abbreviated name: ¹​scientific_name_authorship -## # ℹ 2 more variables: genus , issues -``` - -Once we know that our search matches the correct taxon or taxa, we -can use `identify()` to narrow the results of our query. - -``` r -galah_call() |> - identify("Reptilia") |> - atlas_counts() -``` - -``` -## # A tibble: 1 × 1 -## count -## -## 1 1841182 -``` - -If you're using an international atlas, `search_taxa()` will automatically -switch to using the local name-matching service. For example, Portugal uses the -GBIF taxonomic backbone, but integrates seamlessly with our standard workflow. - - -``` r -galah_config(atlas = "Portugal") -``` - -``` -## Atlas selected: GBIF Portugal (GBIF.pt) [Portugal] -``` - -``` r -galah_call() |> - identify("Lepus") |> - group_by(species) |> - atlas_counts() -``` - -``` -## # A tibble: 5 × 2 -## species count -## -## 1 Lepus granatensis 1378 -## 2 Lepus microtis 64 -## 3 Lepus europaeus 10 -## 4 Lepus saxatilis 2 -## 5 Lepus capensis 1 -``` - -Conversely, the UK's [National Biodiversity Network](https://nbn.org.uk) (NBN), -has its own taxonomic backbone, but is supported using the same function call. - - -``` r -galah_config(atlas = "United Kingdom") -``` - -``` -## Atlas selected: National Biodiversity Network (NBN) [United Kingdom] -``` - -``` r -galah_call() |> - filter(genus == "Bufo") |> - group_by(species) |> - atlas_counts() -``` - -``` -## # A tibble: 3 × 2 -## species count -## -## 1 Bufo bufo 77009 -## 2 Bufo spinosus 143 -## 3 Bufo marinus 1 -``` - -# filter -Perhaps the most important function in galah is `filter()`, which is used -to filter the rows of queries. - - -``` r -galah_config(atlas = "Australia") -``` - -``` -## Atlas selected: Atlas of Living Australia (ALA) [Australia] -``` - -``` r -# Get total record count since 2000 -galah_call() |> - filter(year > 2000) |> - atlas_counts() -``` - -``` -## # A tibble: 1 × 1 -## count -## -## 1 104768572 -``` - -``` r -# Get total record count for iNaturalist in 2021 -galah_call() |> - filter( - year > 2000, - dataResourceName == "iNaturalist Australia") |> - atlas_counts() -``` - -``` -## # A tibble: 1 × 1 -## count -## -## 1 8085678 -``` - -To find available fields and corresponding valid values, use the field lookup -functions `show_all(fields)`, `search_all(fields)` & `show_values()`. - -`galah_filter()` can also be used to make more complex taxonomic -queries than are possible using `search_taxa()`. By using the `taxonConceptID` -field, it is possible to build queries that exclude certain taxa, for example. -This can be useful to filter for paraphyletic concepts such as invertebrates. - - -``` r -galah_call() |> - filter( - taxonConceptID == search_taxa("Animalia")$taxon_concept_id, - taxonConceptID != search_taxa("Chordata")$taxon_concept_id - ) |> - group_by(class) |> - atlas_counts() -``` - -``` -## # A tibble: 70 × 2 -## class count -## -## 1 Insecta 6636702 -## 2 Gastropoda 1079236 -## 3 Arachnida 880799 -## 4 Maxillopoda 701466 -## 5 Malacostraca 667094 -## 6 Polychaeta 278997 -## 7 Bivalvia 238787 -## 8 Anthozoa 228733 -## 9 Cephalopoda 150198 -## 10 Demospongiae 119207 -## # ℹ 60 more rows -``` - -In addition to single filters, some atlases (currently Australia, Sweden & -Spain) also support 'data profiles'. These are effectively pre-formed sets of -filters that are designed to remove records that are suspect in some way. This -feature has its' own function, `apply_profile()`: - - -``` r -galah_call() |> - filter(year > 2000) |> - apply_profile(ALA) |> - atlas_counts() -``` - -``` -## # A tibble: 1 × 1 -## count -## -## 1 91982400 -``` - -To see a full list of data profiles, use `show_all(profiles)`. - -# group_by -Use `group_by()` to group and summarise record counts by specified fields. - - -``` r -# Get record counts since 2010, grouped by year and basis of record -galah_call() |> - filter(year > 2015 & year <= 2020) |> - group_by(year, basisOfRecord) |> - atlas_counts() -``` - -``` -## # A tibble: 35 × 3 -## year basisOfRecord count -## -## 1 2020 HUMAN_OBSERVATION 6859463 -## 2 2020 OCCURRENCE 188090 -## 3 2020 PRESERVED_SPECIMEN 87730 -## 4 2020 MACHINE_OBSERVATION 39642 -## 5 2020 OBSERVATION 4417 -## 6 2020 MATERIAL_SAMPLE 2104 -## 7 2020 LIVING_SPECIMEN 62 -## 8 2019 HUMAN_OBSERVATION 6104069 -## 9 2019 PRESERVED_SPECIMEN 166446 -## 10 2019 OCCURRENCE 93853 -## # ℹ 25 more rows -``` - -# select -Use `select()` to choose which columns are returned when downloading records. - - - - -``` r -Return columns 'kingdom', 'eventDate' & `species` only -occurrences <- galah_call() |> - identify("reptilia") |> - filter(year == 1930) |> - select(kingdom, species, eventDate) |> - atlas_occurrences() - -occurrences |> head() -``` - -``` -## # A tibble: 6 × 3 -## kingdom species eventDate -## -## 1 Animalia Drysdalia coronoides 1930-06-16 00:00:00 -## 2 Animalia Antaresia maculosa 1930-01-01 00:00:00 -## 3 Animalia NA 1930-04-23 00:00:00 -## 4 Animalia Stegonotus australis 1930-01-01 00:00:00 -## 5 Animalia Oxyuranus scutellatus 1930-01-01 00:00:00 -## 6 Animalia Lerista wilkinsi 1930-01-01 00:00:00 -``` - -You can also use other `{dplyr}` functions that work *within* `dplyr::select()`. - - -``` r -occurrences <- galah_call() |> - identify("reptilia") |> - filter(year == 1930) |> - select(starts_with("accepted") | ends_with("record")) |> - atlas_occurrences() -``` - -``` -## Retrying in 1 seconds. -``` - -``` r -occurrences |> head() -``` - -``` -## # A tibble: 6 × 6 -## acceptedNameUsage acceptedNameUsageID basisOfRecord raw_basisOfRecord OCCURRENCE_STATUS_INFE…¹ userDuplicateRecord -## -## 1 NA HUMAN_OBSERVATION HumanObservation FALSE FALSE -## 2 NA PRESERVED_SPECIMEN PreservedSpecimen FALSE FALSE -## 3 NA PRESERVED_SPECIMEN PreservedSpecimen FALSE FALSE -## 4 NA HUMAN_OBSERVATION HumanObservation FALSE FALSE -## 5 NA PRESERVED_SPECIMEN PreservedSpecimen FALSE FALSE -## 6 NA PRESERVED_SPECIMEN PreservedSpecimen FALSE FALSE -## # ℹ abbreviated name: ¹​OCCURRENCE_STATUS_INFERRED_FROM_BASIS_OF_RECORD -``` - -# geolocate -Use `geolocate()` to specify a geographic area or region to limit your search. - - -``` r -# Get list of perameles species in area specified: -# (Note: This can also be specified by a shapefile) -wkt <- "POLYGON((131.36328125 -22.506468769126,135.23046875 -23.396716654542,134.17578125 -27.287832521411,127.40820312499 -26.661206402316,128.111328125 -21.037340349154,131.36328125 -22.506468769126))" - -galah_call() |> - identify("perameles") |> - geolocate(wkt) |> - atlas_species() -``` - -``` -## # A tibble: 1 × 11 -## taxon_concept_id species_name scientific_name_auth…¹ taxon_rank kingdom phylum class order family genus vernacular_name -## -## 1 https://biodive… Perameles e… Spencer, 1897 species Animal… Chord… Mamm… Pera… Peram… Pera… Desert Bandico… -## # ℹ abbreviated name: ¹​scientific_name_authorship -``` - -`geolocate()` also accepts shapefiles. More complex shapefiles may need to -be simplified first (e.g., using [`rmapshaper::ms_simplify()`](https://andyteucher.ca/rmapshaper/reference/ms_simplify.html)) diff --git a/vignettes/narrow_your_results.Rmd.orig b/vignettes/narrow_your_results.Rmd.orig deleted file mode 100644 index 018e47cf..00000000 --- a/vignettes/narrow_your_results.Rmd.orig +++ /dev/null @@ -1,208 +0,0 @@ ---- -title: "Narrow your results" -author: "Martin Westgate & Dax Kellie" -date: '`r Sys.Date()`' -output: html_document -editor_options: - chunk_output_type: inline -vignette: > - %\VignetteIndexEntry{Narrow your results} - %\VignetteEncoding{UTF-8} - %\VignetteEngine{knitr::rmarkdown} ---- - -```{r, include = FALSE} -galah_config(atlas = "Australia", verbose = FALSE) -``` - -Each occurrence record contains taxonomic information and -information about the observation itself, like its location and the date -of observation. These pieces of information are recorded and categorised into -respective **fields**. When you import data using galah, columns of the -resulting `tibble` correspond to these fields. - -Data fields are important because they provide a means to narrow and refine -queries to return only the information that you need, and no more. Consequently, -much of the architecture of galah has been designed to make narrowing as simple -as possible. For legacy reasons, there are both `dplyr`-style verbs and galah- -specific versions of these functions; but they are largely synonymous. They -include: - -- `identify()` or `galah_identify()` -- `filter()` or `galah_filter()` -- `select()` or `galah_select()` -- `group_by()` or `galah_group_by()` -- `geolocate()` or `galah_geolocate()` - -Below we discuss each of these functions in turn. - -# `search_taxa` & `identify` -Perhaps unsurprisingly, `search_taxa()` searches for taxonomic information. -`search_taxa()` uses fuzzy-matching to work a lot like the search bar on the -[Atlas of Living Australia website](https://www.ala.org.au/), -and you can use it to search for taxa by their scientific name. - -Finding your desired taxon with `search_taxa()` is an important step to using -this taxonomic information to download data. For example, to search for -reptiles, we first need to identify whether we have the correct query: - -```{r} -search_taxa("Reptilia") -``` - -If we want to be more specific, we can provide a `tibble` (or `data.frame`) -providing additional taxonomic information. - -```{r} -search_taxa(tibble(genus = "Eolophus", kingdom = "Aves")) -``` - -Once we know that our search matches the correct taxon or taxa, we -can use `identify()` to narrow the results of our query. -```{r} -galah_call() |> - identify("Reptilia") |> - atlas_counts() -``` - -If you're using an international atlas, `search_taxa()` will automatically -switch to using the local name-matching service. For example, Portugal uses the -GBIF taxonomic backbone, but integrates seamlessly with our standard workflow. - -```{r} -galah_config(atlas = "Portugal") - -galah_call() |> - identify("Lepus") |> - group_by(species) |> - atlas_counts() -``` - -Conversely, the UK's [National Biodiversity Network](https://nbn.org.uk) (NBN), -has its own taxonomic backbone, but is supported using the same function call. - -```{r} -galah_config(atlas = "United Kingdom") - -galah_call() |> - filter(genus == "Bufo") |> - group_by(species) |> - atlas_counts() -``` - -# filter -Perhaps the most important function in galah is `filter()`, which is used -to filter the rows of queries. - -```{r} -galah_config(atlas = "Australia") - -# Get total record count since 2000 -galah_call() |> - filter(year > 2000) |> - atlas_counts() - - -# Get total record count for iNaturalist in 2021 -galah_call() |> - filter( - year > 2000, - dataResourceName == "iNaturalist Australia") |> - atlas_counts() -``` - -To find available fields and corresponding valid values, use the field lookup -functions `show_all(fields)`, `search_all(fields)` & `show_values()`. - -`galah_filter()` can also be used to make more complex taxonomic -queries than are possible using `search_taxa()`. By using the `taxonConceptID` -field, it is possible to build queries that exclude certain taxa, for example. -This can be useful to filter for paraphyletic concepts such as invertebrates. - -```{r invert-filter} -galah_call() |> - filter( - taxonConceptID == search_taxa("Animalia")$taxon_concept_id, - taxonConceptID != search_taxa("Chordata")$taxon_concept_id - ) |> - group_by(class) |> - atlas_counts() -``` - -In addition to single filters, some atlases (currently Australia, Sweden & -Spain) also support 'data profiles'. These are effectively pre-formed sets of -filters that are designed to remove records that are suspect in some way. This -feature has its' own function, `apply_profile()`: - -```{r} -galah_call() |> - filter(year > 2000) |> - apply_profile(ALA) |> - atlas_counts() -``` - -To see a full list of data profiles, use `show_all(profiles)`. - -# group_by -Use `group_by()` to group and summarise record counts by specified fields. - -```{r} -# Get record counts since 2010, grouped by year and basis of record -galah_call() |> - filter(year > 2015 & year <= 2020) |> - group_by(year, basisOfRecord) |> - atlas_counts() -``` - -# select -Use `select()` to choose which columns are returned when downloading records. - -```{r include = FALSE} -galah_config(email = "ala4r@ala.org.au", - atlas = "Australia", - caching = FALSE, - verbose = FALSE) -galah_config(download_reason_id = "testing") -# fixme: bug preventing this section executing in a single call -# `download_reason_id = testing` is only valid _after_ atlas has been reset -``` - -```{r} -Return columns 'kingdom', 'eventDate' & `species` only -occurrences <- galah_call() |> - identify("reptilia") |> - filter(year == 1930) |> - select(kingdom, species, eventDate) |> - atlas_occurrences() - -occurrences |> head() -``` - -You can also use other `{dplyr}` functions that work *within* `dplyr::select()`. - -```{r} -occurrences <- galah_call() |> - identify("reptilia") |> - filter(year == 1930) |> - select(starts_with("accepted") | ends_with("record")) |> - atlas_occurrences() - -occurrences |> head() -``` - -# geolocate -Use `geolocate()` to specify a geographic area or region to limit your search. - -```{r} -# Get list of perameles species in area specified: -# (Note: This can also be specified by a shapefile) -wkt <- "POLYGON((131.36328125 -22.506468769126,135.23046875 -23.396716654542,134.17578125 -27.287832521411,127.40820312499 -26.661206402316,128.111328125 -21.037340349154,131.36328125 -22.506468769126))" - -galah_call() |> - identify("perameles") |> - geolocate(wkt) |> - atlas_species() -``` - -`geolocate()` also accepts shapefiles. More complex shapefiles may need to -be simplified first (e.g., using [`rmapshaper::ms_simplify()`](https://andyteucher.ca/rmapshaper/reference/ms_simplify.html)) \ No newline at end of file diff --git a/vignettes/precompile.R b/vignettes/precompile.R index 635b9608..c8b53b06 100644 --- a/vignettes/precompile.R +++ b/vignettes/precompile.R @@ -6,37 +6,6 @@ library(knitr) library(dplyr) galah_config(email = "ala4r@ala.org.au") # add your email -# get data on atlases for the `choosing an atlas` vignette -atlases <- show_all(atlases) -counts <- map(atlases$region, - function(x){ - galah_config(atlas = x) - atlas_counts() - }) -services <- show_all(apis) |> - filter(type != "files/media") |> - bind_rows( # add cached gbif data - tibble( - atlas = "Global", - type = c("data/species", "metadata/fields", "metadata/assertions"), - url = NA), - tibble( - atlas = c("Austria", "Australia", "Brazil", "Guatemala", "Portugal", "Spain", "Sweden", "United Kingdom"), - type = "files/media", - url = NA)) |> - group_by(atlas) |> - summarize(n_services = n()) - - -atlases_csv <- atlases |> - select(region, institution) |> - bind_cols(bind_rows(counts)) |> - left_join(services, by = c("region" = "atlas")) |> - arrange(desc(count)) - -write.csv(atlases_csv, "./vignettes/atlas_stats.csv", row.names = FALSE) - - # workflow for automated detection and processing of vignettes folder <- "./vignettes/" all_files <- list.files(folder) diff --git a/vignettes/quick_start_guide.Rmd b/vignettes/quick_start_guide.Rmd index 0d735e0b..168db27c 100644 --- a/vignettes/quick_start_guide.Rmd +++ b/vignettes/quick_start_guide.Rmd @@ -1,7 +1,7 @@ --- title: "Quick start guide" author: "Martin Westgate & Dax Kellie" -date: '2024-11-19' +date: '2026-01-08' output: rmarkdown::html_vignette vignette: > @@ -9,7 +9,7 @@ vignette: > %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- -galah is an R interface to biodiversity data hosted by the Global Biodiversity +`galah` is an R interface to biodiversity data hosted by the Global Biodiversity Information Facility ([GBIF](https://www.gbif.org)) and its subsidiary node organisations. GBIF and its partner nodes collate and store observations of individual life forms using the ['Darwin Core'](https://dwc.tdwg.org) data @@ -37,38 +37,8 @@ library(galah) ``` # Configuration -By default, galah downloads information from the Atlas of Living Australia -(ALA). To show the full list of organisations currently supported by galah, -use `show_all(atlases)`. - - -``` r -show_all(atlases) -``` - -``` -## # A tibble: 10 × 4 -## region institution acronym url -## -## 1 Australia Atlas of Living Australia ALA https://www.ala.org.au -## 2 Austria Biodiversitäts-Atlas Österreich BAO https://biodiversityat… -## 3 Brazil Sistemas de Informações sobre a Biodiversidade Brasileira SiBBr https://sibbr.gov.br -## 4 France Portail français d'accès aux données d'observation sur les espèces OpenObs https://openobs.mnhn.fr -## 5 Global Global Biodiversity Information Facility GBIF https://gbif.org -## 6 Guatemala Sistema Nacional de Información sobre Diversidad Biológica de Guatemala SNIBgt https://snib.conap.gob… -## 7 Portugal GBIF Portugal GBIF.pt https://www.gbif.pt -## 8 Spain GBIF Spain GBIF.es https://gbif.es -## 9 Sweden Swedish Biodiversity Data Infrastructure SBDI https://biodiversityda… -## 10 United Kingdom National Biodiversity Network NBN https://nbn.org.uk -``` - -Use `galah_config()` to set the node organisation using its region, name, or -acronym. Once set, `galah` will automatically populate the server configuration for your -selected GBIF node. To download occurrence records from your chosen -GBIF node, you will need to register an account with them (using their website), -then provide your registration email to galah. -To download from GBIF, you will need to provide the email, username, and -password. +Begin by choosing which organisation you would like `galah` to query, +and providing your registration information for that organisation. ``` r @@ -77,149 +47,251 @@ galah_config(atlas = "GBIF", email = "email@email.com", password = "my_password") ``` -You can find a full list of configuration options by running `?galah_config`. -# Basic syntax -The standard method to construct queries in `{galah}` is via piped functions. -Pipes in `galah` start with the `galah_call()` function, and typically end with -`collect()`, though `collapse()` and `compute()` are also supported. The -development team use the base pipe by default (`|>`), but the `{magrittr}` pipe -(`%>%`) should work too. - +The full list of supported queries by organisation is as follows: + +

      +Organisations supported by galah +

      Organisations supported by galah

      +
      + +# Getting data +`galah` is a `dplyr` extension package; rather than using pipes to amend +a `tibble` in your workspace, you amend a query, which is then sent to your +chosen organisation. These pipes differ from traditional syntax in two ways: + +- they begin with a function - usually `galah_call()` - instead of a `tibble` +- they end with one of `dplyr`'s evaluation functions, usually `collect()` + +So an example query might be to find the number of records per year: + ``` r -galah_config(atlas = "ALA", - verbose = FALSE) -galah_call() |> - count() |> - collect() +galah_config(atlas = "Australia") + +galah_call() |> # open a pipe + filter(year >= 2020) |> # choose rows to keep + count(year) |> # count the number of rows + collect() # retrieve query from the server ``` ``` -## # A tibble: 1 × 1 -## count -## -## 1 146185520 +## # A tibble: 7 × 2 +## year count +## +## 1 2024 11770412 +## 2 2023 10974292 +## 3 2022 9422565 +## 4 2025 8989765 +## 5 2021 8686097 +## 6 2020 7308421 +## 7 2026 26191 ``` -To pass more complex queries, you can use additional `{dplyr}` functions such as -`filter()`, `select()`, and `group_by()`. +Or to find the number of categories present in a dataset, for example how many +species are present: ``` r -galah_call() |> - filter(year >= 2020) |> +galah_call() |> + identify("Crinia") |> # filters by taxonomic names + distinct(speciesID) |> # keep only unique values count() |> collect() ``` ``` ## # A tibble: 1 × 1 -## count -## -## 1 40200358 +## count +## +## 1 17 ``` -Each GBIF node allows you to query using their own set of in-built fields. You -can investigate which fields are available using `show_all()` and `search_all()`: +Or to download the records themselves: + + ``` r -search_all(fields, "australian states") +galah_call() |> + identify("Eolophus roseicapilla") |> + filter(year == 2010) |> + select(eventDate, decimalLatitude, species) |> # choose columns to keep + collect() ``` ``` -## # A tibble: 2 × 3 -## id description type -## -## 1 cl2013 ASGS Australian States and Territories fields -## 2 cl22 Australian States and Territories fields +## -- ``` -# Taxonomic searches -To narrow your search to a particular taxonomic group, use `identify()`. Note -that this function only accepts scientific names and is not case sensitive. -It's good practice to first use `search_taxa()` to check that the taxa you -provide returns the correct taxonomic results. +``` +## # A tibble: 21,984 × 3 +## eventDate decimalLatitude species +## +## 1 NA -38.2 Eolophus roseicapilla +## 2 NA -38.2 Eolophus roseicapilla +## 3 NA -37.0 Eolophus roseicapilla +## 4 NA -37.7 Eolophus roseicapilla +## 5 NA -35.6 Eolophus roseicapilla +## 6 NA -31.1 Eolophus roseicapilla +## 7 NA -38.2 Eolophus roseicapilla +## 8 NA -36.8 Eolophus roseicapilla +## 9 NA -38.3 Eolophus roseicapilla +## 10 NA -36.8 Eolophus roseicapilla +## # ℹ 21,974 more rows +``` + +This works because many of the functions in `dplyr` are "generic", meaning +it is possible to write extensions that apply them to new object classes. +In our case, `galah_call()` creates a new object class called a +`data_request` for which we have written new extensions. This means that galah +will not interfere with your use of `filter()` and friends on your tibbles. +The full list of supported `dplyr` extensions is: + +- `arrange.data_request()` +- `count.data_request()` +- `distinct.data_request()` +- `filter.data_request()` +- `group_by.data_request()` +- `select.data_request()` +- `slice_head.data_request()` + +Additional verbs are: + +- `apply_profile()` +- `geolocate()` or `st_crop.data_request()` +- `identify.data_request()` +- `unnest()` + +It is good practice to download your data in as few steps as possible, +to minimize impacts on the server, and to ensure you can get a single +DOI for your data. See the +[download data reproducibly](download-data-reproducibly.html) vignette +for details. + +# Finding information + +Building queries using `filter()` requires that you know two things: + +- what **fields** (columns) are present in the dataset you are searching +- what **values** exist for those fields + +Finding this information requires looking for metadata: ``` r -search_taxa("reptilia") # Check whether taxonomic info is correct +request_metadata() |> + collect() ``` ``` -## # A tibble: 1 × 9 -## search_term scientific_name taxon_concept_id rank match_type kingdom phylum class issues -## -## 1 reptilia REPTILIA https://biodiversity.org.au/afd/taxa/682e1228… class exactMatch Animal… Chord… Rept… noIss… +## # A tibble: 639 × 3 +## id description type +## +## 1 abcdTypeStatus fields +## 2 acceptedNameUsage Accepted name fields +## 3 acceptedNameUsageID Accepted name fields +## 4 accessRights Access rights fields +## 5 annotationsDoi fields +## 6 annotationsUid Referenced by publication fields +## 7 assertionUserId Assertions by user fields +## 8 assertions Record issues fields +## 9 assertionsCount fields +## 10 associatedMedia Associated Media fields +## # ℹ 629 more rows ``` +You can browser this tibble using `View()` or search it using `filter()`. +Once you have found a field that you want to include in your query, you +can find values for that field using `unnest()`: + + ``` r -galah_call() |> - identify("reptilia") |> - filter(year >= 2020) |> - count() |> +request_metadata() |> + filter(fields == "cl22") |> + unnest() |> collect() ``` ``` -## # A tibble: 1 × 1 -## count -## -## 1 338434 +## # A tibble: 11 × 1 +## cl22 +## +## 1 New South Wales +## 2 Victoria +## 3 Queensland +## 4 South Australia +## 5 Western Australia +## 6 Northern Territory +## 7 Tasmania +## 8 Australian Capital Territory +## 9 Macquarie Island +## 10 Coral Sea Islands +## 11 Ashmore and Cartier Islands ``` -If you want to query something other than the number of records, modify the -`type` argument in `galah_call()`. Here we'll query the number of species: +Different types of metadata are available; see `?request_metadata` for +a full list. + +# Wrapper functions + +While `dplyr` syntax is very flexible, there are cases where it is easier +to simply say the sort of data you want, rather than create a full database +query to implement it. For this reason, several common use cases have +their own wrapper functions. + +The `atlas_` family of functions act like `collect()`, but enforce +a particular type of data to be returned, such as record counts: ``` r -galah_call(type = "species") |> - identify("reptilia") |> - filter(year >= 2020) |> - count() |> - collect() +galah_call() |> + filter(year == 2025) |> + atlas_counts() # note no need for a `count()` function ``` ``` ## # A tibble: 1 × 1 -## count -## -## 1 883 +## count +## +## 1 8989765 ``` -# Download -To download records---rather than find how many records are available---simply -remove the `count()` function from your pipe. +Or occurrences: ``` r -result <- galah_call() |> - identify("Litoria") |> - filter(year >= 2020, cl22 == "Tasmania") |> - select(basisOfRecord, group = "basic") |> - collect() +galah_call() |> + identify("Eolophus roseicapilla") |> + filter(year == 2000, + cl22 == "Australian Capital Territory") |> + atlas_occurrences() |> + print(n = 6) ``` ``` -## Retrying in 1 seconds. +## ----- ``` -``` r -result |> head() ``` - -``` -## # A tibble: 6 × 9 -## recordID scientificName taxonConceptID decimalLatitude decimalLongitude eventDate occurrenceStatus -## -## 1 00052544-d943-42e9… Litoria ewing… https://biodi… -42.9 147. 2022-09-19 00:00:00 PRESENT -## 2 00168ca6-84d0-4af1… Litoria ranif… https://biodi… -41.2 146. 2023-12-21 10:20:19 PRESENT -## 3 001a43fe-8586-4064… Litoria ewing… https://biodi… -43.0 147. 2021-08-07 00:00:00 PRESENT -## 4 00250163-ec50-4eda… Litoria ranif… https://biodi… -41.2 147. 2023-08-23 11:49:28 PRESENT -## 5 003e0f63-9f95-4af9… Litoria ewing… https://biodi… -42.9 148. 2022-12-24 06:27:00 PRESENT -## 6 0070521f-bb45-46fb… Litoria ewing… https://biodi… -43.1 147. 2023-12-20 14:29:23 PRESENT -## # ℹ 2 more variables: dataResourceName , basisOfRecord +## # A tibble: 2,032 × 9 +## recordID scientificName taxonConceptID decimalLatitude decimalLongitude eventDate basisOfRecord occurrenceStatus dataResourceName +## +## 1 0026d29f-b6ab-4a1d-9c57-6ee12cfde3a0 Eolophus roseicapilla https://biodiversity.org.au/afd/taxa/9b4ad548-8bb3-486a-ab0a-905506c463ea -35.4 149. 2000-08-07 00:00:00 HUMAN_OBSERVATION PRESENT Garden Bird Surveys +## 2 0062d446-007b-4164-ac4f-ae84297e2578 Eolophus roseicapilla https://biodiversity.org.au/afd/taxa/9b4ad548-8bb3-486a-ab0a-905506c463ea -35.3 149. 2000-03-10 00:00:00 HUMAN_OBSERVATION PRESENT BirdLife Australia, Birdata +## 3 00a62ee0-1e08-4114-b0d8-9b7905472d53 Eolophus roseicapilla https://biodiversity.org.au/afd/taxa/9b4ad548-8bb3-486a-ab0a-905506c463ea -35.2 149. 2000-01-29 00:00:00 HUMAN_OBSERVATION PRESENT Garden Bird Surveys +## 4 00ab2f4d-326f-4b01-9a8a-1a10c1f77e3c Eolophus roseicapilla https://biodiversity.org.au/afd/taxa/9b4ad548-8bb3-486a-ab0a-905506c463ea -35.4 149. 2000-09-25 00:00:00 HUMAN_OBSERVATION PRESENT Garden Bird Surveys +## 5 00ae4631-ea59-44ec-b8f8-4377b6b3b3ef Eolophus roseicapilla https://biodiversity.org.au/afd/taxa/9b4ad548-8bb3-486a-ab0a-905506c463ea -35.3 149. 2000-02-12 00:00:00 HUMAN_OBSERVATION PRESENT BirdLife Australia, Birdata +## 6 00b6c8ec-e7b9-4d9f-9638-d962b1b4acfa Eolophus roseicapilla https://biodiversity.org.au/afd/taxa/9b4ad548-8bb3-486a-ab0a-905506c463ea -35.2 149. 2000-02-05 00:00:00 HUMAN_OBSERVATION PRESENT Garden Bird Surveys +## # ℹ 2,026 more rows ``` -Check out our other vignettes for more detail on how to use these functions. +`atlas_species()` replaces the need for `distinct()` call, while `atlas_media()` +is a shortcut to a complex workflow that incorporates both data and metadata +calls; see the [downloading images and sounds](downloading_images_and_sounds.html) +vignette for details. + +Finally, metadata calls can be made more efficiently using the `show_all()` +and `show_values()` functions; see the +[look up information](lookup_up_information.html) vignette for details. + diff --git a/vignettes/quick_start_guide.Rmd.orig b/vignettes/quick_start_guide.Rmd.orig index 427fbf9a..88b5607c 100644 --- a/vignettes/quick_start_guide.Rmd.orig +++ b/vignettes/quick_start_guide.Rmd.orig @@ -9,7 +9,7 @@ vignette: > %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- -galah is an R interface to biodiversity data hosted by the Global Biodiversity +`galah` is an R interface to biodiversity data hosted by the Global Biodiversity Information Facility ([GBIF](https://www.gbif.org)) and its subsidiary node organisations. GBIF and its partner nodes collate and store observations of individual life forms using the ['Darwin Core'](https://dwc.tdwg.org) data @@ -18,118 +18,172 @@ standard. # Installation To install from CRAN: -```{r eval=FALSE} +```{r, cran-install, eval=FALSE} install.packages("galah") ``` Or install the development version from GitHub: -```{r eval=FALSE} +```{r, remotes-install, eval=FALSE} install.packages("remotes") remotes::install_github("AtlasOfLivingAustralia/galah") ``` Load the package -```{r eval=FALSE} +```{r, load-package, eval=FALSE} library(galah) ``` # Configuration -By default, galah downloads information from the Atlas of Living Australia -(ALA). To show the full list of organisations currently supported by galah, -use `show_all(atlases)`. +Begin by choosing which organisation you would like `galah` to query, +and providing your registration information for that organisation. -```{r} -show_all(atlases) -``` - -Use `galah_config()` to set the node organisation using its region, name, or -acronym. Once set, `galah` will automatically populate the server configuration for your -selected GBIF node. To download occurrence records from your chosen -GBIF node, you will need to register an account with them (using their website), -then provide your registration email to galah. -To download from GBIF, you will need to provide the email, username, and -password. - -```{r, eval=FALSE} +```{r, galah-config, eval=FALSE} galah_config(atlas = "GBIF", username = "user1", email = "email@email.com", password = "my_password") ``` -You can find a full list of configuration options by running `?galah_config`. - -# Basic syntax -The standard method to construct queries in `{galah}` is via piped functions. -Pipes in `galah` start with the `galah_call()` function, and typically end with -`collect()`, though `collapse()` and `compute()` are also supported. The -development team use the base pipe by default (`|>`), but the `{magrittr}` pipe -(`%>%`) should work too. - -```{r} -galah_config(atlas = "ALA", - verbose = FALSE) + +The full list of supported queries by organisation is as follows: + +```{r, atlas-support, echo = FALSE, out.width = "100%", fig.cap="Organisations supported by galah"} +knitr::include_graphics('../man/figures/atlases_plot.png') +``` + +# Getting data +`galah` is a `dplyr` extension package; rather than using pipes to amend +a `tibble` in your workspace, you amend a query, which is then sent to your +chosen organisation. These pipes differ from traditional syntax in two ways: + +- they begin with a function - usually `galah_call()` - instead of a `tibble` +- they end with one of `dplyr`'s evaluation functions, usually `collect()` + +So an example query might be to find the number of records per year: + +```{r, count-example} +galah_config(atlas = "Australia") + +galah_call() |> # open a pipe + filter(year >= 2020) |> # choose rows to keep + count(year) |> # count the number of rows + collect() # retrieve query from the server +``` + +Or to find the number of categories present in a dataset, for example how many +species are present: + +```{r, distinct-example} galah_call() |> + identify("Crinia") |> # filters by taxonomic names + distinct(speciesID) |> # keep only unique values count() |> collect() ``` -To pass more complex queries, you can use additional `{dplyr}` functions such as -`filter()`, `select()`, and `group_by()`. +Or to download the records themselves: -```{r} -galah_call() |> - filter(year >= 2020) |> - count() |> +```{r, cache-email, echo = FALSE} +galah_config(email = "ala4r@ala.org.au") +``` + +```{r, occurrence-example, message = FALSE} +galah_call() |> + identify("Eolophus roseicapilla") |> + filter(year == 2010) |> + select(eventDate, decimalLatitude, species) |> # choose columns to keep collect() ``` -Each GBIF node allows you to query using their own set of in-built fields. You -can investigate which fields are available using `show_all()` and `search_all()`: +This works because many of the functions in `dplyr` are "generic", meaning +it is possible to write extensions that apply them to new object classes. +In our case, `galah_call()` creates a new object class called a +`data_request` for which we have written new extensions. This means that galah +will not interfere with your use of `filter()` and friends on your tibbles. +The full list of supported `dplyr` extensions is: -```{r} -search_all(fields, "australian states") -``` +- `arrange.data_request()` +- `count.data_request()` +- `distinct.data_request()` +- `filter.data_request()` +- `group_by.data_request()` +- `select.data_request()` +- `slice_head.data_request()` -# Taxonomic searches -To narrow your search to a particular taxonomic group, use `identify()`. Note -that this function only accepts scientific names and is not case sensitive. -It's good practice to first use `search_taxa()` to check that the taxa you -provide returns the correct taxonomic results. +Additional verbs are: -```{r} -search_taxa("reptilia") # Check whether taxonomic info is correct +- `apply_profile()` +- `geolocate()` or `st_crop.data_request()` +- `identify.data_request()` +- `unnest()` -galah_call() |> - identify("reptilia") |> - filter(year >= 2020) |> - count() |> +It is good practice to download your data in as few steps as possible, +to minimize impacts on the server, and to ensure you can get a single +DOI for your data. See the +[download data reproducibly](download-data-reproducibly.html) vignette +for details. + +# Finding information + +Building queries using `filter()` requires that you know two things: + +- what **fields** (columns) are present in the dataset you are searching +- what **values** exist for those fields + +Finding this information requires looking for metadata: + +```{r, metadata-fields} +request_metadata() |> collect() ``` -If you want to query something other than the number of records, modify the -`type` argument in `galah_call()`. Here we'll query the number of species: +You can browser this tibble using `View()` or search it using `filter()`. +Once you have found a field that you want to include in your query, you +can find values for that field using `unnest()`: -```{r} -galah_call(type = "species") |> - identify("reptilia") |> - filter(year >= 2020) |> - count() |> +```{r, metadata-fields-unnest} +request_metadata() |> + filter(fields == "cl22") |> + unnest() |> collect() ``` -# Download -To download records---rather than find how many records are available---simply -remove the `count()` function from your pipe. +Different types of metadata are available; see `?request_metadata` for +a full list. -```{r} -result <- galah_call() |> - identify("Litoria") |> - filter(year >= 2020, cl22 == "Tasmania") |> - select(basisOfRecord, group = "basic") |> - collect() +# Wrapper functions -result |> head() +While `dplyr` syntax is very flexible, there are cases where it is easier +to simply say the sort of data you want, rather than create a full database +query to implement it. For this reason, several common use cases have +their own wrapper functions. + +The `atlas_` family of functions act like `collect()`, but enforce +a particular type of data to be returned, such as record counts: + +```{r, atlas-counts} +galah_call() |> + filter(year == 2025) |> + atlas_counts() # note no need for a `count()` function ``` -Check out our other vignettes for more detail on how to use these functions. \ No newline at end of file +Or occurrences: + +```{r, atlas-occurrences, message = FALSE} +galah_call() |> + identify("Eolophus roseicapilla") |> + filter(year == 2000, + cl22 == "Australian Capital Territory") |> + atlas_occurrences() |> + print(n = 6) +``` + +`atlas_species()` replaces the need for `distinct()` call, while `atlas_media()` +is a shortcut to a complex workflow that incorporates both data and metadata +calls; see the [downloading images and sounds](downloading_images_and_sounds.html) +vignette for details. + +Finally, metadata calls can be made more efficiently using the `show_all()` +and `show_values()` functions; see the +[look up information](lookup_up_information.html) vignette for details. + From 4eafab82beed5d51347705476ac9dfb8d5a1f297 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Thu, 8 Jan 2026 16:56:11 +1100 Subject: [PATCH 60/94] make `galah_call()` synonymous with `request_data()` --- NEWS.md | 3 +- R/galah_call.R | 51 ++++++++++---------------------- man/galah_call.Rd | 32 +++++++++----------- tests/testthat/test-galah_call.R | 25 ++++++++-------- 4 files changed, 43 insertions(+), 68 deletions(-) diff --git a/NEWS.md b/NEWS.md index 3146e3cb..69617c74 100644 --- a/NEWS.md +++ b/NEWS.md @@ -6,9 +6,10 @@ * Kew gardens and Flanders living atlases added * authentication supported for ALA users within `galah_config()` -### New functions +### New & amended functions * `dplyr::distinct()` can be used to find grouped data and summaries, generalising `atlas_species()` * new functions `as_query()` and `coalesce()` as prequels to `collapse()` +* `galah_call()` is now synonmous with `request_data()` rather than wrapping all `request_` functions; `method` argument is removed. ## Changes to metadata functions * all metadata requests now accept `select()` diff --git a/R/galah_call.R b/R/galah_call.R index afbf1ffd..28e7d31a 100644 --- a/R/galah_call.R +++ b/R/galah_call.R @@ -5,30 +5,24 @@ #' query tells the atlas API what data to download and return, as well as how it #' should be filtered. Using [galah_call()] allows you to build a piped query to #' download data, in the same way that you would wrangle data with `dplyr` and -#' the `tidyverse`. -#' @param method string: what `request` function should be called. Should be one -#' of `"data"` (default), `"metadata"` or `"files"` +#' the `tidyverse`. It is synonymous with [request_data()]; to query other +#' data types call [request_metadata()] or [request_files()]. #' @param type string: what form of data should be returned? Acceptable values #' are specified by the corresponding `request` function #' @details -#' [galah_call()] is a wrapper to a group of underlying -#' `request_` functions, selected using the `method` argument. -#' Each of these functions can begin a piped query, which is then actioned using +#' [galah_call()] and any of the `request_` functions are used to begin a +#' piped query, which is then actioned using #' \code{\link[=collect.data_request]{collect()}}, or optionally one of the -#' \code{\link[=atlas_occurrences]{atlas_}} family of functions. For more -#' details see the object-oriented programming vignette: -#' \code{vignette("object_oriented_programming", package = "galah")} +#' \code{\link[=atlas_occurrences]{atlas_}} family of functions. #' -#' Accepted values of the `type` argument are set by the underlying `request_` -#' functions. These functions are useful because they allow `galah` -#' to separate different types of requests to perform better. For example, -#' \code{\link[=filter.data_request]{filter.data_request()}} translates filters +#' Having distinct functions for different types of request is useful because +#' it allows `galah` to separate different types of requests to perform better. +#' For example, [filter.data_request()]translates filters #' to `solr` syntax for the living atlases, or to predicates for GBIF, whereas -#' \code{\link[=filter.metadata_request]{filter.metadata_request()}} adds a -#' search term to your query. +#' [filter.metadata_request()] adds a search term to your metadata query. #' @return Each sub-function returns a different object class: #' -#' - [request_data()] returns class `"data_request"` +#' - [request_data()] and [galah_call()] return class `"data_request"` #' - [request_metadata()] returns class `"metadata_request"` #' - [request_files()] returns class `"files_request"` #' @@ -77,26 +71,7 @@ #' collect() #' } #' @export -galah_call <- function(method = c("data", - "metadata", - "files"), - type){ - method <- match.arg(method) - if(missing(type)){ - type <- switch(method, - "data" = "occurrences", - "metadata" = "fields", - "files" = "media") - } - switch(method, - "data" = request_data(type = type), - "metadata" = request_metadata(type = type), - "files" = request_files(type = type)) -} - -#' @rdname galah_call -#' @export -request_data <- function(type = c("occurrences", +galah_call <- function(type = c("occurrences", "occurrences-count", "occurrences-doi", # "distributions", @@ -112,6 +87,10 @@ request_data <- function(type = c("occurrences", structure(class = "data_request") } +#' @rdname galah_call +#' @export +request_data <- galah_call + #' @rdname galah_call #' @export request_metadata <- function(type = c("fields", diff --git a/man/galah_call.Rd b/man/galah_call.Rd index 4dc83365..9bed7bce 100644 --- a/man/galah_call.Rd +++ b/man/galah_call.Rd @@ -7,7 +7,10 @@ \alias{request_files} \title{Start building a request} \usage{ -galah_call(method = c("data", "metadata", "files"), type) +galah_call( + type = c("occurrences", "occurrences-count", "occurrences-doi", "species", + "species-count") +) request_data( type = c("occurrences", "occurrences-count", "occurrences-doi", "species", @@ -23,16 +26,13 @@ request_metadata( request_files(type = "media") } \arguments{ -\item{method}{string: what \code{request} function should be called. Should be one -of \code{"data"} (default), \code{"metadata"} or \code{"files"}} - \item{type}{string: what form of data should be returned? Acceptable values are specified by the corresponding \code{request} function} } \value{ Each sub-function returns a different object class: \itemize{ -\item \code{\link[=request_data]{request_data()}} returns class \code{"data_request"} +\item \code{\link[=request_data]{request_data()}} and \code{\link[=galah_call]{galah_call()}} return class \code{"data_request"} \item \code{\link[=request_metadata]{request_metadata()}} returns class \code{"metadata_request"} \item \code{\link[=request_files]{request_files()}} returns class \code{"files_request"} } @@ -45,24 +45,20 @@ To download data from the selected atlas, one must construct a query. This query tells the atlas API what data to download and return, as well as how it should be filtered. Using \code{\link[=galah_call]{galah_call()}} allows you to build a piped query to download data, in the same way that you would wrangle data with \code{dplyr} and -the \code{tidyverse}. +the \code{tidyverse}. It is synonymous with \code{\link[=request_data]{request_data()}}; to query other +data types call \code{\link[=request_metadata]{request_metadata()}} or \code{\link[=request_files]{request_files()}}. } \details{ -\code{\link[=galah_call]{galah_call()}} is a wrapper to a group of underlying -\code{request_} functions, selected using the \code{method} argument. -Each of these functions can begin a piped query, which is then actioned using +\code{\link[=galah_call]{galah_call()}} and any of the \code{request_} functions are used to begin a +piped query, which is then actioned using \code{\link[=collect.data_request]{collect()}}, or optionally one of the -\code{\link[=atlas_occurrences]{atlas_}} family of functions. For more -details see the object-oriented programming vignette: -\code{vignette("object_oriented_programming", package = "galah")} +\code{\link[=atlas_occurrences]{atlas_}} family of functions. -Accepted values of the \code{type} argument are set by the underlying \code{request_} -functions. These functions are useful because they allow \code{galah} -to separate different types of requests to perform better. For example, -\code{\link[=filter.data_request]{filter.data_request()}} translates filters +Having distinct functions for different types of request is useful because +it allows \code{galah} to separate different types of requests to perform better. +For example, \code{\link[=filter.data_request]{filter.data_request()}}translates filters to \code{solr} syntax for the living atlases, or to predicates for GBIF, whereas -\code{\link[=filter.metadata_request]{filter.metadata_request()}} adds a -search term to your query. +\code{\link[=filter.metadata_request]{filter.metadata_request()}} adds a search term to your metadata query. } \examples{ \dontrun{ diff --git a/tests/testthat/test-galah_call.R b/tests/testthat/test-galah_call.R index edd5ade8..91aa349b 100644 --- a/tests/testthat/test-galah_call.R +++ b/tests/testthat/test-galah_call.R @@ -1,19 +1,18 @@ -test_that("galah_call builds objects of class 'data_request' by default", { +test_that("`galah_call()` builds objects of class 'data_request' by default", { expect_equal(length(galah_call()), 1) expect_s3_class(galah_call(), "data_request") }) -test_that("galah_call accepts method arg", { - x <- galah_call(method = "metadata") +test_that("`request_` functions build correct object classes", { + x <- request_metadata() expect_s3_class(x, "metadata_request") expect_true(x$type == "fields") - y <- galah_call(method = "files") + y <- request_files() expect_s3_class(y, "files_request") expect_true(y$type == "media") - expect_error(galah_call(method = "nothing")) }) -test_that("galah_call works with all `galah_` functions", { +test_that("`galah_call()` works with all `galah_` functions", { skip_if_offline(); skip_on_ci() result <- galah_call() |> identify("Litoria") |> @@ -35,7 +34,7 @@ test_that("galah_call works with all `galah_` functions", { "geolocate", "group_by", "arrange")) }) -test_that("galah_call works irrespective of `galah_` function order", { +test_that("`galah_call()` works irrespective of `galah_` function order", { skip_if_offline(); skip_on_ci() result <- galah_call() |> galah_apply_profile(ALA) |> @@ -48,17 +47,17 @@ test_that("galah_call works irrespective of `galah_` function order", { expect_false(any(unlist(lapply(result, is.null)))) }) -test_that("repeated calls to `galah_identify` are added correctly", { +test_that("repeated calls to `identify()` are added correctly", { skip_if_offline(); skip_on_ci() result <- galah_call() |> - galah_identify("Litoria") |> - galah_identify("Aves") + identify("Litoria") |> + identify("Aves") expect_equal(nrow(result$identify), 2) }) -test_that("repeated calls to `galah_filter` are added correctly", { +test_that("repeated calls to `filter()` are added correctly", { result <- galah_call() |> - galah_filter(year >= 2010) |> - galah_filter(basisOfRecord == "human_observation", cl22 == "Tasmania") + filter(year >= 2010) |> + filter(basisOfRecord == "human_observation", cl22 == "Tasmania") expect_equal(nrow(result$filter), 3) }) From 3dd0c3b9ea71d93ee9b4d5fe0d5e93d45af883d4 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Thu, 8 Jan 2026 16:56:36 +1100 Subject: [PATCH 61/94] Consolidate OOP vignette into Quick Start Guide --- vignettes/object_oriented_programming.Rmd | 301 ------------------ .../object_oriented_programming.Rmd.orig | 230 ------------- vignettes/quick_start_guide.Rmd | 23 +- vignettes/quick_start_guide.Rmd.orig | 18 +- 4 files changed, 30 insertions(+), 542 deletions(-) delete mode 100644 vignettes/object_oriented_programming.Rmd delete mode 100644 vignettes/object_oriented_programming.Rmd.orig diff --git a/vignettes/object_oriented_programming.Rmd b/vignettes/object_oriented_programming.Rmd deleted file mode 100644 index f654f6ec..00000000 --- a/vignettes/object_oriented_programming.Rmd +++ /dev/null @@ -1,301 +0,0 @@ ---- -title: "Object-Oriented Programming" -author: "Martin Westgate & Dax Kellie" -date: '2024-11-19' -output: - rmarkdown::html_vignette -vignette: > - %\VignetteIndexEntry{Object-Oriented Programming} - %\VignetteEngine{knitr::rmarkdown} - %\VignetteEncoding{UTF-8} ---- - - -The default method for building queries in `galah` is to first use `galah_call()` -to create a query object called a "`data_request`". This object class is specific -to `galah`. - - -``` r -galah_call() |> - filter(genus == "Crinia") |> - class() -``` - -``` -## [1] "data_request" -``` - -When a piped object is of class `data_request`, galah can trigger functions to -use specific methods for this object class, even if a function name is used by -another package. For example, users can use `filter()` and `group_by()` functions -from [dplyr](https://dplyr.tidyverse.org/index.html) instead -of `galah_filter()` and `galah_group_by()` to construct a query. Consequently, -the following queries are synonymous: - - -``` r -galah_call() |> - galah_filter(genus == "Crinia", year == 2020) |> - galah_group_by(species) |> - atlas_counts() -``` - -``` r -galah_call() |> - filter(genus == "Crinia", year == 2020) |> - group_by(species) |> - atlas_counts() -``` - -``` -## # A tibble: 16 × 2 -## species count -## -## 1 Crinia signifera 42621 -## 2 Crinia parinsignifera 8664 -## 3 Crinia glauerti 3111 -## 4 Crinia georgiana 1509 -## 5 Crinia remota 718 -## 6 Crinia sloanei 682 -## 7 Crinia insignifera 530 -## 8 Crinia tinnula 291 -## 9 Crinia deserticola 253 -## 10 Crinia pseudinsignifera 223 -## 11 Crinia tasmaniensis 181 -## 12 Crinia bilingua 74 -## 13 Crinia subinsignifera 46 -## 14 Crinia riparia 10 -## 15 Crinia flindersensis 3 -## 16 Crinia nimba 1 -``` - -Thanks to object-oriented programming, galah "masks" `filter()` and `group_by()` -functions to use methods defined for `data_request` objects instead. The full -list of masked functions is: - -- `arrange()` (`{dplyr}`) -- `count()` (`{dplyr}`) -- `identify()` (`{graphics}`) as a synonym for `galah_identify()` -- `select()` (`{dplyr}`) as a synonym for `galah_select()` -- `group_by()` (`{dplyr}`) as a synonym for `galah_group_by()` -- `slice_head()` (`{dplyr}`) as a synonym for the `limit` argument in `atlas_counts()` -- `st_crop()` (`{sf}`) as a synonym for `galah_polygon()` - -Note that these functions are all evaluated lazily; they amend the underlying -object, but do not amend the nature of the data until the call is evaluated. To -actually build and run the query, we'll need to use one or more of a different -set of dplyr verbs: `collapse()`, `compute()` and `collect()`. - -## Advanced query building - -The usual way to begin a query to request data in galah is using `galah_call()`. -However, this function now calls one of three types of `request_` functions. -If you prefer, you can begin your pipe with one of these dedicated `request_` -functions (rather than `galah_call()`) depending on the type of data you -want to collect. - -For example, if you want to download occurrences, use `request_data()`: - - -``` r -x <- request_data("occurrences") |> # note that "occurrences" is the default `type` - filter(species == "Crinia tinnula", - year == 2010) |> - collect() -``` - -You'll notice that this query differs slightly from the query structure used in -earlier versions of `galah`. The desired data type, `"occurrences"`, -is specified at the beginning of the query within `request_data()` rather than -at the end using `atlas_occurrences()`. Specifying the data type at the start -allows users to make use of advanced query building using three newly -implemented stages of query building: `collapse()`, `compute()` and `collect()`. -These stages mirror existing [functions in dplyr for querying -databases](https://dplyr.tidyverse.org/reference/compute.html), and act in the -following way: - -- `collapse()` converts the object to a `query`. This allows users to inspect - their API calls before they are sent. Depending on the request, this function - may also call 'supplementary' APIs to collect required information, - such as Taxon Concept Identifiers or field names. -- `compute()` is intended to send the query in question to the requested API - for processing. This is particularly important for occurrences, where - it can be useful to submit a query and retrieve it at a later time. If the - `compute()` stage is not required, however, `compute()` simply converts - the `query` to a new class (`computed_query`). -- `collect()` retrieves the requested data into your workspace, returning a - `tibble`. - -We can use these in sequence, or just leap ahead to the stage we want: - - -``` r -x <- request_data() |> - filter(genus == "Crinia", year == 2020) |> - group_by(species) |> - arrange(species) |> - count() - -collapse(x) -``` - -``` -## Object of class query with type data/occurrences-count-groupby -## url: https://api.ala.org.au/occurrences/occurrences/facets?fq=%28genus%3A%2... -## arrange: species (ascending) -``` - -``` r -compute(x) -``` - -``` -## Object of class computed_query with type data/occurrences-count-groupby -## url: https://api.ala.org.au/occurrences/occurrences/facets?fq=%28genus%3A%2... -## arrange: species (ascending) -``` - -``` r -collect(x) |> head() -``` - -``` -## # A tibble: 6 × 2 -## species count -## -## 1 Crinia bilingua 74 -## 2 Crinia deserticola 253 -## 3 Crinia flindersensis 3 -## 4 Crinia georgiana 1509 -## 5 Crinia glauerti 3111 -## 6 Crinia insignifera 530 -``` - -The benefit of using `collapse()`, `compute()` and `collect()` is that queries -are more modular. This is particularly useful for large data requests in galah. -Users can send their query using `compute()`, and download data once the query -has finished — downloading with `collect()` later — rather than waiting for the -request to finish within R. - - -``` r -# Create and send query to be calculated server-side -request <- request_data() |> - identify("perameles") |> - filter(year > 1900) |> - compute() - -# Download data -request |> - collect() -``` - -Additionally, functions that are more modular are generally easier to -interrogate and debug. Previously some functions did several different things, -making it difficult to know which APIs were being called, when, and for what -purpose. Partitioning queries into three distinct stages is much more transparent, -and allows users to check their query construction prior to sending a request. -For example, the query above is constructed with the following information, -returned by `collapse()`. - - -``` r -request_data() |> - identify("perameles") |> - filter(year > 1900) |> - collapse() -``` - -``` -## Object of class query with type data/occurrences -## url: https://api.ala.org.au/occurrences/occurrences/offline/download?fq=%28... -``` - -The `collapse()` stage includes an additional argument (`.expand`) that, -when set to `TRUE`, shows all the APIs called to construct the user-requested -query. This is especially useful for debugging. - -## Object classes - -Under the hood, the different query-building verbs each amend the supplied -object to a new class: - -- `collapse()` returns class `query`, which is a list containing a `type` slot - and one or more `url`s -- `compute()` returns a single object of class `computed_query` -- `collect()` returns a `tibble` - -These can be called directly, or via the `method` and `type` arguments of -`galah_call()`, which specify which dedicated `request_` function and data type -to return. To demonstrate what we mean, take the following calls, which despite -using different syntax, all return the number of records available for the year -2020: - - -``` r -# new syntax -request_data() |> - filter(year == 2020) |> - count() |> - collect() - -# similar, but using `galah_call()` -galah_call(method = "data", - type = "occurrences-count") |> - filter(year == 2020) |> - collect() - -# original syntax -galah_call() |> - galah_filter(year == 2020) |> - atlas_counts() -``` - -Another example is to list available `fields` in the selected atlas: - - -``` r -request_metadata(type = "fields") |> - collect() - -galah_call(method = "metadata", - type = "fields") |> - collect() - -show_all(fields) -``` - -Or to show values for states and territories: - - -``` r -request_metadata() |> - filter(field == "cl22") |> - unnest() |> - collect() - -galah_call(method = "metadata", - type = "fields-unnest") |> - galah_filter(id == "cl22") |> - collect() - -search_all(fields, "cl22") |> - show_values() -``` - -While `request_metadata()` is more modular than `show_all()`, there is -little benefit to using it for most applications. However, in some cases, -larger databases like GBIF return huge `data.frame`s of metadata when called -via `show_all()`. Using `request_metdata()` allows users to specify a -`slice_head()` line within their pipe to get around this issue. - -## Which syntax should I prefer? - -Despite these benefits, we have no plans to _require_ users to call masked -functions. Functions prefixed with `galah_` or `atlas_` are not going away. -Indeed, while there is perfect redundancy between old and new syntax in some -cases, in others they serve different purposes. In `atlas_media()` for example, -several calls are made and joined in a way that reduces the number of steps -required by the user. Under the hood, however, all `atlas_` functions are now -entirely built using the above syntax. diff --git a/vignettes/object_oriented_programming.Rmd.orig b/vignettes/object_oriented_programming.Rmd.orig deleted file mode 100644 index a07dc9d3..00000000 --- a/vignettes/object_oriented_programming.Rmd.orig +++ /dev/null @@ -1,230 +0,0 @@ ---- -title: "Object-Oriented Programming" -author: "Martin Westgate & Dax Kellie" -date: '`r Sys.Date()`' -output: - rmarkdown::html_vignette -vignette: > - %\VignetteIndexEntry{Object-Oriented Programming} - %\VignetteEngine{knitr::rmarkdown} - %\VignetteEncoding{UTF-8} ---- -```{r include = FALSE} -galah_config(email = "ala4r@ala.org.au", - atlas = "Australia", - verbose = FALSE) -``` - -The default method for building queries in `galah` is to first use `galah_call()` -to create a query object called a "`data_request`". This object class is specific -to `galah`. - -```{r} -galah_call() |> - filter(genus == "Crinia") |> - class() -``` - -When a piped object is of class `data_request`, galah can trigger functions to -use specific methods for this object class, even if a function name is used by -another package. For example, users can use `filter()` and `group_by()` functions -from [dplyr](https://dplyr.tidyverse.org/index.html) instead -of `galah_filter()` and `galah_group_by()` to construct a query. Consequently, -the following queries are synonymous: - -```{r, eval = FALSE} -galah_call() |> - galah_filter(genus == "Crinia", year == 2020) |> - galah_group_by(species) |> - atlas_counts() -``` -```{r} -galah_call() |> - filter(genus == "Crinia", year == 2020) |> - group_by(species) |> - atlas_counts() -``` - -Thanks to object-oriented programming, galah "masks" `filter()` and `group_by()` -functions to use methods defined for `data_request` objects instead. The full -list of masked functions is: - -- `arrange()` (`{dplyr}`) -- `count()` (`{dplyr}`) -- `identify()` (`{graphics}`) as a synonym for `galah_identify()` -- `select()` (`{dplyr}`) as a synonym for `galah_select()` -- `group_by()` (`{dplyr}`) as a synonym for `galah_group_by()` -- `slice_head()` (`{dplyr}`) as a synonym for the `limit` argument in `atlas_counts()` -- `st_crop()` (`{sf}`) as a synonym for `galah_polygon()` - -Note that these functions are all evaluated lazily; they amend the underlying -object, but do not amend the nature of the data until the call is evaluated. To -actually build and run the query, we'll need to use one or more of a different -set of dplyr verbs: `collapse()`, `compute()` and `collect()`. - -## Advanced query building - -The usual way to begin a query to request data in galah is using `galah_call()`. -However, this function now calls one of three types of `request_` functions. -If you prefer, you can begin your pipe with one of these dedicated `request_` -functions (rather than `galah_call()`) depending on the type of data you -want to collect. - -For example, if you want to download occurrences, use `request_data()`: - -```{r, eval = FALSE} -x <- request_data("occurrences") |> # note that "occurrences" is the default `type` - filter(species == "Crinia tinnula", - year == 2010) |> - collect() -``` - -You'll notice that this query differs slightly from the query structure used in -earlier versions of `galah`. The desired data type, `"occurrences"`, -is specified at the beginning of the query within `request_data()` rather than -at the end using `atlas_occurrences()`. Specifying the data type at the start -allows users to make use of advanced query building using three newly -implemented stages of query building: `collapse()`, `compute()` and `collect()`. -These stages mirror existing [functions in dplyr for querying -databases](https://dplyr.tidyverse.org/reference/compute.html), and act in the -following way: - -- `collapse()` converts the object to a `query`. This allows users to inspect - their API calls before they are sent. Depending on the request, this function - may also call 'supplementary' APIs to collect required information, - such as Taxon Concept Identifiers or field names. -- `compute()` is intended to send the query in question to the requested API - for processing. This is particularly important for occurrences, where - it can be useful to submit a query and retrieve it at a later time. If the - `compute()` stage is not required, however, `compute()` simply converts - the `query` to a new class (`computed_query`). -- `collect()` retrieves the requested data into your workspace, returning a - `tibble`. - -We can use these in sequence, or just leap ahead to the stage we want: - -```{r} -x <- request_data() |> - filter(genus == "Crinia", year == 2020) |> - group_by(species) |> - arrange(species) |> - count() - -collapse(x) -compute(x) -collect(x) |> head() -``` - -The benefit of using `collapse()`, `compute()` and `collect()` is that queries -are more modular. This is particularly useful for large data requests in galah. -Users can send their query using `compute()`, and download data once the query -has finished — downloading with `collect()` later — rather than waiting for the -request to finish within R. - -```{r, eval = FALSE} -# Create and send query to be calculated server-side -request <- request_data() |> - identify("perameles") |> - filter(year > 1900) |> - compute() - -# Download data -request |> - collect() -``` - -Additionally, functions that are more modular are generally easier to -interrogate and debug. Previously some functions did several different things, -making it difficult to know which APIs were being called, when, and for what -purpose. Partitioning queries into three distinct stages is much more transparent, -and allows users to check their query construction prior to sending a request. -For example, the query above is constructed with the following information, -returned by `collapse()`. - -```{r} -request_data() |> - identify("perameles") |> - filter(year > 1900) |> - collapse() -``` - -## Object classes - -Under the hood, the different query-building verbs each amend the supplied -object to a new class: - -- `collapse()` returns class `query`, which is a list containing a `type` slot - and one or more `url`s -- `compute()` returns a single object of class `computed_query` -- `collect()` returns a `tibble` - -These can be called directly, or via the `method` and `type` arguments of -`galah_call()`, which specify which dedicated `request_` function and data type -to return. To demonstrate what we mean, take the following calls, which despite -using different syntax, all return the number of records available for the year -2020: - -```{r, eval = FALSE} -# new syntax -request_data() |> - filter(year == 2020) |> - count() |> - collect() - -# similar, but using `galah_call()` -galah_call(method = "data", - type = "occurrences-count") |> - filter(year == 2020) |> - collect() - -# original syntax -galah_call() |> - galah_filter(year == 2020) |> - atlas_counts() -``` - -Another example is to list available `fields` in the selected atlas: - -```{r, eval = FALSE} -request_metadata(type = "fields") |> - collect() - -galah_call(method = "metadata", - type = "fields") |> - collect() - -show_all(fields) -``` - -Or to show values for states and territories: - -```{r, eval = FALSE} -request_metadata() |> - filter(field == "cl22") |> - unnest() |> - collect() - -galah_call(method = "metadata", - type = "fields-unnest") |> - galah_filter(id == "cl22") |> - collect() - -search_all(fields, "cl22") |> - show_values() -``` - -While `request_metadata()` is more modular than `show_all()`, there is -little benefit to using it for most applications. However, in some cases, -larger databases like GBIF return huge `data.frame`s of metadata when called -via `show_all()`. Using `request_metdata()` allows users to specify a -`slice_head()` line within their pipe to get around this issue. - -## Which syntax should I prefer? - -Despite these benefits, we have no plans to _require_ users to call masked -functions. Functions prefixed with `galah_` or `atlas_` are not going away. -Indeed, while there is perfect redundancy between old and new syntax in some -cases, in others they serve different purposes. In `atlas_media()` for example, -several calls are made and joined in a way that reduces the number of steps -required by the user. Under the hood, however, all `atlas_` functions are now -entirely built using the above syntax. \ No newline at end of file diff --git a/vignettes/quick_start_guide.Rmd b/vignettes/quick_start_guide.Rmd index 168db27c..ef7317eb 100644 --- a/vignettes/quick_start_guide.Rmd +++ b/vignettes/quick_start_guide.Rmd @@ -120,10 +120,6 @@ galah_call() |> collect() ``` -``` -## -- -``` - ``` ## # A tibble: 21,984 × 3 ## eventDate decimalLatitude species @@ -146,7 +142,7 @@ it is possible to write extensions that apply them to new object classes. In our case, `galah_call()` creates a new object class called a `data_request` for which we have written new extensions. This means that galah will not interfere with your use of `filter()` and friends on your tibbles. -The full list of supported `dplyr` extensions is: +Supported `dplyr` verbs that modify queries are as follows: - `arrange.data_request()` - `count.data_request()` @@ -163,6 +159,19 @@ Additional verbs are: - `identify.data_request()` - `unnest()` +Three other `dplyr` functions are supported to conclude a pipe: + +- `collapse.data_request()` converts the `request` object to a `query`. This allows users to inspect + their API calls before they are sent. Depending on the request, this function + may also call 'supplementary' APIs to collect required information, + such as Taxon Concept Identifiers or field names. +- `compute.data_request()` is intended to send the query in question to the requested API + for processing. In practice, only occurrence queries distinguish between + posting a query and retrieving the results, meaning that in all other cases, + `compute()` simply converts the `query` to a new class (`computed_query`). +- `collect.data_request()` retrieves the requested data into your workspace, returning a + `tibble`. + It is good practice to download your data in as few steps as possible, to minimize impacts on the server, and to ensure you can get a single DOI for your data. See the @@ -269,10 +278,6 @@ galah_call() |> print(n = 6) ``` -``` -## ----- -``` - ``` ## # A tibble: 2,032 × 9 ## recordID scientificName taxonConceptID decimalLatitude decimalLongitude eventDate basisOfRecord occurrenceStatus dataResourceName diff --git a/vignettes/quick_start_guide.Rmd.orig b/vignettes/quick_start_guide.Rmd.orig index 88b5607c..3a1b9dff 100644 --- a/vignettes/quick_start_guide.Rmd.orig +++ b/vignettes/quick_start_guide.Rmd.orig @@ -84,7 +84,8 @@ galah_call() |> Or to download the records themselves: ```{r, cache-email, echo = FALSE} -galah_config(email = "ala4r@ala.org.au") +galah_config(email = "ala4r@ala.org.au", + verbose = FALSE) ``` ```{r, occurrence-example, message = FALSE} @@ -100,7 +101,7 @@ it is possible to write extensions that apply them to new object classes. In our case, `galah_call()` creates a new object class called a `data_request` for which we have written new extensions. This means that galah will not interfere with your use of `filter()` and friends on your tibbles. -The full list of supported `dplyr` extensions is: +Supported `dplyr` verbs that modify queries are as follows: - `arrange.data_request()` - `count.data_request()` @@ -117,6 +118,19 @@ Additional verbs are: - `identify.data_request()` - `unnest()` +Three other `dplyr` functions are supported to conclude a pipe: + +- `collapse.data_request()` converts the `request` object to a `query`. This allows users to inspect + their API calls before they are sent. Depending on the request, this function + may also call 'supplementary' APIs to collect required information, + such as Taxon Concept Identifiers or field names. +- `compute.data_request()` is intended to send the query in question to the requested API + for processing. In practice, only occurrence queries distinguish between + posting a query and retrieving the results, meaning that in all other cases, + `compute()` simply converts the `query` to a new class (`computed_query`). +- `collect.data_request()` retrieves the requested data into your workspace, returning a + `tibble`. + It is good practice to download your data in as few steps as possible, to minimize impacts on the server, and to ensure you can get a single DOI for your data. See the From efd72309600e3db9aaeb6518f28446d2e3a83dab Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Fri, 9 Jan 2026 13:38:27 +1100 Subject: [PATCH 62/94] update tests fix minor bugs in image APIs, disable authentication for overseas atlases, adjust tests for minor changes to object structures etc --- R/atlas_media.R | 9 ++++----- R/authenticate.R | 5 ++--- R/coalesce.R | 6 ++++-- R/galah_config.R | 1 + R/sysdata.rda | Bin 6441 -> 6438 bytes data-raw/node_config.csv | 16 ++++++++-------- tests/testthat/test-authentication.R | 16 ---------------- tests/testthat/test-galah_config.R | 9 --------- tests/testthat/test-galah_identify.R | 11 +++++++---- tests/testthat/test-international-Austria.R | 6 +++--- tests/testthat/test-international-Brazil.R | 8 ++++---- tests/testthat/test-international-Flanders.R | 6 ++++-- tests/testthat/test-international-Guatemala.R | 8 ++++---- tests/testthat/test-international-Kew.R | 2 +- tests/testthat/test-international-Portugal.R | 4 ++-- tests/testthat/test-international-Sweden.R | 6 +++--- tests/testthat/test-international-UK.R | 6 +++--- 17 files changed, 50 insertions(+), 69 deletions(-) diff --git a/R/atlas_media.R b/R/atlas_media.R index f44dcf76..17aa7985 100644 --- a/R/atlas_media.R +++ b/R/atlas_media.R @@ -79,7 +79,9 @@ atlas_media <- function(request = NULL, collect(wait = TRUE) |> tidyr::unnest_longer(col = tidyselect::any_of(present_fields)) - if(!any(colnames(occ) == "all_image_url")){ + if(any(colnames(occ) == "all_image_url")){ + occ <- dplyr::rename(occ, "media_id" = "all_image_url") + }else{ occ$media_id <- build_media_id(occ) } @@ -93,9 +95,6 @@ atlas_media <- function(request = NULL, media <- collect(media_query) # join and return - if(any(colnames(occ) == "all_image_url")){ - occ <- dplyr::rename(occ, "media_id" = "all_image_url") - } occ_media <- dplyr::right_join(occ, media, by = dplyr::join_by("media_id")) @@ -113,7 +112,7 @@ parse_regional_media_filters <- function(present_fields, "Austria" = "(all_image_url:*)", "Australia" = glue::glue("({present_fields}:*)"), "Brazil" = "(all_image_url:*)", - # Flanders? + "Flanders" = "(all_image_url:*)", "Guatemala" = "(all_image_url:*)", "Kew" = "(all_image_url:*)", "Portugal" = "(all_image_url:*)", diff --git a/R/authenticate.R b/R/authenticate.R index aa5c4312..13ebc4f8 100644 --- a/R/authenticate.R +++ b/R/authenticate.R @@ -21,9 +21,8 @@ use_authentication <- function(.data, #' @keywords Internal check_authentication <- function(x){ if( - isTRUE(potions::pour("user", - "authenticate", - .pkg = "galah")) & + isTRUE(potions::pour("user", "authenticate", .pkg = "galah")) & + (potions::pour("atlas", "region", .pkg = "galah") == "Australia") & x$type %in% c("occurrences") # possible to add other allowed queries ){ x |> use_authentication() diff --git a/R/coalesce.R b/R/coalesce.R index 0cec8b14..05a47780 100644 --- a/R/coalesce.R +++ b/R/coalesce.R @@ -140,8 +140,10 @@ build_query_set_data <- function(x, mint_doi, ...){ result <- list() # handle authentication - if(isTRUE(potions::pour("user", "authenticate", .pkg = "galah")) | - !is.null(x$authenticate) + if( + (isTRUE(potions::pour("user", "authenticate", .pkg = "galah")) | + !is.null(x$authenticate)) & + potions::pour("atlas", "region") == "Australia" ){ result <- append(result, list(request_metadata("config") |> as_query())) diff --git a/R/galah_config.R b/R/galah_config.R index 730b25ea..e9956173 100644 --- a/R/galah_config.R +++ b/R/galah_config.R @@ -351,6 +351,7 @@ check_authentication_argument <- function(x){ # an API. For safety and clarity reasons, I've added the following steps: # 1. giving some notice to the user that this has been performed # 2. adding a warning message if the API call fails + # This is currently deactivated due to changes in workflow if(isTRUE(purrr::pluck(x, "package", "authenticate")) & # value set to TRUE by user is.null(retrieve_cache("config")) # not already cached ){ diff --git a/R/sysdata.rda b/R/sysdata.rda index ce65a7771e1dc5c111b8c48b751e3701d08d1864..170d6376998c7ed9ea7376fb8d71e9503acc8839 100644 GIT binary patch literal 6438 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 literal 6441 zcmV+^8P?`PT4*^jL0KkKSw)G4kN_TP|NZ~}|NsC0|NsC0|M|cF|N1~eK>$SIN(dML z0D(k>;2+nIovXUt_W^gO9ViYc)H!bJy?X^+fYn~gZh7tjptcQYXKi-cb*_dccsX5K z)Pu3Jvq8~RX3B3tyIZccY))#R()S4@LSi&D8cbC`VNIh{cnLO|o`g?R)csS*jUnof z)Q=R?(@4!U(+TQNG&MCg(rFC==@=ye0W~xsPbr892A-fA0B8n)000JtgFpZQpa1}a zCICzcgv8L%iHU$r0%Q#h8e%XLLjXep4441_69P>rgw)Dt)byvR>OB)iO%G55BTWqe z&}a;R0001bfB*mhXd+M~#Z&b?O_es2)jdy9>VBy8KS~=&XnIXON2CB`4=4&?Pbbi~ywABUziOhI;0h+-f~)bSMFuu?FiylxZ7%T=KyLu!cyvPyc3Kmw~UDHwpNY1HM}e>VU(^M8=gEi9)-7!jbx|t9IvJnQn zH5Yq73_NZQAxP-e+`s8zB*P|LHtn`}Ddy{-guQ^xBqYJ-@9_9v0^-_P@5BZw9ZIx< zB!7|8KnAlVNK*wODzi|ijS&nTo&mbdS0^5c%R(CIuRh#d%;+)}lADZBP(^fAvWw|=~|Y5WhF z5B)t9xgF9Ifih-xq!CrCcu^?=%T^$Ucx55p$hFAB$zLWNL&$0HV%2Hu%`;k3v}Z

      9(yPeTDwBt-^6QbZ&~kp(|2^B^jvgv3Zm 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 diff --git a/data-raw/node_config.csv b/data-raw/node_config.csv index 1d302b64..1254292a 100644 --- a/data-raw/node_config.csv +++ b/data-raw/node_config.csv @@ -32,7 +32,7 @@ Austria,data/occurrences-count,https://biocache.biodiversityatlas.at/ws/occurren Austria,data/occurrences-count-groupby,https://biocache.biodiversityatlas.at/ws/occurrence/facets,TRUE Austria,data/species,https://biocache.biodiversityatlas.at/ws/occurrences/facets/download,TRUE Austria,data/species-count,https://biocache.biodiversityatlas.at/ws/occurrence/facets,TRUE -Austria,files/media,https://images.biodiversityatlas.at/ws/image/{id}/{size},TRUE +Austria,files/media,https://images.biodiversityatlas.at/image/{id}/{size},TRUE Austria,metadata/assertions,https://biocache.biodiversityatlas.at/ws/assertions/codes,TRUE Austria,metadata/collections,https://collectory.biodiversityatlas.at/ws/collection,TRUE Austria,metadata/datasets,https://collectory.biodiversityatlas.at/ws/dataResource,TRUE @@ -51,7 +51,7 @@ Brazil,data/occurrences-count,https://biocache-service.sibbr.gov.br/biocache-ser Brazil,data/occurrences-count-groupby,https://biocache-service.sibbr.gov.br/biocache-service/occurrence/facets,TRUE Brazil,data/species,https://biocache-service.sibbr.gov.br/biocache-service/occurrences/facets/download,TRUE Brazil,data/species-count,https://biocache-service.sibbr.gov.br/biocache-service/occurrence/facets,TRUE -Brazil,files/media,https://images.sibbr.gov.br/ws/image/{id}/{size},TRUE +Brazil,files/media,https://images.sibbr.gov.br/image/{id}/{size},TRUE Brazil,metadata/assertions,https://biocache-service.sibbr.gov.br/biocache-service/assertions/codes,TRUE Brazil,metadata/collections,https://collectory.sibbr.gov.br/collectory/ws/collection,TRUE Brazil,metadata/datasets,https://collectory.sibbr.gov.br/collectory/ws/dataResource,TRUE @@ -81,7 +81,7 @@ Flanders,data/occurrences-count,https://natuurdata.inbo.be/biocache-service/occu Flanders,data/occurrences-count-groupby,https://natuurdata.inbo.be/biocache-service/occurrence/facets,TRUE Flanders,data/species,https://natuurdata.inbo.be/biocache-service/occurrences/facets/download,TRUE Flanders,data/species-count,https://natuurdata.inbo.be/biocache-service/occurrence/facets,TRUE -Flanders,files/media,https://natuurdata.inbo.be/image-service/ws/image/{id}/{size},TRUE +Flanders,files/media,https://natuurdata.inbo.be/image-service/image/{id}/{size},TRUE Flanders,metadata/assertions,https://natuurdata.inbo.be/biocache-service/assertions/codes,TRUE Flanders,metadata/collections,https://natuurdata.inbo.be/collectory/ws/collection,TRUE Flanders,metadata/datasets,https://natuurdata.inbo.be/collectory/ws/dataResource,TRUE @@ -133,7 +133,7 @@ Guatemala,data/occurrences-count,https://snib.conap.gob.gt/registros-ws/occurren Guatemala,data/occurrences-count-groupby,https://snib.conap.gob.gt/registros-ws/occurrence/facets,TRUE Guatemala,data/species,https://snib.conap.gob.gt/registros-ws/occurrences/facets/download,FALSE Guatemala,data/species-count,https://snib.conap.gob.gt/registros-ws/occurrence/facets,TRUE -Guatemala,files/media,https://imagenes.snib.conap.gob.gt/ws/image/{id}/{size},TRUE +Guatemala,files/media,https://imagenes.snib.conap.gob.gt/image/{id}/{size},TRUE Guatemala,metadata/assertions,https://snib.conap.gob.gt/registros-ws/assertions/codes,TRUE Guatemala,metadata/collections,https://snib.conap.gob.gt/colecciones/ws/collection,TRUE Guatemala,metadata/datasets,https://snib.conap.gob.gt/colecciones/ws/dataResource,TRUE @@ -150,7 +150,7 @@ Kew,data/occurrences-count,https://records-ws.data.kew.org/occurrences/search,TR Kew,data/occurrences-count-groupby,https://records-ws.data.kew.org/occurrences/facets,TRUE Kew,data/species,https://records-ws.data.kew.org/occurrences/facets/download,TRUE Kew,data/species-count,https://records-ws.data.kew.org/occurrences/facets,TRUE -Kew,files/media,https://images.data.kew.org/ws/image/{id}/{size},TRUE +Kew,files/media,https://images.data.kew.org/image/{id}/{size},TRUE Kew,metadata/assertions,https://records-ws.data.kew.org/assertions/codes,TRUE Kew,metadata/collections,https://collections.data.kew.org/ws/collection,TRUE Kew,metadata/datasets,https://collections.data.kew.org/ws/dataResource,TRUE @@ -169,7 +169,7 @@ Portugal,data/occurrences-count,https://registos-ws.gbif.pt/occurrences/search,T Portugal,data/occurrences-count-groupby,https://registos-ws.gbif.pt/occurrence/facets,TRUE Portugal,data/species,https://registos-ws.gbif.pt/occurrences/facets/download,FALSE Portugal,data/species-count,https://registos-ws.gbif.pt/occurrence/facets,TRUE -Portugal,files/media,https://imagens.gbif.pt/ws/image/{id}/{size},TRUE +Portugal,files/media,https://imagens.gbif.pt/image/{id}/{size},TRUE Portugal,metadata/assertions,https://registos-ws.gbif.pt/assertions/codes,TRUE Portugal,metadata/collections,https://metadados.gbif.pt/ws/collection,TRUE Portugal,metadata/datasets,https://metadados.gbif.pt/ws/dataResource,TRUE @@ -188,7 +188,7 @@ Spain,data/occurrences-count-groupby,https://registros-ws.gbif.es/occurrence/fac Spain,data/occurrences-doi,https://doi.gbif.es/doi/{doi_string}/download,TRUE Spain,data/species,https://registros-ws.gbif.es/occurrences/facets/download,TRUE Spain,data/species-count,https://registros-ws.gbif.es/occurrence/facets,TRUE -Spain,files/media,https://imagenes.gbif.es/ws/images/{id}/{size},TRUE +Spain,files/media,https://imagenes.gbif.es/images/{id}/{size},TRUE Spain,metadata/assertions,https://registros-ws.gbif.es/assertions/codes,TRUE Spain,metadata/collections,https://colecciones.gbif.es/ws/collection,TRUE Spain,metadata/datasets,https://colecciones.gbif.es/ws/dataResource,TRUE @@ -234,7 +234,7 @@ United Kingdom,data/occurrences-count,https://records-ws.nbnatlas.org/occurrence United Kingdom,data/occurrences-count-groupby,https://records-ws.nbnatlas.org/occurrence/facets,TRUE United Kingdom,data/species,https://records-ws.nbnatlas.org/occurrences/facets/download,TRUE United Kingdom,data/species-count,https://records-ws.nbnatlas.org/occurrence/facets,TRUE -United Kingdom,files/media,https://images.nbnatlas.org/ws/image/{id}/{size},TRUE +United Kingdom,files/media,https://images.nbnatlas.org/image/{id}/{size},TRUE United Kingdom,metadata/assertions,https://records-ws.nbnatlas.org/assertions/codes,TRUE United Kingdom,metadata/collections,https://registry.nbnatlas.org/ws/collection,TRUE United Kingdom,metadata/datasets,https://registry.nbnatlas.org/ws/dataResource,TRUE diff --git a/tests/testthat/test-authentication.R b/tests/testthat/test-authentication.R index 82656848..adfa5cd1 100644 --- a/tests/testthat/test-authentication.R +++ b/tests/testthat/test-authentication.R @@ -1,21 +1,5 @@ quiet_config <- purrr::quietly(galah_config) -test_that("`galah_config()` caches config info when `authenticate` is set to `TRUE`", { - skip_on_ci(); skip_on_cran() - x <- galah_config() - expect_false(x$user$authenticate) - expect_true(is.null(retrieve_cache("config"))) - y <- quiet_config(authenticate = TRUE) - stringr::str_detect(y$messages, - "Caching `config` information to support authentication") |> - any() |> - expect_true() - expect_true(y$result$user$authenticate) - cached_config <- retrieve_cache("config") - expect_false(is.null(cached_config)) - expect_equal(nrow(cached_config), 1) -}) - test_that("`request_metadata()` works for type = `config`", { skip_on_ci(); skip_on_cran() result <- request_metadata(type = "config") |> diff --git a/tests/testthat/test-galah_config.R b/tests/testthat/test-galah_config.R index 3713c8d9..245d1cd1 100644 --- a/tests/testthat/test-galah_config.R +++ b/tests/testthat/test-galah_config.R @@ -5,15 +5,6 @@ test_that("`galah_config()` gives nice error messages for incorrect arguments", expect_error(quiet_config(something = "nothing")) }) -test_that("galah_config warns that `cache_directory` is deprecated", { - unlink("temp", recursive = TRUE) - dir.create("temp") - expect_warning(galah_config(cache_directory = "temp")) - expect_true(galah_config()$package$directory == "temp") - galah_config(directory = tempfile()) - unlink("temp", recursive = TRUE) -}) - test_that("galah_config creates nested folders where requested", { galah_config(directory = "non/existent") directories <- list.dirs(recursive = TRUE) diff --git a/tests/testthat/test-galah_identify.R b/tests/testthat/test-galah_identify.R index f56dd550..be81ddb4 100644 --- a/tests/testthat/test-galah_identify.R +++ b/tests/testthat/test-galah_identify.R @@ -87,11 +87,14 @@ test_that("`identify()` truncates unmatched list of taxa at 3 ", { expected_messages <- c( "Matched 1 of 5 taxonomic search terms in selected atlas (Australia).\n", "4 unmatched search terms:\n", - "• \"blarghy\", \"blorp\", \"florp\" + 1 more\"\n", + "\"blarghy\", \"blorp\", \"florp\" + 1 more\"\n", "\n") - x |> - purrr::pluck("messages") |> - expect_equal(expected_messages) + actual_messages <- purrr::pluck(x, "messages") + # NOTE: due to differences in how dot points are represented, this test + # fails unless we handle the strings carefully + expect_equal( + stringr::str_replace_all(actual_messages, "[:punct:]", "") |> trimws(), + stringr::str_replace_all(expected_messages, "[:punct:]", "") |> trimws()) }) ## NOTE: Not certain if this is a necessary test diff --git a/tests/testthat/test-international-Austria.R b/tests/testthat/test-international-Austria.R index a4d22004..b4eafd04 100644 --- a/tests/testthat/test-international-Austria.R +++ b/tests/testthat/test-international-Austria.R @@ -139,7 +139,7 @@ test_that("show_values works for lists for Austria", { test_that("atlas_counts works with type = 'occurrences' for Austria", { skip_if_offline(); skip_on_ci() x <- atlas_counts() |> - pull(count) |> + dplyr::pull(count) |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") expect_gt(x, 0) @@ -148,7 +148,7 @@ test_that("atlas_counts works with type = 'occurrences' for Austria", { test_that("atlas_counts works with type = 'species' for Austria", { skip_if_offline(); skip_on_ci() x <- atlas_counts(type = "species") |> - pull(count) |> + dplyr::pull(count) |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") expect_gt(x, 0) @@ -224,7 +224,7 @@ test_that("atlas_occurrences works for Austria", { skip_if(inherits(occ_collapse, "try-error"), message = "API not available") expect_s3_class(occ_collapse, "query") expect_equal(names(occ_collapse), - c("type", "url", "headers", "filter")) + c("type", "url", "headers", "request")) expect_equal(occ_collapse$type, "data/occurrences") # compute occ_compute <- compute(occ_collapse) diff --git a/tests/testthat/test-international-Brazil.R b/tests/testthat/test-international-Brazil.R index ca439ddb..cfaabd0a 100644 --- a/tests/testthat/test-international-Brazil.R +++ b/tests/testthat/test-international-Brazil.R @@ -105,7 +105,7 @@ test_that("show_values works for Brazil", { test_that("atlas_counts works for Brazil", { skip_if_offline(); skip_on_ci() x <- atlas_counts() |> - pull(count) |> + dplyr::pull(count) |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") expect_gt(x, 0) @@ -114,7 +114,7 @@ test_that("atlas_counts works for Brazil", { test_that("atlas_counts works with type = 'species' for Brazil", { skip_if_offline(); skip_on_ci() x <- atlas_counts(type = "species") |> - pull(count) |> + dplyr::pull(count) |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") expect_gt(x, 0) @@ -190,7 +190,7 @@ test_that("atlas_occurrences works for Brazil", { skip_if(inherits(occ_collapse, "try-error"), message = "API not available") expect_s3_class(occ_collapse, "query") expect_equal(names(occ_collapse), - c("type", "url", "headers", "filter")) + c("type", "url", "headers", "request")) occ_compute <- compute(occ_collapse) |> try(silent = TRUE) skip_if(inherits(occ_compute, "try-error"), message = "API not available") @@ -200,7 +200,7 @@ test_that("atlas_occurrences works for Brazil", { try(silent = TRUE) skip_if(inherits(occ, "try-error"), message = "API not available") expect_gt(nrow(occ), 0) - expect_equal(ncol(occ), 8) + expect_equal(ncol(occ), 9) expect_true(inherits(occ, c("tbl_df", "tbl", "data.frame"))) }) diff --git a/tests/testthat/test-international-Flanders.R b/tests/testthat/test-international-Flanders.R index 13f60f3c..056ddbdf 100644 --- a/tests/testthat/test-international-Flanders.R +++ b/tests/testthat/test-international-Flanders.R @@ -263,8 +263,10 @@ test_that("atlas_media() works for Flanders", { directory = "temp", send_email = FALSE) x <- request_data() |> - identify("Mammalia") |> - filter(year >= 2023 + identify("Erinaceinae") |> # problem here: + # 1. query works but doesn't return an entry + # 2. identify() is not robust to searches that return no values + filter(year == 2023 # imageIDsCount > 0 ) |> # count() |> diff --git a/tests/testthat/test-international-Guatemala.R b/tests/testthat/test-international-Guatemala.R index 2919fa09..24926cb9 100644 --- a/tests/testthat/test-international-Guatemala.R +++ b/tests/testthat/test-international-Guatemala.R @@ -101,7 +101,7 @@ test_that("show_values works for fields for Guatemala", { test_that("atlas_counts works for Guatemala", { skip_if_offline(); skip_on_ci() x <- atlas_counts() |> - pull(count) |> + dplyr::pull(count) |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") expect_gt(x, 0) @@ -110,7 +110,7 @@ test_that("atlas_counts works for Guatemala", { test_that("atlas_counts works with type = 'species' for Guatemala", { skip_if_offline(); skip_on_ci() x <- atlas_counts(type = "species") |> - pull(count) |> + dplyr::pull(count) |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") expect_gt(x, 0) @@ -186,7 +186,7 @@ test_that("atlas_occurrences works for Guatemala", { skip_if(inherits(occ_collapse, "try-error"), message = "API not available") expect_s3_class(occ_collapse, "query") expect_equal(names(occ_collapse), - c("type", "url", "headers", "filter")) + c("type", "url", "headers", "request")) occ_compute <- compute(occ_collapse) |> try(silent = TRUE) skip_if(inherits(occ_compute, "try-error"), message = "API not available") @@ -196,7 +196,7 @@ test_that("atlas_occurrences works for Guatemala", { try(silent = TRUE) skip_if(inherits(occ, "try-error"), message = "API not available") expect_gt(nrow(occ), 0) - expect_equal(ncol(occ), 8) + expect_equal(ncol(occ), 9) expect_true(inherits(occ, c("tbl_df", "tbl", "data.frame"))) }) diff --git a/tests/testthat/test-international-Kew.R b/tests/testthat/test-international-Kew.R index 20869c6e..915235a3 100644 --- a/tests/testthat/test-international-Kew.R +++ b/tests/testthat/test-international-Kew.R @@ -127,7 +127,7 @@ test_that("search_all(identifiers) unavailable for Kew", { test_that("atlas_counts works for Kew", { skip_if_offline(); skip_on_ci() x <- atlas_counts() |> - pull(count) |> + dplyr::pull(count) |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") expect_gt(x, 0) diff --git a/tests/testthat/test-international-Portugal.R b/tests/testthat/test-international-Portugal.R index 2176d8b4..2dc26c8b 100644 --- a/tests/testthat/test-international-Portugal.R +++ b/tests/testthat/test-international-Portugal.R @@ -99,7 +99,7 @@ test_that("show_values works for fields for Portugal", { test_that("atlas_counts works for Portugal", { skip_if_offline(); skip_on_ci() x <- atlas_counts() |> - pull(count) |> + dplyr::pull(count) |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") expect_gt(x, 0) @@ -108,7 +108,7 @@ test_that("atlas_counts works for Portugal", { test_that("atlas_counts works with type = 'species' for Portugal", { skip_if_offline(); skip_on_ci() x <- atlas_counts(type = "species") |> - pull(count) |> + dplyr::pull(count) |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") expect_gt(x, 0) diff --git a/tests/testthat/test-international-Sweden.R b/tests/testthat/test-international-Sweden.R index 7ef71990..3ac3cdd2 100644 --- a/tests/testthat/test-international-Sweden.R +++ b/tests/testthat/test-international-Sweden.R @@ -180,7 +180,7 @@ test_that("show_values works for lists in Sweden", { test_that("atlas_counts works with type = 'occurrences' for Sweden", { skip_if_offline(); skip_on_ci() x <- atlas_counts() |> - pull(count) |> + dplyr::pull(count) |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") expect_gt(x, 0) @@ -189,7 +189,7 @@ test_that("atlas_counts works with type = 'occurrences' for Sweden", { test_that("atlas_counts works with type = 'species' for Sweden", { skip_if_offline(); skip_on_ci() x <- atlas_counts(type = "species") |> - pull(count) |> + dplyr::pull(count) |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") expect_gt(x, 0) @@ -269,7 +269,7 @@ test_that("atlas_occurrences works for Sweden", { skip_if(inherits(occ_collapse, "try-error"), message = "API not available") expect_s3_class(occ_collapse, "query") expect_equal(names(occ_collapse), - c("type", "url", "headers", "filter")) + c("type", "url", "headers", "request")) expect_equal(occ_collapse$type, "data/occurrences") # compute occ_compute <- compute(occ_collapse) diff --git a/tests/testthat/test-international-UK.R b/tests/testthat/test-international-UK.R index fad5296e..c23dcf8f 100644 --- a/tests/testthat/test-international-UK.R +++ b/tests/testthat/test-international-UK.R @@ -127,7 +127,7 @@ test_that("show_list_values works for United Kingdom", { test_that("atlas_counts works with type = 'occurrences' for United Kingdom", { skip_if_offline(); skip_on_ci() x <- atlas_counts() |> - pull(count) |> + dplyr::pull(count) |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") expect_gt(x, 0) @@ -136,7 +136,7 @@ test_that("atlas_counts works with type = 'occurrences' for United Kingdom", { test_that("atlas_counts works with type = 'species' for United Kingdom", { skip_if_offline(); skip_on_ci() x <- atlas_counts(type = "species") |> - pull(count) |> + dplyr::pull(count) |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") expect_gt(x, 0) @@ -218,7 +218,7 @@ test_that("atlas_occurrences works for United Kingdom", { # with run checks, this gives n = 5. Without it's n = 2 expect_s3_class(occ_collapse, "query") expect_equal(names(occ_collapse), - c("type", "url", "headers", "filter")) + c("type", "url", "headers", "request")) expect_equal(occ_collapse$type, "data/occurrences") # compute # notes: From 8a722f17c8560b1bb2af18359a18d42a162bbe7d Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Tue, 13 Jan 2026 15:33:01 +1100 Subject: [PATCH 63/94] Fix bugs in `distinct()` parsing --- R/as_query-occurrences_count.R | 11 ++++++++--- R/as_query.R | 18 ++++++++++++++++-- R/coalesce.R | 2 +- R/handle_request_objects.R | 2 +- tests/testthat/test-dplyr-distinct.R | 14 +++++++++++++- 5 files changed, 39 insertions(+), 8 deletions(-) diff --git a/R/as_query-occurrences_count.R b/R/as_query-occurrences_count.R index e331f908..6500e38e 100644 --- a/R/as_query-occurrences_count.R +++ b/R/as_query-occurrences_count.R @@ -23,7 +23,8 @@ as_query_occurrences_count_atlas <- function(identify = NULL, filter = NULL, geolocate = NULL, apply_profile = NULL, - group_by = NULL, + group_by = NULL, + distinct = NULL, slice_arrange = NULL ){ query <- build_query(identify, @@ -31,7 +32,7 @@ as_query_occurrences_count_atlas <- function(identify = NULL, geolocate, apply_profile = apply_profile) # set behaviour depending on `group_by()` - if(is.null(group_by)){ + if(is.null(group_by) & is.null(distinct)){ url <- url_lookup("data/occurrences-count") |> httr2::url_parse() url$query <- c(query, pageSize = 0) @@ -41,7 +42,11 @@ as_query_occurrences_count_atlas <- function(identify = NULL, }else{ url <- url_lookup("data/occurrences-count-groupby") |> httr2::url_parse() - facets <- as.list(group_by$name) + if(!is.null(group_by)){ + facets <- group_by$name + }else{ + facets <- distinct$name + } names(facets) <- rep("facets", length(facets)) url$query <- c(query, facets, parse_slice_arrange(slice_arrange)) result <- list(type = "data/occurrences-count-groupby", diff --git a/R/as_query.R b/R/as_query.R index 0e6c10b7..6607b1b9 100644 --- a/R/as_query.R +++ b/R/as_query.R @@ -183,10 +183,24 @@ check_distinct_count_groupby <- function(x){ has_distinct_name <- !is.na(x$distinct$name) keep_all <- x$distinct$keep_all if(has_distinct_name){ - if(keep_all){ # this section feels incomplete + if(keep_all){ update_request_object(x, type = "species") }else{ # keep_all is FALSE - update_request_object(x, type = "species-count") + if(has_group_by){ + x |> + update_request_object(type = "species-count") + }else{ + if(has_count){ + # counts the number of facets + x |> + update_request_object(type = "species-count") + }else{ + # designed to be equivalent to `show_values()` + x |> + update_request_object(type = "occurrences-count") |> + dplyr::select(-dplyr::any_of(c("label", "i18nCode", "fq", "count"))) + } + } } }else{ # no distinct name if(has_group_by){ diff --git a/R/coalesce.R b/R/coalesce.R index 05a47780..b5d6861d 100644 --- a/R/coalesce.R +++ b/R/coalesce.R @@ -151,7 +151,7 @@ build_query_set_data <- function(x, mint_doi, ...){ # handle `run_checks` # find which functions are missing from the pipe - lookup_fields <- c("arrange", "filter", "select", "group_by") + lookup_fields <- c("arrange", "filter", "select", "group_by", "distinct") fields_absent <- !(lookup_fields %in% names(x)) names(fields_absent) <- lookup_fields diff --git a/R/handle_request_objects.R b/R/handle_request_objects.R index cb242b6c..b5b1e556 100644 --- a/R/handle_request_objects.R +++ b/R/handle_request_objects.R @@ -100,7 +100,7 @@ update_select <- function(x, y){ group_vec <- NULL } result |> - add_summary() |> + generate_summary(dots) |> add_group(group = group_vec) } diff --git a/tests/testthat/test-dplyr-distinct.R b/tests/testthat/test-dplyr-distinct.R index 102f35ad..2b0529ff 100644 --- a/tests/testthat/test-dplyr-distinct.R +++ b/tests/testthat/test-dplyr-distinct.R @@ -74,6 +74,18 @@ test_that("`group_by() |> distinct(.keep_all = TRUE)` converts type from occurre nrow(x)) }) +test_that("distinct(variable, .keep_all = FALSE) returns field values", { + skip_if_offline(); skip_on_ci() + result <- galah_call() |> + distinct(cl11226) |> + quiet_collect() + # should return values for that field + expect_s3_class(result, + c("tbl_df", "tbl", "data.frame")) + expect_equal(colnames(result), "cl11226") + expect_gte(nrow(result), 10) +}) + test_that("`distinct(.keep_all = TRUE)` sets species queries", { skip_if_offline(); skip_on_ci() result <- galah_call() |> @@ -87,7 +99,7 @@ test_that("`distinct(.keep_all = TRUE)` sets species queries", { c("species", "species_name", "kingdom")) }) -test_that("`distinct(speciesID) |> count()` can be used to count the number of species", { +test_that("`distinct(variable) |> count()` can be used to count the number of levels", { skip_if_offline(); skip_on_ci() result <- galah_call() |> identify("perameles") |> From 6fedf59956269ddbf7745e29f0a32f83bae7bcfc Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Wed, 14 Jan 2026 08:04:24 +1100 Subject: [PATCH 64/94] Fix bugs in authentication pipeline - ensure `galah_config()` errors with sensible messages when invalid arguments are supplied - turn off checking for supplied emails when authentication is set to TRUE - export `authenticate()` function, use as primary mechanism for affecting authentication --- NAMESPACE | 1 + R/authenticate.R | 79 +++++++--------------------- R/check.R | 25 ++++++++- R/coalesce.R | 8 +-- R/compute_occurrences.R | 2 +- R/galah_config.R | 47 +++++------------ R/query_API.R | 36 ++++++++++++- man/authenticate.Rd | 26 +++++++++ tests/testthat/test-authentication.R | 21 ++++---- tests/testthat/test-galah_config.R | 1 - 10 files changed, 131 insertions(+), 115 deletions(-) create mode 100644 man/authenticate.Rd diff --git a/NAMESPACE b/NAMESPACE index d55e2594..49a408ca 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -64,6 +64,7 @@ export(atlas_media) export(atlas_occurrences) export(atlas_species) export(atlas_taxonomy) +export(authenticate) export(coalesce) export(collapse) export(collect) diff --git a/R/authenticate.R b/R/authenticate.R index 13ebc4f8..ee44e002 100644 --- a/R/authenticate.R +++ b/R/authenticate.R @@ -1,64 +1,21 @@ #' Set up authentication #' -#' This is early-stage code. It is triggered from -#' `galah_config(authenticate = TRUE)`, but given the package-wide importance -#' of authentication it seemed wise to collect all functions in one place. -#' @noRd -#' @keywords Internal -use_authentication <- function(.data, - cache_disk = FALSE){ - .data$authenticate <- list( - use_jwt = TRUE, - use_apikey = FALSE, # not supported yet - cache_disk = cache_disk) - .data -} - -#' Internal function to lookup requests for authentication -#' Note this is currently only called on `data_request` objects, and -#' then only before parsing -#' @noRd -#' @keywords Internal -check_authentication <- function(x){ - if( - isTRUE(potions::pour("user", "authenticate", .pkg = "galah")) & - (potions::pour("atlas", "region", .pkg = "galah") == "Australia") & - x$type %in% c("occurrences") # possible to add other allowed queries - ){ - x |> use_authentication() - }else{ - x - } -} - -#' get a client, and if it doesn't exist, make one -#' @noRd -#' @keywords Internal -get_auth_info <- function(error_call = rlang::caller_env()){ - x <- retrieve_cache("client") # this is cached by build_auth_client() - auth_config <- show_all_config() # handle download /retrieval of config info - if(is.null(x)){ - x <- build_auth_client(auth_config) - } - # if still can't get a client, you might be offline - if(is.null(x)){ - cli::cli_abort(c("Unable to generate an authentication client", - i = "You might be offline"), - call = error_call) - } - list(config = auth_config, - client = x) -} - -#' create a client object -#' @noRd -#' @keywords Internal -build_auth_client <- function(config){ - result <- httr2::oauth_client( - id = dplyr::pull(config, "client_id"), - token_url = dplyr::pull(config, "token_url"), - auth = "body", - name = "galah") - update_cache(client = result) - result +#' Add an authentication slot to a query. That slot is then used by later +#' code to determine whether to add an OAuth workflow. It is triggered +#' automatically within [as_query()] if the `authenticate` argument +#' of [galah_config()] is set to `TRUE`, but only for occurrence queries +#' to the Atlas of Living Australia. +#' `r lifecycle::badge("experimental")`. +#' @param .data An object of class `data_request` or `metadata_request` +#' @param cache_disk (logical) Should JWT tokens be cached to disk? Defaults +#' to `FALSE` +#' @returns An object of the same class as supplied, but with an added +#' `authenticate` slot. +#' @export +authenticate <- function(.data, + cache_disk = FALSE){ + update_request_object(.data, + authenticate = list(use_jwt = TRUE, + use_apikey = FALSE, # not supported yet + cache_disk = cache_disk)) } \ No newline at end of file diff --git a/R/check.R b/R/check.R index a7cf95ac..1f0cc5f7 100644 --- a/R/check.R +++ b/R/check.R @@ -26,6 +26,25 @@ check_atlas_inputs <- function(args, request_obj } +#' Internal function to lookup requests for authentication +#' Note this is currently only called on `data_request` objects, and +#' then only before parsing +#' @noRd +#' @keywords Internal +check_authentication <- function(x){ + if(is.null(x$authenticate) & + isTRUE(potions::pour("user", "authenticate", .pkg = "galah")) & + x$type %in% c("occurrences")){ + x <- x |> authenticate() + } + atlas <- potions::pour("atlas", "region", .pkg = "galah") + if(atlas != "Australia" & + !is.null(x$authenticate)){ + cli::cli_warn("Authentication not supported for atlas {atlas}: skipping") + x$authenticate <- NULL + } + x +} #' Internal function to check that the specified path exists, and if not, #' to create it. Called by `galah_config()` @@ -234,7 +253,7 @@ check_field_identities <- function(df, if(any(added_check)){ added_fields <- field_names[added_check] # if authentication has occurred, remove `sensitive_` fields - if(!is.null(.query$authenticate)){ + if(!is.null(.query$request$request$authenticate)){ added_fields <- added_fields[!stringr::str_detect(added_fields, "^sensitive")] } # then, if any remain, warn @@ -477,7 +496,9 @@ check_login <- function(.query, check_password(.query, call = error_call) } }else{ - if(.query$type %in% c("data/occurrences", "data/species")){ + if(.query$type %in% c("data/occurrences", "data/species") & + is.null(.query$request$authenticate) # i.e. only validate if authenticate = FALSE + ){ switch(potions::pour("atlas", "region"), "United Kingdom" = {}, check_email(.query, call = error_call)) diff --git a/R/coalesce.R b/R/coalesce.R index b5d6861d..aed304c0 100644 --- a/R/coalesce.R +++ b/R/coalesce.R @@ -50,11 +50,11 @@ coalesce.metadata_request <- function(x, ...){ result <- list() # handle authentication - if(!is.null(x$authenticate)){ + if(!is.null(x$request$authenticate)){ result <- append(result, list(request_metadata("config") |> as_query())) } - + # add checks if required if(potions::pour("package", "run_checks")){ result <- append(result, @@ -140,9 +140,9 @@ build_query_set_data <- function(x, mint_doi, ...){ result <- list() # handle authentication + browser() if( - (isTRUE(potions::pour("user", "authenticate", .pkg = "galah")) | - !is.null(x$authenticate)) & + !is.null(x$authenticate) & potions::pour("atlas", "region") == "Australia" ){ result <- append(result, diff --git a/R/compute_occurrences.R b/R/compute_occurrences.R index ceab8dc3..c36c6034 100644 --- a/R/compute_occurrences.R +++ b/R/compute_occurrences.R @@ -46,7 +46,7 @@ compute_occurrences_la <- function(.query){ if(potions::pour("package", "verbose")){ n_records <- status_code$total_records cli::cli_par() - if(!is.null(.query$authenticate)){ + if(!is.null(.query$request$authenticate)){ cli::cli_text("Query sent including JWT token") } cli::cli_text("Request for {n_records} occurrences placed in queue") diff --git a/R/galah_config.R b/R/galah_config.R index e9956173..cbd666a3 100644 --- a/R/galah_config.R +++ b/R/galah_config.R @@ -123,14 +123,12 @@ galah_config <- function(...) { # invisibly return x <- potions::pour() - # check_authentication_argument(x) as_galah_config(x) |> invisible() }else{ # visibly return x <- potions::pour() - # check_authentication_argument(x) as_galah_config(x) } } @@ -166,11 +164,17 @@ default_config <- function(){ #' Place new options into correctly nested structure #' @noRd #' @keywords Internal -restructure_config <- function(dots){ - result <- purrr::map(names(dots), - \(a){validate_config(a, - dots[[a]], - error_call = error_call)}) +restructure_config <- function(dots, + error_call= rlang::caller_env()){ + # NOTE: we use `lapply()` here rather than `purrr::map()` ON PURPOSE + # It prevents error messages being prefaced with: + # Error in `purrr::map()` at galah-R/R/galah_config.R:171:3: + # ℹ In index: 1. + # ...which is undesirable + result <- lapply(names(dots), + \(a){validate_config(a, + dots[[a]], + error_call = error_call)}) names(result) <- names(dots) result } @@ -341,31 +345,4 @@ check_atlas <- function(current_data, new_data){ }) } new_data -} - -#' if authentication is requested, cache config info -#' @noRd -#' @keywords Internal -check_authentication_argument <- function(x){ - # NOTE: This is the only place in galah where we _silently_ query - # an API. For safety and clarity reasons, I've added the following steps: - # 1. giving some notice to the user that this has been performed - # 2. adding a warning message if the API call fails - # This is currently deactivated due to changes in workflow - if(isTRUE(purrr::pluck(x, "package", "authenticate")) & # value set to TRUE by user - is.null(retrieve_cache("config")) # not already cached - ){ - cli::cli_text("Caching `config` information to support authentication") - config <- request_metadata(type = "config") |> - collect() |> - try(silent = TRUE) - if(inherits(config, "try-error")){ - c("`galah_config()` tried caching `config` information for authentication purposes, but failed.", - i = "This could mean you are offline or that the API is unavailable.", - i = "To try again, call `show_all_config()` or `galah_config(authenticate = TRUE)`") |> - cli::cli_warn() - } - } -} -# NOTE: Need to add trigger to stop this process for orgs that are not ALA. -# This will mean moving it higher up in the workflow. \ No newline at end of file +} \ No newline at end of file diff --git a/R/query_API.R b/R/query_API.R index 514140e7..7f0c1b69 100644 --- a/R/query_API.R +++ b/R/query_API.R @@ -71,7 +71,7 @@ query_API_internal <- function(.query, add_body(.query$body) # NOTE: adding `body` converts from GET to POST # set authentication behaviour - if(!is.null(.query$authenticate) & + if(!is.null(.query$request$authenticate) & .query$type != "metadata/config" # necessary to prevent circular problems ){ @@ -83,7 +83,7 @@ query_API_internal <- function(.query, auth_url = dplyr::pull(auth_info$config, "authorize_url"), scope = dplyr::pull(auth_info$config, "scopes"), pkce = TRUE, - cache_disk = purrr::pluck(.query, "authenticate", "cache_disk")) + cache_disk = FALSE) # purrr::pluck(.query, "authenticate", "cache_disk")) } # then handle downloads @@ -149,3 +149,35 @@ add_options <- function(req, options){ } req } + +#' get a client, and if it doesn't exist, make one +#' @noRd +#' @keywords Internal +get_auth_info <- function(error_call = rlang::caller_env()){ + x <- retrieve_cache("client") # this is cached by build_auth_client() + auth_config <- show_all_config() # handle download /retrieval of config info + if(is.null(x)){ + x <- build_auth_client(auth_config) + } + # if still can't get a client, you might be offline + if(is.null(x)){ + cli::cli_abort(c("Unable to generate an authentication client", + i = "You might be offline"), + call = error_call) + } + list(config = auth_config, + client = x) +} + +#' create a client object +#' @noRd +#' @keywords Internal +build_auth_client <- function(config){ + result <- httr2::oauth_client( + id = dplyr::pull(config, "client_id"), + token_url = dplyr::pull(config, "token_url"), + auth = "body", + name = "galah") + update_cache(client = result) + result +} \ No newline at end of file diff --git a/man/authenticate.Rd b/man/authenticate.Rd new file mode 100644 index 00000000..b69beb91 --- /dev/null +++ b/man/authenticate.Rd @@ -0,0 +1,26 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/authenticate.R +\name{authenticate} +\alias{authenticate} +\title{Set up authentication} +\usage{ +authenticate(.data, cache_disk = FALSE) +} +\arguments{ +\item{.data}{An object of class \code{data_request} or \code{metadata_request}} + +\item{cache_disk}{(logical) Should JWT tokens be cached to disk? Defaults +to \code{FALSE}} +} +\value{ +An object of the same class as supplied, but with an added +\code{authenticate} slot. +} +\description{ +Add an authentication slot to a query. That slot is then used by later +code to determine whether to add an OAuth workflow. It is triggered +automatically within \code{\link[=as_query]{as_query()}} if the \code{authenticate} argument +of \code{\link[=galah_config]{galah_config()}} is set to \code{TRUE}, but only for occurrence queries +to the Atlas of Living Australia. +\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}}. +} diff --git a/tests/testthat/test-authentication.R b/tests/testthat/test-authentication.R index adfa5cd1..ee222f05 100644 --- a/tests/testthat/test-authentication.R +++ b/tests/testthat/test-authentication.R @@ -21,37 +21,40 @@ test_that("`request_metadata()` caches type `config` correctly", { expect_true(!is.null(result$data)) }) -test_that("`use_authentication()` works in-pipe for metadata", { +test_that("`authenticate()` works in-pipe for metadata", { skip("authentication requires interactivity") galah_config(caching = FALSE) # turn off caching to force galah to call an API query <- request_metadata(type = "reasons") |> - use_authentication() + authenticate() result <- as_query(query) - is.null(result$authenticate) |> + is.null(result$request$authenticate) |> expect_false() result2 <- coalesce(result) expect_equal(length(result2), 2) + purrr::map(result2, \(a){a$type}) |> + unlist() |> + expect_equal(c("metadata/config", "metadata/reasons")) galah_config(caching = TRUE) }) -test_that("`use_authentication()` works in-pipe for occurrences", { +test_that("`authenticate()` works in-pipe for occurrences", { skip("authentication requires interactivity") - galah_config(authenticate = TRUE) - query <- galah_call() |> + query <- galah_call() |> + authenticate() |> identify("Litoria dentata") |> filter(year == 2025) |> coalesce() expect_equal(length(query), 6) - is.null(query[[6]]$authenticate) |> + is.null(query[[6]]$request$authenticate) |> expect_false() x <- collapse(query) x |> - purrr::pluck("authenticate") |> + purrr::pluck("request", "authenticate") |> is.null() |> expect_false() @@ -133,7 +136,7 @@ test_that("setting `authentication` to `TRUE` changes data returned", { # Downloading from a DOI fails # galah_call() |> # filter(doi == "ala.3d0e08ac-d0ec-420d-a1f7-8cde778e82f6") |> -# use_authentication() |> +# authenticate() |> # collect() # May be same problem as previously documented diff --git a/tests/testthat/test-galah_config.R b/tests/testthat/test-galah_config.R index 245d1cd1..6fb8829f 100644 --- a/tests/testthat/test-galah_config.R +++ b/tests/testthat/test-galah_config.R @@ -1,7 +1,6 @@ quiet_config <- purrr::quietly(galah_config) test_that("`galah_config()` gives nice error messages for incorrect arguments", { - # FIXME: error comes from `purrr::map()`, not `galah_config()` expect_error(quiet_config(something = "nothing")) }) From eea7cce99a90004a71d5d45589bcc300029649cc Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Wed, 14 Jan 2026 10:17:44 +1100 Subject: [PATCH 65/94] swap `as_query()` for `capture()`, add new class `prequery()` Previously we only had class `query`, which was returned by both `as_query()` and `collapse()`, making it difficult to determine whether it was necessary to run `coalesce()` under some circumstances. The solution is to have a new class `prequery` which requires `coalesce()`, but allow `capture()` to return `query` objects where `coalesce()` is NOT required. --- NAMESPACE | 12 ++- R/as_query-metadata-unnest.R | 8 +- R/as_query-occurrences.R | 6 +- R/as_query-occurrences_count.R | 4 +- R/as_query-species.R | 2 +- R/as_query-species_count.R | 2 +- R/authenticate.R | 2 +- R/{as_query.R => capture.R} | 90 ++++++++--------- R/coalesce.R | 178 +++++++++++++++++++-------------- R/dplyr-collapse.R | 49 ++++----- R/dplyr-collect.R | 10 +- R/dplyr-compute.R | 2 +- R/galah-package.R | 4 +- R/onload.R | 14 +-- R/print.R | 7 +- _pkgdown.yml | 2 +- man/as_query.data_request.Rd | 85 ---------------- man/authenticate.Rd | 2 +- man/capture.data_request.Rd | 72 +++++++++++++ man/coalesce.Rd | 14 ++- man/collapse.data_request.Rd | 17 ++-- man/collect.data_request.Rd | 10 +- man/compute.data_request.Rd | 2 +- man/galah.Rd | 4 +- man/print_galah_objects.Rd | 3 + 25 files changed, 318 insertions(+), 283 deletions(-) rename R/{as_query.R => capture.R} (82%) delete mode 100644 man/as_query.data_request.Rd create mode 100644 man/capture.data_request.Rd diff --git a/NAMESPACE b/NAMESPACE index 49a408ca..0154f52c 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -3,16 +3,19 @@ S3method(add_count,data_request) S3method(arrange,data_request) S3method(arrange,metadata_request) -S3method(as_query,data_request) -S3method(as_query,files_request) -S3method(as_query,metadata_request) +S3method(capture,data_request) +S3method(capture,files_request) +S3method(capture,metadata_request) S3method(coalesce,data_request) S3method(coalesce,files_request) S3method(coalesce,metadata_request) +S3method(coalesce,prequery) S3method(coalesce,query) +S3method(coalesce,query_set) S3method(collapse,data_request) S3method(collapse,files_request) S3method(collapse,metadata_request) +S3method(collapse,prequery) S3method(collapse,query) S3method(collapse,query_set) S3method(collect,computed_query) @@ -43,6 +46,7 @@ S3method(print,galah_config) S3method(print,metadata_filter) S3method(print,metadata_request) S3method(print,predicates_filter) +S3method(print,prequery) S3method(print,query) S3method(print,query_set) S3method(select,data_request) @@ -57,7 +61,6 @@ export(as_data_filter) export(as_files_filter) export(as_metadata_filter) export(as_predicates_filter) -export(as_query) export(atlas_citation) export(atlas_counts) export(atlas_media) @@ -65,6 +68,7 @@ export(atlas_occurrences) export(atlas_species) export(atlas_taxonomy) export(authenticate) +export(capture) export(coalesce) export(collapse) export(collect) diff --git a/R/as_query-metadata-unnest.R b/R/as_query-metadata-unnest.R index 699587e1..43674e72 100644 --- a/R/as_query-metadata-unnest.R +++ b/R/as_query-metadata-unnest.R @@ -14,8 +14,8 @@ as_query_fields_unnest <- function(.query){ flimit = 10^4) } list(type = "metadata/fields-unnest", - url = httr2::url_build(url)) |> - as_query() + url = httr2::url_build(url)) |> + as_prequery() } #' Internal function to run `as_query()` for @@ -44,7 +44,7 @@ as_query_profiles_unnest <- function(.query){ list(type = "metadata/profiles-unnest", url = url_lookup("metadata/profiles-unnest", profile = .query$filter$value[1])) |> - as_query() + as_prequery() } #' Internal function to `as_query()` for @@ -60,5 +60,5 @@ as_query_taxa_unnest <- function(.query){ list(type = "metadata/taxa-unnest", url = url_lookup("metadata/taxa-unnest", id = id), headers = build_headers()) |> - as_query() + as_prequery() } diff --git a/R/as_query-occurrences.R b/R/as_query-occurrences.R index 394d4806..54758900 100644 --- a/R/as_query-occurrences.R +++ b/R/as_query-occurrences.R @@ -45,7 +45,7 @@ as_query_occurrences_uk <- function(.query, ...){ list(type = "data/occurrences", url = httr2::url_build(url), headers = build_headers()) |> - as_query() + as_prequery() } #' calculate the query to be returned for GBIF @@ -74,7 +74,7 @@ as_query_occurrences_gbif <- function(.query, identify = .query$identify, geolocate = .query$geolocate, format = "SIMPLE_CSV")) |> - as_query() + as_prequery() } #' calculate the query to be returned for a given living atlas @@ -108,5 +108,5 @@ as_query_occurrences_la <- function(.query, list(type = "data/occurrences", url = httr2::url_build(url), headers = build_headers()) |> - as_query() + as_prequery() } diff --git a/R/as_query-occurrences_count.R b/R/as_query-occurrences_count.R index 6500e38e..364b92ae 100644 --- a/R/as_query-occurrences_count.R +++ b/R/as_query-occurrences_count.R @@ -53,7 +53,7 @@ as_query_occurrences_count_atlas <- function(identify = NULL, url = httr2::url_build(url), headers = build_headers()) } - as_query(result) + as_prequery(result) } #' Internal function to parse `slice` and `arrange` for counts @@ -124,5 +124,5 @@ as_query_occurrences_count_gbif <- function(identify = NULL, userpwd = user_string), body = predicates_info, slot_name = "count") |> - as_query() + as_prequery() } diff --git a/R/as_query-species.R b/R/as_query-species.R index 2984f451..d1c03495 100644 --- a/R/as_query-species.R +++ b/R/as_query-species.R @@ -49,7 +49,7 @@ as_query_species_atlas <- function(.query){ url = httr2::url_build(url), headers = build_headers(), download = TRUE) |> - as_query() + as_prequery() } #' parse `select()` for `atlas_species()` diff --git a/R/as_query-species_count.R b/R/as_query-species_count.R index 9b298953..d3bab73f 100644 --- a/R/as_query-species_count.R +++ b/R/as_query-species_count.R @@ -48,5 +48,5 @@ as_query_species_count_atlas <- function(identify = NULL, url = httr2::url_build(url), headers = build_headers()) } - as_query(result) + as_prequery(result) } diff --git a/R/authenticate.R b/R/authenticate.R index ee44e002..94d155af 100644 --- a/R/authenticate.R +++ b/R/authenticate.R @@ -2,7 +2,7 @@ #' #' Add an authentication slot to a query. That slot is then used by later #' code to determine whether to add an OAuth workflow. It is triggered -#' automatically within [as_query()] if the `authenticate` argument +#' automatically within [capture()] if the `authenticate` argument #' of [galah_config()] is set to `TRUE`, but only for occurrence queries #' to the Atlas of Living Australia. #' `r lifecycle::badge("experimental")`. diff --git a/R/as_query.R b/R/capture.R similarity index 82% rename from R/as_query.R rename to R/capture.R index 6607b1b9..faa729f3 100644 --- a/R/as_query.R +++ b/R/capture.R @@ -1,44 +1,34 @@ -#' Convert an object to class `query` +#' Capture a request #' -#' Functionally similar to \code{\link[=collapse.data_request]{collapse()}}, but -#' without passing through [coalesce()] first. Primarily an internal function, -#' but exported for clarity and debugging purposes. +#' The first step in evaluating a request is to capture and parse the +#' information it contains. The resulting object has class `prequery` +#' for those requiring further processing or `query` for those that don't. #' @details #' Typically, queries in galah are piped using [galah_call()], which builds -#' an object of class `"data_request"`, `"metadata_request"` or -#' `"files_request"`. All these objects can be converted to class `"query"` +#' an object of class `"data_request"`; or [request_metadata()] or +#' [request_files()]. All these objects can be converted to class `"query"` #' using \code{\link[=collapse.data_request]{collapse()}}. However, -#' \code{\link[=collapse.data_request]{collapse()}} first calls -#' \code{\link[=coalesce.data_request]{coalesce()}}, which expands to an -#' object of class `"query_set"` _before_ evaluating -#' \code{\link[=collapse.data_request]{collapse()}}. In this context, -#' [as_query()] serves two purposes: externally, it can be called to convert -#' directly to class `"query"` without running checks; and internally it allows -#' a query to be appended to a `"query_set"` without calling causing an -#' infinite loop. -#' +#' properly evaluating a query often requires building and running +#' additional queries to populate or validate the requested information. +#' A `prequery` object shows what has been requested, before those +#' calls are built by [coalesce()] and evaluated by +#' \code{\link[=collapse.data_request]{collapse()}}. #' For simple cases, this gives the same result as running #' \code{\link[=collapse.data_request]{collapse()}} while the `run_checks` -#' argument of [galah_config()] is set to `FALSE`, but is slightly faster. For -#' complex cases, however, it is likely to generate irresolvable API calls, -#' because e.g. taxonomic queries are not parsed before the URL is built. It -#' should therefore be used with care. -#' @name as_query.data_request -#' @param x An object to convert to a `query`. Supported classes are the same -#' as those produced by [galah_call()], namely `data_request`, -#' `metadata_request` or `files_request`. +#' argument of [galah_config()] is set to `FALSE`, but is slightly faster. +#' In complex cases, it is simply a precursor to [coalesce()] +#' @name capture.data_request +#' @param x A `_request` object to convert to a `prequery`. #' @param ... Other arguments, currently ignored #' @order 1 -#' @return An object of class `query`, which is a list-like object containing -#' two or more of the following slots: +#' @return Either an object of class `prequery` when further processing is +#' required; or `query` when it is not. Both classes are structurally identical, +#' being list-like and containing at the following slots: #' #' - `type`: The type of query, serves as a lookup to the corresponding field in `show_all(apis)` #' - `url`: Either: #' - a length-1 character giving the API to be queried; or #' - a `tibble()` containing at least the field `url` and optionally others -#' - `headers`: headers to be sent with the API call -#' - `body`: body section of the API call -#' - `options`: options section of the API call #' - `request`: captures the preceeding `_request` object (see [galah_call()]) #' #' @seealso To open a piped query, see [galah_call()]. For alternative @@ -47,16 +37,16 @@ #' \code{\link[=compute.data_request]{compute()}} or #' \code{\link[=collect.data_request]{collect()}}. #' @export -as_query <- function(x, ...){ - UseMethod("as_query") +capture <- function(x, ...){ + UseMethod("capture") } -#' @rdname as_query.data_request +#' @rdname capture.data_request #' @param mint_doi Logical: should a DOI be minted for this download? Only #' applies to `type = "occurrences"` when atlas chosen is "ALA". #' @order 2 #' @export -as_query.data_request <- function(x, +capture.data_request <- function(x, mint_doi = FALSE, ...){ x <- x |> @@ -75,10 +65,10 @@ as_query.data_request <- function(x, add_request(x) } -#' @rdname as_query.data_request +#' @rdname capture.data_request #' @order 3 #' @export -as_query.metadata_request <- function(x, ...){ +capture.metadata_request <- function(x, ...){ x <- x |> check_authentication() |> enforce_select_query() @@ -107,14 +97,15 @@ as_query.metadata_request <- function(x, ...){ cli::cli_abort("Unrecognised 'type'") ) |> add_request(x) + # FIXME: If authentication is added, this should change from being a `query` to a `prequery` } -#' @rdname as_query.data_request +#' @rdname capture.data_request #' @param thumbnail Logical: should thumbnail-size images be returned? Defaults #' to `FALSE`, indicating full-size images are required. #' @order 4 #' @export -as_query.files_request <- function(x, +capture.files_request <- function(x, thumbnail = FALSE, ...){ # NOTE: switch is technically superfluous right now, but could be useful @@ -128,20 +119,27 @@ as_query.files_request <- function(x, add_request(x) } -#' @rdname as_query.data_request +#' @rdname capture.data_request #' @order 5 -as_query.list <- function(x){ - # TODO add some checks here? +capture.list <- function(x){ + as_prequery(x) +} + +#' Internal function to enforce class `query` +#' @noRd +#' @keywords Internal +as_query <- function(x){ structure(x, class = c("query", "list")) } -#' @rdname as_query.data_request -#' @order 6 -as_query.query <- function(x){ - x +#' Internal function to enforce class `prequery` +#' @noRd +#' @keywords Internal +as_prequery <- function(x){ + structure(x, class = c("prequery", "list")) } -#' Internal function called by `as_query()` +#' Internal function called by `capture()` #' @noRd #' @keywords Internal count_switch <- function(x){ @@ -156,7 +154,7 @@ count_switch <- function(x){ } #' Internal function to check behaviour of `distinct()`, `group_by()` etc. -#' called by `as_query()` +#' called by `capture()` #' @noRd #' @keywords Internal check_distinct_count_groupby <- function(x){ @@ -284,7 +282,7 @@ check_slice_arrange <- function(x){ #' Internal function to enforce `select()` for metadata queries. Basically just #' supplies defaults. This is the *setup* phase as is usually called by -#' `as_query()` +#' `capture()` #' @noRd #' @keywords Internal enforce_select_query <- function(x){ diff --git a/R/coalesce.R b/R/coalesce.R index aed304c0..04912870 100644 --- a/R/coalesce.R +++ b/R/coalesce.R @@ -9,18 +9,18 @@ #' is required to run _before_ the 'final' query is attempted. In relation to #' other functions that manipulate `_request` objects, [coalesce()] is called #' within \code{\link[=collapse.data_request]{collapse()}}, and itself -#' calls [as_query()] internally. +#' calls [capture()] internally where required. #' @rdname coalesce #' @param x An object to be coalesced. Works for `data_request`, #' `metadata_request` and `file_request`. -#' @param ... Other arguments passed to [as_query()]. +#' @param ... Other arguments passed to [capture()]. #' @order 1 #' @return An object of class `query_set`, which is simply a list of all `query` #' objects required to properly evaluate the specified request. Objects are #' listed in the order in which they will be evaluated, meaning the query #' that the user has actually requested will be placed last. #' @seealso To open a piped query, see [galah_call()]. For alternative -#' operations on `_request` objects, see [as_query()], +#' operations on `_request` objects, see [capture()], #' \code{\link[=collapse.data_request]{collapse()}}, #' \code{\link[=compute.data_request]{compute()}} or #' \code{\link[=collect.data_request]{collect()}}. @@ -30,90 +30,120 @@ coalesce <- function(x, ...){ } #' @rdname coalesce -#' @param mint_doi Logical: should a DOI be minted for this download? Only -#' applies to `type = "occurrences"` when atlas chosen is "ALA". #' @order 2 #' @export coalesce.data_request <- function(x, mint_doi, ...){ - if(x$type == "distributions"){ + x |> + capture() |> + coalesce() +} + +#' @rdname coalesce +#' @order 3 +#' @export +coalesce.metadata_request <- function(x, ...){ + x |> + capture() |> + coalesce() +} + +#' @rdname coalesce +#' @order 4 +#' @export +coalesce.files_request <- function(x, + ...){ + x |> + capture() |> + coalesce() +} + +#' @rdname coalesce +#' @param mint_doi Logical: should a DOI be minted for this download? Only +#' applies to `type = "occurrences"`, and only for supported atlases. +#' @order 5 +#' @export +coalesce.prequery <- function(x, mint_doi, ...){ + if(stringr::str_detect(x$type, "^metadata")){ + build_query_set_metadata(x) + }else if(stringr::str_detect(x$type, "^files")){ + list(as_query_media_files(x, ...)) |> + as_query_set() + }else if(x$type == "data/distribtions"){ build_query_set_distributions(x) }else{ - build_query_set_data(x, mint_doi = mint_doi, ...) + build_query_set_data(x, mint_doi = mint_doi, ...) } } #' @rdname coalesce -#' @order 3 +#' @order 6 #' @export -coalesce.metadata_request <- function(x, ...){ +coalesce.query <- function(x, ...){ + list(x) |> + as_query_set() +} + +#' @rdname coalesce +#' @order 7 +#' @export +coalesce.query_set <- function(x, ...){ + x +} + +#' Internal function to routinely apply `query_set` naming +#' @noRd +#' @keywords Internal +as_query_set <- function(x){ + structure(x, class = c("query_set", "list")) +} + +#' Internal function to build a `query_set` object +#' for object of class `data_request` +#' @noRd +#' @keywords Internal +build_query_set_metadata <- function(x){ # create an empty object to store results result <- list() # handle authentication if(!is.null(x$request$authenticate)){ result <- append(result, - list(request_metadata("config") |> as_query())) + list(request_metadata("config") |> capture())) } # add checks if required if(potions::pour("package", "run_checks")){ result <- append(result, - switch(x$type, - "fields-unnest" = list(request_metadata("fields") |> as_query()), - "profiles-unnest" = list(request_metadata("profiles") |> as_query()), + switch(x$request$type, + "fields-unnest" = list(request_metadata("fields") |> capture()), + "profiles-unnest" = list(request_metadata("profiles") |> capture()), NULL)) } # then handle `filter` and `identify` queries, where supported - if(grepl("-unnest$", x$type)){ - if(x$type == "taxa-unnest"){ + if(grepl("-unnest$", x$request$type)){ + if(x$request$type == "taxa-unnest"){ # identify() calls must be parsed, irrespective of `run_checks` (which is parsed above) - if(!is.null(x$identify)){ + if(!is.null(x$request$identify)){ result[[(length(result) + 1)]] <- list(type = "taxa", - identify = x$identify) |> + identify = x$request$identify) |> structure(class = "metadata_request") |> - as_query() + capture() } - if(is.null(x$identify) & is.null(x$filter)){ + if(is.null(x$request$identify) & is.null(x$request$filter)){ cli::cli_abort("Requests of type `taxa-unnest` must also supply one of `filter()` or `identify()`.") } - }else if(is.null(x$filter)){ - current_type <- x$type + }else if(is.null(x$request$filter)){ + current_type <- x$request$type cli::cli_abort("Requests of type `{current_type}` must supply `filter()`.") } } # add query in last place - result[[(length(result) + 1)]] <- as_query(x) + result[[(length(result) + 1)]] <- x # return object of correct class - structure(result, - class = "query_set") -} - -#' @rdname coalesce -#' @order 4 -#' @export -coalesce.files_request <- function(x, - ...){ - # NOTE: switch is technically superfluous right now, but could be useful - # for future file types - list(switch(x$type, - "media" = as_query_media_files(x, ...) - )) |> - structure(class = "query_set") -} - -#' @rdname coalesce -#' @order 5 -#' @export -coalesce.query <- function(x, ...){ - type_extracted <- stringr::str_extract(x$type, "^[[:alnum:]]+/") |> - stringr::str_remove("/$") - switch(type_extracted, - "metadata" = coalesce.metadata_request(x, ...), - "data" = coalesce.data_request(x, ...), - "files" = coalesce.files_request(x, ...)) + as_query_set(result) } #' Internal function to build a `query_set` object @@ -124,15 +154,15 @@ build_query_set_data <- function(x, mint_doi, ...){ # handle DOIs if(!missing(mint_doi)){ - x$mint_doi <- mint_doi + x$request$mint_doi <- mint_doi } # handle sending dois via `filter()` # important this happens first, as it affects `type`, which affects later code - variables <- x$filter$variable # NOTE: breaks for GBIF + variables <- purrr::pluck(x, "request", "filter", "variable") # NOTE: breaks for GBIF if(!is.null(variables)){ if(length(variables) == 1 & variables[1] == "doi"){ - x$type <- "occurrences-doi" + x$request$type <- "occurrences-doi" } } @@ -140,69 +170,69 @@ build_query_set_data <- function(x, mint_doi, ...){ result <- list() # handle authentication - browser() if( - !is.null(x$authenticate) & + !is.null(x$request$authenticate) & potions::pour("atlas", "region") == "Australia" ){ result <- append(result, - list(request_metadata("config") |> as_query())) + list(request_metadata("config") |> capture())) } # handle `run_checks` # find which functions are missing from the pipe lookup_fields <- c("arrange", "filter", "select", "group_by", "distinct") - fields_absent <- !(lookup_fields %in% names(x)) + fields_absent <- !(lookup_fields %in% names(x$request)) names(fields_absent) <- lookup_fields + x_type <- x$request$type if(potions::pour("package", "run_checks") & - x$type != "occurrences-doi"){ + x_type != "occurrences-doi"){ # add check here to see whether any filters are specified # it is possible to only call `identify()`, for example if(any(!fields_absent) | - x$type %in% c("species-count", "species")) { + x_type %in% c("species-count", "species")) { result <- c(result, - list(request_metadata("fields") |> as_query(), - request_metadata("assertions") |> as_query())) + list(request_metadata("fields") |> capture(), + request_metadata("assertions") |> capture())) }else{ # for living atlases, we need `collapse_fields()` to check the `lsid` field # this isn't required for GBIF which doesn't use `fq` for taxon queries - if(!is.null(x$identify) & + if(!is.null(x$request$identify) & !is_gbif()){ result <- c(result, - list(request_metadata("fields") |> as_query())) + list(request_metadata("fields") |> capture())) } } - if(x$type %in% c("occurrences", "media", "species") & + if(x_type %in% c("occurrences", "media", "species") & reasons_supported()) { result[[(length(result) + 1)]] <- request_metadata("reasons") |> - as_query() + capture() } }else{ # if select is required, we need fields even if `run_checks == FALSE` if(!fields_absent[["select"]] | - x$type %in% c("occurrences", "species")){ + x_type %in% c("occurrences", "species")){ result <- c(result, - list(request_metadata("fields") |> as_query(), - request_metadata("assertions") |> as_query())) + list(request_metadata("fields") |> capture(), + request_metadata("assertions") |> capture())) } } # handle `identify()` - if(!is.null(x$identify) & - x$type != "occurrences-doi"){ + if(!is.null(x$request$identify) & + x_type != "occurrences-doi"){ result[[(length(result) + 1)]] <- request_metadata() |> - identify(x$identify) |> - as_query() + identify(x$request$identify) |> + capture() } # handle `apply_profile()` - if(!is.null(x$apply_profile)){ + if(!is.null(x$request$apply_profile)){ result[[(length(result) + 1)]] <- request_metadata("profiles") |> - as_query() + capture() } # handle query - result[[(length(result) + 1)]] <- as_query(x) + result[[(length(result) + 1)]] <- x # return structure(result, class = "query_set") diff --git a/R/dplyr-collapse.R b/R/dplyr-collapse.R index 2be6f675..72292e5b 100644 --- a/R/dplyr-collapse.R +++ b/R/dplyr-collapse.R @@ -10,9 +10,9 @@ #' @order 1 #' @param x An object to run `collapse()` on. Classes supported by `galah` #' include `data_request`, `metadata_request` and `files_request` for building -#' queries; and `query` or `query_set` once constructed (via [as_query()] or -#' [coalesce()]). -#' @param ... Arguments passed on to [as_query()]. +#' queries; and `prequery`, `query` or `query_set` once constructed (via +#' [capture()] or [coalesce()]). +#' @param ... Arguments passed on to [capture()]. #' @return An object of class `query`, which is a list-like object containing #' two or more of the following slots: #' @@ -23,43 +23,44 @@ #' - `headers`: headers to be sent with the API call #' - `body`: body section of the API call #' - `options`: options section of the API call -#' - Any other information retained from the preceeding `_request` object (see [galah_call()]) +#' - Any other information retained from the preceeding `_request` object (see [capture()]) #' #' @seealso To open a piped query, see [galah_call()]. For alternative -#' operations on `_request` objects, see [as_query()], [coalesce()], +#' operations on `_request` objects, see [capture()], [coalesce()], #' \code{\link[=compute.data_request]{compute()}} or #' \code{\link[=collect.data_request]{collect()}}. #' @export collapse.data_request <- function(x, ...){ - coalesce(x, ...) |> + x |> + coalesce(...) |> collapse() } -# if calling `collapse()` after `request_metadata()` #' @rdname collapse.data_request #' @order 2 #' @export -collapse.metadata_request <- function(x, - ...){ - coalesce(x, ...) |> - collapse() -} +collapse.metadata_request <- collapse.data_request -# if calling `collapse()` after `request_files()` #' @rdname collapse.data_request #' @order 3 #' @export -collapse.files_request <- function(x, - ... - ){ - # convert to `query_set` then parse - coalesce(x, ...) |> - collapse() +collapse.files_request <- collapse.data_request + +#' @rdname collapse.data_request +#' @order 4 +#' @export +collapse.prequery <- collapse.data_request + +#' @rdname collapse.data_request +#' @order 5 +#' @export +collapse.query <- function(x, ...){ + x } # if calling `collapse()` after `coalesce()` #' @rdname collapse.data_request -#' @order 4 +#' @order 6 #' @export collapse.query_set <- function(x, ...){ # note: files requests do not need to call build_checks() @@ -72,12 +73,4 @@ collapse.query_set <- function(x, ...){ collapse_run_checks() |> collapse_query_set() } -} - -# if calling `collapse()` after `as_query()` -#' @rdname collapse.data_request -#' @order 4 -#' @export -collapse.query <- function(x, ...){ - x } \ No newline at end of file diff --git a/R/dplyr-collect.R b/R/dplyr-collect.R index 7e71e2c1..23c88806 100644 --- a/R/dplyr-collect.R +++ b/R/dplyr-collect.R @@ -5,10 +5,12 @@ #' @name collect.data_request #' @order 1 #' @param x An object of class `data_request`, `metadata_request` or -#' `files_request` (from `galah_call()`); or an object of class `query_set` or -#' `query` (from `collapse()` or `compute()`) +#' `files_request` (from `galah_call()`); or an object of class `prequery`, +#' `query_set` or `query` (from [capture()], +#' \code{\link[=collapse.data_request]{collapse()}} +#' or \code{\link[=compute.data_request]{compute()}}) #' @param ... Arguments passed on to other methods -#' @param wait logical; should `galah` wait for a response? Defaults to FALSE. +#' @param wait logical; should `galah` wait for a response? Defaults to `FALSE`. #' Only applies for `type = "occurrences"` or `"species"`. #' @param file (Optional) file name. If not given, will be set to `data` with #' date and time added. The file path (directory) is always given by @@ -18,7 +20,7 @@ #' `wait` is set to `FALSE`), this function returns an object of class `query` #' that can be used to recheck the download at a later time. #' @seealso To open a piped query, see [galah_call()]. For alternative -#' operations on `_request` objects, see [as_query()], [coalesce()], +#' operations on `_request` objects, see [capture()], [coalesce()], #' \code{\link[=collapse.data_request]{collapse()}} or #' \code{\link[=compute.data_request]{compute()}}. #' @export diff --git a/R/dplyr-compute.R b/R/dplyr-compute.R index fe88cedf..d676fed0 100644 --- a/R/dplyr-compute.R +++ b/R/dplyr-compute.R @@ -18,7 +18,7 @@ #' `query` except for occurrence data, where it also contains information on the #' status of the request. #' @seealso To open a piped query, see [galah_call()]. For alternative -#' operations on `_request` objects, see [as_query()], [coalesce()], +#' operations on `_request` objects, see [capture()], [coalesce()], #' \code{\link[=collapse.data_request]{collapse()}}, #' \code{\link[=collect.data_request]{collect()}}. #' @export diff --git a/R/galah-package.R b/R/galah-package.R index 32cc52bc..e37793e0 100644 --- a/R/galah-package.R +++ b/R/galah-package.R @@ -42,8 +42,8 @@ #' #' **Create and execute a query** #' -#' * [as_query()] Convert a request into a `query` object -#' * [coalesce()] Convert a `data_request` or `query` into a `query_set` showing all calls needed for evaluation +#' * [capture()] Convert a request into a `prequery` or `query` +#' * [coalesce()] Convert an object into a `query_set` showing all calls needed for evaluation #' * \code{\link[=collapse.data_request]{collapse()}} Convert an object to a valid `query` #' * \code{\link[=compute.data_request]{compute()}} Compute a query #' * \code{\link[=collect.data_request]{collect()}} Retrieve a database query diff --git a/R/onload.R b/R/onload.R index ec501895..55738f48 100644 --- a/R/onload.R +++ b/R/onload.R @@ -17,12 +17,14 @@ silent = TRUE)) # show currently-selected atlas - current_node <- potions::pour("atlas", .pkg = "galah") |> - purrr::pluck("acronym") - current_url <- show_all_atlases() |> - dplyr::filter(.data$acronym == current_node) |> - dplyr::pull("url") |> - stringr::str_replace("^https://", "") + current_node <- "ALA" + current_url <- "none" + #current_node <- potions::pour("atlas", .pkg = "galah") |> + # purrr::pluck("acronym") + #current_url <- show_all_atlases() |> + # dplyr::filter(.data$acronym == current_node) |> + # dplyr::pull("url") |> + # stringr::str_replace("^https://", "") # display a message # NOTE: This message *must* have the following classes to enable them diff --git a/R/print.R b/R/print.R index 91b40155..cb1db2e8 100644 --- a/R/print.R +++ b/R/print.R @@ -168,10 +168,15 @@ print.query <- function(x, ...){ print_list <- print_list[!unlist(purrr::map(print_list, is.null))] # print - cli::cli_text("Object of class {galah_pink(\"query\")} with type {galah_green(x$type)}") + class_tr <- class(x)[1] + cli::cli_text("Object of class {galah_pink(class_tr)} with type {galah_green(x$type)}") cli::cli_li(print_list) } +#' @rdname print_galah_objects +#' @export +print.prequery <- print.query + #' @rdname print_galah_objects #' @export print.computed_query <- function(x, ...){ diff --git a/_pkgdown.yml b/_pkgdown.yml index aef60e47..208b8346 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -64,7 +64,7 @@ reference: - unnest - title: Create and execute a query contents: - - as_query.data_request + - capture - coalesce - collapse.data_request - compute.data_request diff --git a/man/as_query.data_request.Rd b/man/as_query.data_request.Rd deleted file mode 100644 index 89b53a12..00000000 --- a/man/as_query.data_request.Rd +++ /dev/null @@ -1,85 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/as_query.R -\name{as_query.data_request} -\alias{as_query.data_request} -\alias{as_query} -\alias{as_query.metadata_request} -\alias{as_query.files_request} -\alias{as_query.list} -\alias{as_query.query} -\title{Convert an object to class \code{query}} -\usage{ -as_query(x, ...) - -\method{as_query}{data_request}(x, mint_doi = FALSE, ...) - -\method{as_query}{metadata_request}(x, ...) - -\method{as_query}{files_request}(x, thumbnail = FALSE, ...) - -\method{as_query}{list}(x) - -\method{as_query}{query}(x) -} -\arguments{ -\item{x}{An object to convert to a \code{query}. Supported classes are the same -as those produced by \code{\link[=galah_call]{galah_call()}}, namely \code{data_request}, -\code{metadata_request} or \code{files_request}.} - -\item{...}{Other arguments, currently ignored} - -\item{mint_doi}{Logical: should a DOI be minted for this download? Only -applies to \code{type = "occurrences"} when atlas chosen is "ALA".} - -\item{thumbnail}{Logical: should thumbnail-size images be returned? Defaults -to \code{FALSE}, indicating full-size images are required.} -} -\value{ -An object of class \code{query}, which is a list-like object containing -two or more of the following slots: -\itemize{ -\item \code{type}: The type of query, serves as a lookup to the corresponding field in \code{show_all(apis)} -\item \code{url}: Either: -\itemize{ -\item a length-1 character giving the API to be queried; or -\item a \code{tibble()} containing at least the field \code{url} and optionally others -} -\item \code{headers}: headers to be sent with the API call -\item \code{body}: body section of the API call -\item \code{options}: options section of the API call -\item \code{request}: captures the preceeding \verb{_request} object (see \code{\link[=galah_call]{galah_call()}}) -} -} -\description{ -Functionally similar to \code{\link[=collapse.data_request]{collapse()}}, but -without passing through \code{\link[=coalesce]{coalesce()}} first. Primarily an internal function, -but exported for clarity and debugging purposes. -} -\details{ -Typically, queries in galah are piped using \code{\link[=galah_call]{galah_call()}}, which builds -an object of class \code{"data_request"}, \code{"metadata_request"} or -\code{"files_request"}. All these objects can be converted to class \code{"query"} -using \code{\link[=collapse.data_request]{collapse()}}. However, -\code{\link[=collapse.data_request]{collapse()}} first calls -\code{\link[=coalesce.data_request]{coalesce()}}, which expands to an -object of class \code{"query_set"} \emph{before} evaluating -\code{\link[=collapse.data_request]{collapse()}}. In this context, -\code{\link[=as_query]{as_query()}} serves two purposes: externally, it can be called to convert -directly to class \code{"query"} without running checks; and internally it allows -a query to be appended to a \code{"query_set"} without calling causing an -infinite loop. - -For simple cases, this gives the same result as running -\code{\link[=collapse.data_request]{collapse()}} while the \code{run_checks} -argument of \code{\link[=galah_config]{galah_config()}} is set to \code{FALSE}, but is slightly faster. For -complex cases, however, it is likely to generate irresolvable API calls, -because e.g. taxonomic queries are not parsed before the URL is built. It -should therefore be used with care. -} -\seealso{ -To open a piped query, see \code{\link[=galah_call]{galah_call()}}. For alternative -operations on \verb{_request} objects, see \code{\link[=coalesce]{coalesce()}}, -\code{\link[=collapse.data_request]{collapse()}}, -\code{\link[=compute.data_request]{compute()}} or -\code{\link[=collect.data_request]{collect()}}. -} diff --git a/man/authenticate.Rd b/man/authenticate.Rd index b69beb91..0cd36fcd 100644 --- a/man/authenticate.Rd +++ b/man/authenticate.Rd @@ -19,7 +19,7 @@ An object of the same class as supplied, but with an added \description{ Add an authentication slot to a query. That slot is then used by later code to determine whether to add an OAuth workflow. It is triggered -automatically within \code{\link[=as_query]{as_query()}} if the \code{authenticate} argument +automatically within \code{\link[=capture]{capture()}} if the \code{authenticate} argument of \code{\link[=galah_config]{galah_config()}} is set to \code{TRUE}, but only for occurrence queries to the Atlas of Living Australia. \ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}}. diff --git a/man/capture.data_request.Rd b/man/capture.data_request.Rd new file mode 100644 index 00000000..62884c19 --- /dev/null +++ b/man/capture.data_request.Rd @@ -0,0 +1,72 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/capture.R +\name{capture.data_request} +\alias{capture.data_request} +\alias{capture} +\alias{capture.metadata_request} +\alias{capture.files_request} +\alias{capture.list} +\title{Capture a request} +\usage{ +capture(x, ...) + +\method{capture}{data_request}(x, mint_doi = FALSE, ...) + +\method{capture}{metadata_request}(x, ...) + +\method{capture}{files_request}(x, thumbnail = FALSE, ...) + +\method{capture}{list}(x) +} +\arguments{ +\item{x}{A \verb{_request} object to convert to a \code{prequery}.} + +\item{...}{Other arguments, currently ignored} + +\item{mint_doi}{Logical: should a DOI be minted for this download? Only +applies to \code{type = "occurrences"} when atlas chosen is "ALA".} + +\item{thumbnail}{Logical: should thumbnail-size images be returned? Defaults +to \code{FALSE}, indicating full-size images are required.} +} +\value{ +Either an object of class \code{prequery} when further processing is +required; or \code{query} when it is not. Both classes are structurally identical, +being list-like and containing at the following slots: +\itemize{ +\item \code{type}: The type of query, serves as a lookup to the corresponding field in \code{show_all(apis)} +\item \code{url}: Either: +\itemize{ +\item a length-1 character giving the API to be queried; or +\item a \code{tibble()} containing at least the field \code{url} and optionally others +} +\item \code{request}: captures the preceeding \verb{_request} object (see \code{\link[=galah_call]{galah_call()}}) +} +} +\description{ +The first step in evaluating a request is to capture and parse the +information it contains. The resulting object has class \code{prequery} +for those requiring further processing or \code{query} for those that don't. +} +\details{ +Typically, queries in galah are piped using \code{\link[=galah_call]{galah_call()}}, which builds +an object of class \code{"data_request"}; or \code{\link[=request_metadata]{request_metadata()}} or +\code{\link[=request_files]{request_files()}}. All these objects can be converted to class \code{"query"} +using \code{\link[=collapse.data_request]{collapse()}}. However, +properly evaluating a query often requires building and running +additional queries to populate or validate the requested information. +A \code{prequery} object shows what has been requested, before those +calls are built by \code{\link[=coalesce]{coalesce()}} and evaluated by +\code{\link[=collapse.data_request]{collapse()}}. +For simple cases, this gives the same result as running +\code{\link[=collapse.data_request]{collapse()}} while the \code{run_checks} +argument of \code{\link[=galah_config]{galah_config()}} is set to \code{FALSE}, but is slightly faster. +In complex cases, it is simply a precursor to \code{\link[=coalesce]{coalesce()}} +} +\seealso{ +To open a piped query, see \code{\link[=galah_call]{galah_call()}}. For alternative +operations on \verb{_request} objects, see \code{\link[=coalesce]{coalesce()}}, +\code{\link[=collapse.data_request]{collapse()}}, +\code{\link[=compute.data_request]{compute()}} or +\code{\link[=collect.data_request]{collect()}}. +} diff --git a/man/coalesce.Rd b/man/coalesce.Rd index 69ec7881..413ef40f 100644 --- a/man/coalesce.Rd +++ b/man/coalesce.Rd @@ -5,7 +5,9 @@ \alias{coalesce.data_request} \alias{coalesce.metadata_request} \alias{coalesce.files_request} +\alias{coalesce.prequery} \alias{coalesce.query} +\alias{coalesce.query_set} \title{Force evaluation of a database query} \usage{ coalesce(x, ...) @@ -16,16 +18,20 @@ coalesce(x, ...) \method{coalesce}{files_request}(x, ...) +\method{coalesce}{prequery}(x, mint_doi, ...) + \method{coalesce}{query}(x, ...) + +\method{coalesce}{query_set}(x, ...) } \arguments{ \item{x}{An object to be coalesced. Works for \code{data_request}, \code{metadata_request} and \code{file_request}.} -\item{...}{Other arguments passed to \code{\link[=as_query]{as_query()}}.} +\item{...}{Other arguments passed to \code{\link[=capture]{capture()}}.} \item{mint_doi}{Logical: should a DOI be minted for this download? Only -applies to \code{type = "occurrences"} when atlas chosen is "ALA".} +applies to \code{type = "occurrences"}, and only for supported atlases.} } \value{ An object of class \code{query_set}, which is simply a list of all \code{query} @@ -43,11 +49,11 @@ the user's query includes a call to is required to run \emph{before} the 'final' query is attempted. In relation to other functions that manipulate \verb{_request} objects, \code{\link[=coalesce]{coalesce()}} is called within \code{\link[=collapse.data_request]{collapse()}}, and itself -calls \code{\link[=as_query]{as_query()}} internally. +calls \code{\link[=capture]{capture()}} internally where required. } \seealso{ To open a piped query, see \code{\link[=galah_call]{galah_call()}}. For alternative -operations on \verb{_request} objects, see \code{\link[=as_query]{as_query()}}, +operations on \verb{_request} objects, see \code{\link[=capture]{capture()}}, \code{\link[=collapse.data_request]{collapse()}}, \code{\link[=compute.data_request]{compute()}} or \code{\link[=collect.data_request]{collect()}}. diff --git a/man/collapse.data_request.Rd b/man/collapse.data_request.Rd index 3da1fe88..284fa312 100644 --- a/man/collapse.data_request.Rd +++ b/man/collapse.data_request.Rd @@ -4,8 +4,9 @@ \alias{collapse.data_request} \alias{collapse.metadata_request} \alias{collapse.files_request} -\alias{collapse.query_set} +\alias{collapse.prequery} \alias{collapse.query} +\alias{collapse.query_set} \title{Generate a query} \usage{ \method{collapse}{data_request}(x, ...) @@ -14,17 +15,19 @@ \method{collapse}{files_request}(x, ...) -\method{collapse}{query_set}(x, ...) +\method{collapse}{prequery}(x, ...) \method{collapse}{query}(x, ...) + +\method{collapse}{query_set}(x, ...) } \arguments{ \item{x}{An object to run \code{collapse()} on. Classes supported by \code{galah} include \code{data_request}, \code{metadata_request} and \code{files_request} for building -queries; and \code{query} or \code{query_set} once constructed (via \code{\link[=as_query]{as_query()}} or -\code{\link[=coalesce]{coalesce()}}).} +queries; and \code{prequery}, \code{query} or \code{query_set} once constructed (via +\code{\link[=capture]{capture()}} or \code{\link[=coalesce]{coalesce()}}).} -\item{...}{Arguments passed on to \code{\link[=as_query]{as_query()}}.} +\item{...}{Arguments passed on to \code{\link[=capture]{capture()}}.} } \value{ An object of class \code{query}, which is a list-like object containing @@ -39,7 +42,7 @@ two or more of the following slots: \item \code{headers}: headers to be sent with the API call \item \code{body}: body section of the API call \item \code{options}: options section of the API call -\item Any other information retained from the preceeding \verb{_request} object (see \code{\link[=galah_call]{galah_call()}}) +\item Any other information retained from the preceeding \verb{_request} object (see \code{\link[=capture]{capture()}}) } } \description{ @@ -52,7 +55,7 @@ the \code{method} argument. } \seealso{ To open a piped query, see \code{\link[=galah_call]{galah_call()}}. For alternative -operations on \verb{_request} objects, see \code{\link[=as_query]{as_query()}}, \code{\link[=coalesce]{coalesce()}}, +operations on \verb{_request} objects, see \code{\link[=capture]{capture()}}, \code{\link[=coalesce]{coalesce()}}, \code{\link[=compute.data_request]{compute()}} or \code{\link[=collect.data_request]{collect()}}. } diff --git a/man/collect.data_request.Rd b/man/collect.data_request.Rd index 2a6bccfc..d6e68c19 100644 --- a/man/collect.data_request.Rd +++ b/man/collect.data_request.Rd @@ -23,12 +23,14 @@ } \arguments{ \item{x}{An object of class \code{data_request}, \code{metadata_request} or -\code{files_request} (from \code{galah_call()}); or an object of class \code{query_set} or -\code{query} (from \code{collapse()} or \code{compute()})} +\code{files_request} (from \code{galah_call()}); or an object of class \code{prequery}, +\code{query_set} or \code{query} (from \code{\link[=capture]{capture()}}, +\code{\link[=collapse.data_request]{collapse()}} +or \code{\link[=compute.data_request]{compute()}})} \item{...}{Arguments passed on to other methods} -\item{wait}{logical; should \code{galah} wait for a response? Defaults to FALSE. +\item{wait}{logical; should \code{galah} wait for a response? Defaults to \code{FALSE}. Only applies for \code{type = "occurrences"} or \code{"species"}.} \item{file}{(Optional) file name. If not given, will be set to \code{data} with @@ -47,7 +49,7 @@ default way to end a piped query begun with \code{\link[=galah_call]{galah_call( } \seealso{ To open a piped query, see \code{\link[=galah_call]{galah_call()}}. For alternative -operations on \verb{_request} objects, see \code{\link[=as_query]{as_query()}}, \code{\link[=coalesce]{coalesce()}}, +operations on \verb{_request} objects, see \code{\link[=capture]{capture()}}, \code{\link[=coalesce]{coalesce()}}, \code{\link[=collapse.data_request]{collapse()}} or \code{\link[=compute.data_request]{compute()}}. } diff --git a/man/compute.data_request.Rd b/man/compute.data_request.Rd index 45283793..d26088b0 100644 --- a/man/compute.data_request.Rd +++ b/man/compute.data_request.Rd @@ -42,7 +42,7 @@ prevents execution of new code until the server-side process is complete. } \seealso{ To open a piped query, see \code{\link[=galah_call]{galah_call()}}. For alternative -operations on \verb{_request} objects, see \code{\link[=as_query]{as_query()}}, \code{\link[=coalesce]{coalesce()}}, +operations on \verb{_request} objects, see \code{\link[=capture]{capture()}}, \code{\link[=coalesce]{coalesce()}}, \code{\link[=collapse.data_request]{collapse()}}, \code{\link[=collect.data_request]{collect()}}. } diff --git a/man/galah.Rd b/man/galah.Rd index 5d4bb4f9..34f1a0e8 100644 --- a/man/galah.Rd +++ b/man/galah.Rd @@ -49,8 +49,8 @@ For those outside Australia, 'galah' is the common name of \strong{Create and execute a query} \itemize{ -\item \code{\link[=as_query]{as_query()}} Convert a request into a \code{query} object -\item \code{\link[=coalesce]{coalesce()}} Convert a \code{data_request} or \code{query} into a \code{query_set} showing all calls needed for evaluation +\item \code{\link[=capture]{capture()}} Convert a request into a \code{prequery} or \code{query} +\item \code{\link[=coalesce]{coalesce()}} Convert an object into a \code{query_set} showing all calls needed for evaluation \item \code{\link[=collapse.data_request]{collapse()}} Convert an object to a valid \code{query} \item \code{\link[=compute.data_request]{compute()}} Compute a query \item \code{\link[=collect.data_request]{collect()}} Retrieve a database query diff --git a/man/print_galah_objects.Rd b/man/print_galah_objects.Rd index d7b8b7d0..bc497739 100644 --- a/man/print_galah_objects.Rd +++ b/man/print_galah_objects.Rd @@ -6,6 +6,7 @@ \alias{print.files_request} \alias{print.metadata_request} \alias{print.query} +\alias{print.prequery} \alias{print.computed_query} \alias{print.query_set} \alias{print.galah_config} @@ -19,6 +20,8 @@ \method{print}{query}(x, ...) +\method{print}{prequery}(x, ...) + \method{print}{computed_query}(x, ...) \method{print}{query_set}(x, ...) From ac9a235c4b0ef12485f3b4ac330eafb0eef596ab Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Wed, 14 Jan 2026 10:38:21 +1100 Subject: [PATCH 66/94] rename files and internal functions from `as_query_` prefix to `capture_` --- R/as_query-occurrences_doi.R | 46 -------------- R/capture.R | 56 ++++++++--------- ...istributions.R => capture_distributions.R} | 6 +- R/{as_query-files.R => capture_files.R} | 4 +- R/{as_query-metadata.R => capture_metadata.R} | 30 ++++----- ...ata-unnest.R => capture_metadata_unnest.R} | 16 ++--- ...ry-occurrences.R => capture_occurrences.R} | 63 ++++++++++++++++--- ...es_count.R => capture_occurrences_count.R} | 20 +++--- R/{as_query-species.R => capture_species.R} | 12 ++-- ...pecies_count.R => capture_species_count.R} | 10 +-- R/{as_query-taxa.R => capture_taxa.R} | 14 ++--- R/onload.R | 14 ++--- 12 files changed, 145 insertions(+), 146 deletions(-) delete mode 100644 R/as_query-occurrences_doi.R rename R/{as_query-distributions.R => capture_distributions.R} (88%) rename R/{as_query-files.R => capture_files.R} (96%) rename R/{as_query-metadata.R => capture_metadata.R} (94%) rename R/{as_query-metadata-unnest.R => capture_metadata_unnest.R} (82%) rename R/{as_query-occurrences.R => capture_occurrences.R} (65%) rename R/{as_query-occurrences_count.R => capture_occurrences_count.R} (89%) rename R/{as_query-species.R => capture_species.R} (88%) rename R/{as_query-species_count.R => capture_species_count.R} (87%) rename R/{as_query-taxa.R => capture_taxa.R} (91%) diff --git a/R/as_query-occurrences_doi.R b/R/as_query-occurrences_doi.R deleted file mode 100644 index f85a5156..00000000 --- a/R/as_query-occurrences_doi.R +++ /dev/null @@ -1,46 +0,0 @@ -#' Internal function to convert `data_request` with `type = "doi"` to a `query` -#' @noRd -#' @keywords Internal -as_query_occurrences_doi <- function(.query, - error_call = rlang::caller_env()){ - if(is.null(.query$filter)){ - cli::cli_abort("A DOI must be specified using `filter(doi == \"my-doi-here\")`.", - call = error_call) - } - - if(is.null(.query$filter$variable) && .query$filter$variable != "doi"){ - cli::cli_abort("No DOI has been supplied.", - call = error_call) - } - - atlas <- potions::pour("atlas", "acronym") - if(!(atlas %in% c("ALA", "GBIF"))){ - c( - "DOI downloads not supported by selected atlas.", - i = "`request_data(type = \"occurrences-doi\")` has only been implemented for ALA & GBIF") |> - cli::cli_abort(call = error_call) - } - - doi <- .query$filter$value[[1]] - - # remove "https://" if present - if (grepl("^http://doi.org/", doi)) { - doi <- sub("^https://doi.org/", "", doi) - } - - # extract useful part of DOI - doi_str <- stringr::str_split(doi, "ala.")[[1]][2] - if(is.na(doi_str)){ - c( - "DOI has not been generated by the ALA.", - i = "DOIs created by the ALA have a prefix of 10.26197/ala.") |> - cli::cli_abort(call = error_call) - } - - list(type = "data/occurrences-doi", - url = url_lookup("data/occurrences-doi", - doi_string = doi_str), - headers = build_headers(), - download = TRUE) |> - as_query() -} diff --git a/R/capture.R b/R/capture.R index faa729f3..d625727b 100644 --- a/R/capture.R +++ b/R/capture.R @@ -55,12 +55,12 @@ capture.data_request <- function(x, check_slice_arrange() |> enforce_select_query() switch(x$type, - "occurrences" = as_query_occurrences(x, mint_doi = mint_doi), - "occurrences-count" = as_query_occurrences_count(x), - "occurrences-doi" = as_query_occurrences_doi(x), - "species" = as_query_species(x), - "species-count" = as_query_species_count(x), - "distributions" = as_query_distributions_data(x), + "occurrences" = capture_occurrences(x, mint_doi = mint_doi), + "occurrences-count" = capture_occurrences_count(x), + "occurrences-doi" = capture_occurrences_doi(x), + "species" = capture_species(x), + "species-count" = capture_species_count(x), + "distributions" = capture_distributions_data(x), cli::cli_abort("Unrecognised 'type'")) |> add_request(x) } @@ -73,27 +73,27 @@ capture.metadata_request <- function(x, ...){ check_authentication() |> enforce_select_query() switch(x$type, - "apis" = as_query_apis(x), - "assertions" = as_query_assertions(x), - "atlases" = as_query_atlases(x), - "collections" = as_query_collections(x), - "config" = as_query_config(x), - "datasets" = as_query_datasets(x), - "distributions" = as_query_distributions_metadata(x), - "fields" = as_query_fields(x), - "fields-unnest" = as_query_fields_unnest(x), - "licences" = as_query_licences(x), - "lists" = as_query_lists(x), - "lists-unnest" = as_query_lists_unnest(x), - "media" = as_query_media_metadata(x), - "profiles" = as_query_profiles(x), - "profiles-unnest" = as_query_profiles_unnest(x), - "providers" = as_query_providers(x), - "ranks" = as_query_ranks(x), - "reasons" = as_query_reasons(x), - "taxa" = as_query_taxa(x), - "taxa-unnest" = as_query_taxa_unnest(x), - "identifiers" = as_query_identifiers(x), + "apis" = capture_apis(x), + "assertions" = capture_assertions(x), + "atlases" = capture_atlases(x), + "collections" = capture_collections(x), + "config" = capture_config(x), + "datasets" = capture_datasets(x), + "distributions" = capture_distributions_metadata(x), + "fields" = capture_fields(x), + "fields-unnest" = capture_fields_unnest(x), + "licences" = capture_licences(x), + "lists" = capture_lists(x), + "lists-unnest" = capture_lists_unnest(x), + "media" = capture_media_metadata(x), + "profiles" = capture_profiles(x), + "profiles-unnest" = capture_profiles_unnest(x), + "providers" = capture_providers(x), + "ranks" = capture_ranks(x), + "reasons" = capture_reasons(x), + "taxa" = capture_taxa(x), + "taxa-unnest" = capture_taxa_unnest(x), + "identifiers" = capture_identifiers(x), cli::cli_abort("Unrecognised 'type'") ) |> add_request(x) @@ -113,7 +113,7 @@ capture.files_request <- function(x, # This code is identical to `collapse.files_request()` switch(x$type, - "media" = as_query_media_files(x, + "media" = capture_media_files(x, thumbnail = thumbnail), cli::cli_abort("Unrecognised 'type'")) |> add_request(x) diff --git a/R/as_query-distributions.R b/R/capture_distributions.R similarity index 88% rename from R/as_query-distributions.R rename to R/capture_distributions.R index dfa47efb..7d33404e 100644 --- a/R/as_query-distributions.R +++ b/R/capture_distributions.R @@ -1,7 +1,7 @@ -#' Internal function to run `as_query()` for type `data/distributions` +#' Internal function to run `capture()` for type `data/distributions` #' @noRd #' @keywords Internal -as_query_distributions_data <- function(.query, +capture_distributions_data <- function(.query, error_call = rlang::caller_env()){ identify_supplied <- !is.null(.query$identify) filter_supplied <- !is.null(.query$filter) @@ -31,7 +31,7 @@ as_query_distributions_data <- function(.query, #' Internal function to create a distributions query #' @noRd #' @keywords Internal -as_query_distributions_metadata <- function(.query){ +capture_distributions_metadata <- function(.query){ url <- url_lookup("metadata/distributions") result <- list(type = "metadata/distributions", url = url, diff --git a/R/as_query-files.R b/R/capture_files.R similarity index 96% rename from R/as_query-files.R rename to R/capture_files.R index 4bbfa239..0fe0f02b 100644 --- a/R/as_query-files.R +++ b/R/capture_files.R @@ -1,8 +1,8 @@ -#' Internal version of `as_query()` for `request_files(type = "media")` +#' Internal version of `capture()` for `request_files(type = "media")` #' @param .query An object of class `files_request` (from `request_files()`) #' @noRd #' @keywords Internal -as_query_media_files <- function(.query, +capture_media_files <- function(.query, thumbnail = FALSE, error_call = rlang::caller_env() ){ diff --git a/R/as_query-metadata.R b/R/capture_metadata.R similarity index 94% rename from R/as_query-metadata.R rename to R/capture_metadata.R index 1b1fc8a7..72f578cc 100644 --- a/R/as_query-metadata.R +++ b/R/capture_metadata.R @@ -1,4 +1,4 @@ -# These functions are called by `as_query.metadata_request()` +# These functions are called by `capture.metadata_request()` # Utility functions to build default forms of query @@ -39,7 +39,7 @@ filtered_query <- function(query_type, .query){ #' Internal function get a tibble of APIs #' @noRd #' @keywords Internal -as_query_apis <- function(x){ +capture_apis <- function(x){ list(type = "metadata/apis", data = "galah:::node_config") |> as_query() @@ -49,7 +49,7 @@ as_query_apis <- function(x){ #' NOTE: API doesn't accept any arguments - could post-filter for search #' @noRd #' @keywords Internal -as_query_assertions <- function(x){ +capture_assertions <- function(x){ query_type <- "metadata/assertions" if(is_gbif()){ result <- list(type = query_type, @@ -68,7 +68,7 @@ as_query_assertions <- function(x){ #' Internal function to create an atlases query #' @noRd #' @keywords Internal -as_query_atlases <- function(x){ +capture_atlases <- function(x){ list(type = "metadata/atlases", data = "galah:::node_metadata") |> as_query() @@ -77,7 +77,7 @@ as_query_atlases <- function(x){ #' Internal function to create a collections query #' @noRd #' @keywords Internal -as_query_collections <- function(x){ +capture_collections <- function(x){ # set `type` query_type <- "metadata/collections" # If `filter()` is supplied, we always need a query @@ -104,7 +104,7 @@ as_query_collections <- function(x){ #' Internal function to create an auth-config query #' @noRd #' @keywords Internal -as_query_config <- function(x){ +capture_config <- function(x){ query_type <- "metadata/config" if(check_if_cache_update_needed("config")){ result <- default_query(query_type) @@ -118,7 +118,7 @@ as_query_config <- function(x){ #' Internal function to create a datasets query #' @noRd #' @keywords Internal -as_query_datasets <- function(x){ +capture_datasets <- function(x){ # set `type` query_type <- "metadata/datasets" # If `filter()` is supplied, we always need a query @@ -143,7 +143,7 @@ as_query_datasets <- function(x){ #' Internal function to create a fields query #' @noRd #' @keywords Internal -as_query_fields <- function(x){ +capture_fields <- function(x){ query_type <- "metadata/fields" if(check_if_cache_update_needed("fields")){ default_query(query_type) |> as_query() @@ -155,7 +155,7 @@ as_query_fields <- function(x){ #' Internal function to create a licences query #' @noRd #' @keywords Internal -as_query_licences <- function(x){ +capture_licences <- function(x){ query_type <- "metadata/licences" if(check_if_cache_update_needed("licences")){ default_query(query_type) |> as_query() @@ -167,7 +167,7 @@ as_query_licences <- function(x){ #' Internal function to create a lists query #' @noRd #' @keywords Internal -as_query_lists <- function(x, +capture_lists <- function(x, error_call = rlang::caller_env()){ query_type <- "metadata/lists" # if filter is supplied, lookup a specified list by dr number @@ -217,7 +217,7 @@ as_query_lists <- function(x, #' @param .query An object of class `metadata_request` (from `request_metadata()`) #' @noRd #' @keywords Internal -as_query_media_metadata <- function(.query, +capture_media_metadata <- function(.query, error_call = rlang::caller_env()){ # NOTE: # this function currently assumes that the user has passed an occurrence @@ -257,7 +257,7 @@ as_query_media_metadata <- function(.query, #' Internal function to create a profiles query #' @noRd #' @keywords Internal -as_query_profiles <- function(x){ +capture_profiles <- function(x){ query_type <- "metadata/profiles" if(check_if_cache_update_needed("profiles")){ result <- default_query(query_type) @@ -271,7 +271,7 @@ as_query_profiles <- function(x){ #' Internal function to create a providers query #' @noRd #' @keywords Internal -as_query_providers <- function(x){ +capture_providers <- function(x){ # set `type` query_type <- "metadata/providers" # If `filter()` is supplied, we always need a query @@ -296,7 +296,7 @@ as_query_providers <- function(x){ #' Internal function to create a reasons query #' @noRd #' @keywords Internal -as_query_reasons <- function(x){ +capture_reasons <- function(x){ query_type <- "metadata/reasons" if(check_if_cache_update_needed("reasons")){ result <- default_query(query_type) @@ -310,7 +310,7 @@ as_query_reasons <- function(x){ #' Internal function to create a ranks query #' @noRd #' @keywords Internal -as_query_ranks <- function(x){ +capture_ranks <- function(x){ if(is_gbif()){ result <- list(type = "metadata/ranks", data = "galah:::gbif_internal_archived$ranks") diff --git a/R/as_query-metadata-unnest.R b/R/capture_metadata_unnest.R similarity index 82% rename from R/as_query-metadata-unnest.R rename to R/capture_metadata_unnest.R index 43674e72..c27188ad 100644 --- a/R/as_query-metadata-unnest.R +++ b/R/capture_metadata_unnest.R @@ -1,8 +1,8 @@ -#' Internal function to run `as_query()` for +#' Internal function to run `capture()` for #' `request_metadata(type = "fields") |> unnest()` #' @noRd #' @keywords Internal -as_query_fields_unnest <- function(.query){ +capture_fields_unnest <- function(.query){ url <- url_lookup("metadata/fields-unnest") |> httr2::url_parse() if(is_gbif()){ @@ -18,11 +18,11 @@ as_query_fields_unnest <- function(.query){ as_prequery() } -#' Internal function to run `as_query()` for +#' Internal function to run `capture()` for #' `request_metadata(type = "lists") |> unnest()` #' @noRd #' @keywords Internal -as_query_lists_unnest <- function(.query){ +capture_lists_unnest <- function(.query){ # get list lookup url url <- url_lookup("metadata/lists-unnest", list_id = .query$filter$value[1]) |> @@ -36,22 +36,22 @@ as_query_lists_unnest <- function(.query){ as_query() } -#' Internal function to run `as_query()` for +#' Internal function to run `capture()` for #' `request_metadata(type = "profiles") |> unnest()` #' @noRd #' @keywords Internal -as_query_profiles_unnest <- function(.query){ +capture_profiles_unnest <- function(.query){ list(type = "metadata/profiles-unnest", url = url_lookup("metadata/profiles-unnest", profile = .query$filter$value[1])) |> as_prequery() } -#' Internal function to `as_query()` for +#' Internal function to `capture()` for #' `request_metadata(type = "taxa") |> unnest()` #' @noRd #' @keywords Internal -as_query_taxa_unnest <- function(.query){ +capture_taxa_unnest <- function(.query){ if(!is.null(.query$filter)){ id <- .query$filter$value[1] }else if(!is.null(.query$identify)){ diff --git a/R/as_query-occurrences.R b/R/capture_occurrences.R similarity index 65% rename from R/as_query-occurrences.R rename to R/capture_occurrences.R index 54758900..79409fdc 100644 --- a/R/as_query-occurrences.R +++ b/R/capture_occurrences.R @@ -1,26 +1,26 @@ #' Internal function to convert `data_request` with `type = "occurrences"` to a `query` #' @noRd #' @keywords Internal -as_query_occurrences <- function(.query, +capture_occurrences <- function(.query, ..., error_call = rlang::caller_env()){ if(is.null(.query$filter) & is.null(.query$identify) & is.null(.query$geolocate)){ - cli::cli_abort("No filters supplied to `collapse()` with `type = \"occurrences\"`", + cli::cli_abort("No filters supplied to `capture()` with `type = \"occurrences\"`", call = error_call) } switch(potions::pour("atlas", "region"), - "United Kingdom" = as_query_occurrences_uk(.query, ...), - "Global" = as_query_occurrences_gbif(.query, ...), - as_query_occurrences_la(.query, ...)) + "United Kingdom" = capture_occurrences_uk(.query, ...), + "Global" = capture_occurrences_gbif(.query, ...), + capture_occurrences_la(.query, ...)) } #' calculate the query to be returned for the UK atlas #' @param .query An object of class `data_request()` #' @noRd #' @keywords Internal -as_query_occurrences_uk <- function(.query, ...){ +capture_occurrences_uk <- function(.query, ...){ # set default columns if(is.null(.query$select)){ .query$select <- galah_select(group = "basic") @@ -51,7 +51,7 @@ as_query_occurrences_uk <- function(.query, ...){ #' calculate the query to be returned for GBIF #' @noRd #' @keywords Internal -as_query_occurrences_gbif <- function(.query, +capture_occurrences_gbif <- function(.query, format = "SIMPLE_CSV", ...){ # get user string @@ -81,7 +81,7 @@ as_query_occurrences_gbif <- function(.query, #' @param .query An object of class `data_request()` #' @noRd #' @keywords Internal -as_query_occurrences_la <- function(.query, +capture_occurrences_la <- function(.query, mint_doi = FALSE){ # build a query query <- c(build_query(identify = .query$identify, @@ -110,3 +110,50 @@ as_query_occurrences_la <- function(.query, headers = build_headers()) |> as_prequery() } + +#' Internal function to convert `data_request` with `type = "doi"` to a `query` +#' @noRd +#' @keywords Internal +capture_occurrences_doi <- function(.query, + error_call = rlang::caller_env()){ + if(is.null(.query$filter)){ + cli::cli_abort("A DOI must be specified using `filter(doi == \"my-doi-here\")`.", + call = error_call) + } + + if(is.null(.query$filter$variable) && .query$filter$variable != "doi"){ + cli::cli_abort("No DOI has been supplied.", + call = error_call) + } + + atlas <- potions::pour("atlas", "acronym") + if(!(atlas %in% c("ALA", "GBIF"))){ + c( + "DOI downloads not supported by selected atlas.", + i = "`request_data(type = \"occurrences-doi\")` has only been implemented for ALA & GBIF") |> + cli::cli_abort(call = error_call) + } + + doi <- .query$filter$value[[1]] + + # remove "https://" if present + if (grepl("^http://doi.org/", doi)) { + doi <- sub("^https://doi.org/", "", doi) + } + + # extract useful part of DOI + doi_str <- stringr::str_split(doi, "ala.")[[1]][2] + if(is.na(doi_str)){ + c( + "DOI has not been generated by the ALA.", + i = "DOIs created by the ALA have a prefix of 10.26197/ala.") |> + cli::cli_abort(call = error_call) + } + + list(type = "data/occurrences-doi", + url = url_lookup("data/occurrences-doi", + doi_string = doi_str), + headers = build_headers(), + download = TRUE) |> + as_query() +} \ No newline at end of file diff --git a/R/as_query-occurrences_count.R b/R/capture_occurrences_count.R similarity index 89% rename from R/as_query-occurrences_count.R rename to R/capture_occurrences_count.R index 364b92ae..a00f2f60 100644 --- a/R/as_query-occurrences_count.R +++ b/R/capture_occurrences_count.R @@ -1,25 +1,25 @@ -#' collapse for type = "occurrences-count" +#' capture() for type = "occurrences-count" #' @keywords Internal #' @param .query an object of class `data_request` #' @noRd -as_query_occurrences_count <- function(.query){ +capture_occurrences_count <- function(.query){ # NOTE: This is quite weird syntax; consider revising if(is_gbif()){ - function_name <- "as_query_occurrences_count_gbif" - arg_names <- names(formals(as_query_occurrences_count_gbif)) + function_name <- "capture_occurrences_count_gbif" + arg_names <- names(formals(capture_occurrences_count_gbif)) }else{ - function_name <- "as_query_occurrences_count_atlas" - arg_names <- names(formals(as_query_occurrences_count_atlas)) + function_name <- "capture_occurrences_count_atlas" + arg_names <- names(formals(capture_occurrences_count_atlas)) } custom_call <- .query[names(.query) %in% arg_names] class(custom_call) <- "data_request" do.call(function_name, custom_call) } -#' collapse for counts on LAs +#' capture() for counts on LAs #' @keywords Internal #' @noRd -as_query_occurrences_count_atlas <- function(identify = NULL, +capture_occurrences_count_atlas <- function(identify = NULL, filter = NULL, geolocate = NULL, apply_profile = NULL, @@ -75,10 +75,10 @@ parse_slice_arrange <- function(df){ } } -#' collapse for counts on GBIF +#' capture() for counts on GBIF #' @keywords Internal #' @noRd -as_query_occurrences_count_gbif <- function(identify = NULL, +capture_occurrences_count_gbif <- function(identify = NULL, filter = NULL, geolocate = NULL, group_by = NULL, diff --git a/R/as_query-species.R b/R/capture_species.R similarity index 88% rename from R/as_query-species.R rename to R/capture_species.R index d1c03495..b6758353 100644 --- a/R/as_query-species.R +++ b/R/capture_species.R @@ -1,21 +1,21 @@ -#' Internal function to convert `data_request` with `type = "species"` to a `query` +#' Internal function to run capture() for type = "species" #' @noRd #' @keywords Internal -as_query_species <- function(.query){ +capture_species <- function(.query){ if(is_gbif()){ - result <- as_query_occurrences_gbif(.query, - format = "SPECIES_LIST") + result <- capture_occurrences_gbif(.query, + format = "SPECIES_LIST") result$type <- "data/species" result }else{ - as_query_species_atlas(.query) + capture_species_atlas(.query) } } #' calculate the query to be returned for a given living atlas #' @noRd #' @keywords Internal -as_query_species_atlas <- function(.query){ +capture_species_atlas <- function(.query){ # set default columns if(is.null(.query$select)){ .query <- .query |> select(group = "taxonomy") diff --git a/R/as_query-species_count.R b/R/capture_species_count.R similarity index 87% rename from R/as_query-species_count.R rename to R/capture_species_count.R index d3bab73f..7a983ddc 100644 --- a/R/as_query-species_count.R +++ b/R/capture_species_count.R @@ -1,15 +1,15 @@ -#' collapse for type = "species-count" +#' capture() for type = "species-count" #' @keywords Internal #' @param .query an object of class `data_request` #' @noRd -as_query_species_count <- function(.query, +capture_species_count <- function(.query, error_call = rlang::caller_env()){ if(is_gbif()){ cli::cli_abort("`count()` is not supported for GBIF with type = 'species'", call = error_call) }else{ - function_name <- "as_query_species_count_atlas" - arg_names <- names(formals(as_query_species_count_atlas)) + function_name <- "capture_species_count_atlas" + arg_names <- names(formals(capture_species_count_atlas)) } custom_call <- .query[names(.query) %in% arg_names] class(custom_call) <- "data_request" @@ -19,7 +19,7 @@ as_query_species_count <- function(.query, #' collapse for counts on LAs #' @keywords Internal #' @noRd -as_query_species_count_atlas <- function(identify = NULL, +capture_species_count_atlas <- function(identify = NULL, filter = NULL, geolocate = NULL, apply_profile = NULL, diff --git a/R/as_query-taxa.R b/R/capture_taxa.R similarity index 91% rename from R/as_query-taxa.R rename to R/capture_taxa.R index 947d59cc..19b30a15 100644 --- a/R/as_query-taxa.R +++ b/R/capture_taxa.R @@ -1,15 +1,15 @@ -#' Internal function to run `as_query()` for `type = "taxa"`, `method = "metadata` +#' Internal function to run `capture()` for `type = "taxa"`, `method = "metadata` #' @noRd #' @keywords Internal -as_query_taxa <- function(.query){ +capture_taxa <- function(.query){ if(is.null(.query$identify)){ result <- list(type = "metadata/taxa") }else{ if(ncol(.query$identify) > 1 | colnames(.query$identify)[1] != "search_term"){ - result <- as_query_taxa_multiple(.query) + result <- capture_taxa_multiple(.query) }else{ - result <- as_query_taxa_single(.query) + result <- capture_taxa_single(.query) } } result |> @@ -19,7 +19,7 @@ as_query_taxa <- function(.query){ #' Internal function to `as_query()` for a single taxonomic name #' @noRd #' @keywords Internal -as_query_taxa_single <- function(.query){ +capture_taxa_single <- function(.query){ terms <- .query$identify$search_term list(type = "metadata/taxa-single", url = tibble::tibble(url = url_lookup("metadata/taxa-single", @@ -31,7 +31,7 @@ as_query_taxa_single <- function(.query){ #' Internal function to `collapse()` where multiple taxonomic levels are given #' @noRd #' @keywords Internal -as_query_taxa_multiple <- function(.query){ +capture_taxa_multiple <- function(.query){ # get a data.frame, enforce use of accepted taxon levels identify_df <- .query$identify colnames(identify_df) <- colnames(identify_df) |> @@ -65,7 +65,7 @@ as_query_taxa_multiple <- function(.query){ #' Internal function to create an identifiers query #' @noRd #' @keywords Internal -as_query_identifiers <- function(.query){ +capture_identifiers <- function(.query){ if(is.null(.query$filter)){ url_list <- url_lookup("metadata/identifiers") names(url_list) <- "no-name-supplied" diff --git a/R/onload.R b/R/onload.R index 55738f48..ec501895 100644 --- a/R/onload.R +++ b/R/onload.R @@ -17,14 +17,12 @@ silent = TRUE)) # show currently-selected atlas - current_node <- "ALA" - current_url <- "none" - #current_node <- potions::pour("atlas", .pkg = "galah") |> - # purrr::pluck("acronym") - #current_url <- show_all_atlases() |> - # dplyr::filter(.data$acronym == current_node) |> - # dplyr::pull("url") |> - # stringr::str_replace("^https://", "") + current_node <- potions::pour("atlas", .pkg = "galah") |> + purrr::pluck("acronym") + current_url <- show_all_atlases() |> + dplyr::filter(.data$acronym == current_node) |> + dplyr::pull("url") |> + stringr::str_replace("^https://", "") # display a message # NOTE: This message *must* have the following classes to enable them From 5005da0583cf6e9c2da2a367fddce57fe6307631 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Wed, 14 Jan 2026 11:46:47 +1100 Subject: [PATCH 67/94] Ensure DOI queries parsed correctly; minor bux fixes re: as_query() --- R/capture.R | 22 ++++++++++++++++++++++ R/capture_metadata.R | 2 +- R/capture_taxa.R | 2 +- R/coalesce.R | 21 ++++++--------------- R/collect_metadata.R | 2 +- R/galah_call.R | 2 +- R/handle_quosures.R | 2 +- man/galah_call.Rd | 2 +- tests/testthat/test-atlas_occurrences.R | 2 +- tests/testthat/test-authentication.R | 4 ++-- tests/testthat/test-dplyr-select.R | 2 +- tests/testthat/test-print.R | 4 ++-- 12 files changed, 40 insertions(+), 27 deletions(-) diff --git a/R/capture.R b/R/capture.R index d625727b..294854df 100644 --- a/R/capture.R +++ b/R/capture.R @@ -51,6 +51,7 @@ capture.data_request <- function(x, ...){ x <- x |> check_authentication() |> + check_doi() |> check_distinct_count_groupby() |> check_slice_arrange() |> enforce_select_query() @@ -153,12 +154,33 @@ count_switch <- function(x){ x } +#' Internal function to ensure that DOIs are parsed properly +#' @noRd +#' @keywords Internal +check_doi <- function(x){ + if(x$type == "occurrences"){ + # handle sending dois via `filter()` + # important this happens first, as it affects `type`, which affects later code + variables <- purrr::pluck(x, "filter", "variable") # NOTE: breaks for GBIF + if(!is.null(variables)){ + if(length(variables) == 1 & variables[1] == "doi"){ + x$type <- "occurrences-doi" + } + } + } + x +} + #' Internal function to check behaviour of `distinct()`, `group_by()` etc. #' called by `capture()` #' @noRd #' @keywords Internal check_distinct_count_groupby <- function(x){ + if(x$type == "occurrences-doi"){ + return(x) + } + # get basic info has_group_by <- !is.null(x$group_by) has_count <- !is.null(x$count) diff --git a/R/capture_metadata.R b/R/capture_metadata.R index 72f578cc..2e803810 100644 --- a/R/capture_metadata.R +++ b/R/capture_metadata.R @@ -213,7 +213,7 @@ capture_lists <- function(x, as_query() } -#' Internal version of `as_query()` for `request_metadata(type = "media")` +#' Internal version of `capture()` for `request_metadata(type = "media")` #' @param .query An object of class `metadata_request` (from `request_metadata()`) #' @noRd #' @keywords Internal diff --git a/R/capture_taxa.R b/R/capture_taxa.R index 19b30a15..b6d9a040 100644 --- a/R/capture_taxa.R +++ b/R/capture_taxa.R @@ -16,7 +16,7 @@ capture_taxa <- function(.query){ as_query() } -#' Internal function to `as_query()` for a single taxonomic name +#' Internal function to `capture()` for a single taxonomic name #' @noRd #' @keywords Internal capture_taxa_single <- function(.query){ diff --git a/R/coalesce.R b/R/coalesce.R index 04912870..1b622f93 100644 --- a/R/coalesce.R +++ b/R/coalesce.R @@ -157,15 +157,6 @@ build_query_set_data <- function(x, mint_doi, ...){ x$request$mint_doi <- mint_doi } - # handle sending dois via `filter()` - # important this happens first, as it affects `type`, which affects later code - variables <- purrr::pluck(x, "request", "filter", "variable") # NOTE: breaks for GBIF - if(!is.null(variables)){ - if(length(variables) == 1 & variables[1] == "doi"){ - x$request$type <- "occurrences-doi" - } - } - # set up an object result <- list() @@ -235,7 +226,7 @@ build_query_set_data <- function(x, mint_doi, ...){ result[[(length(result) + 1)]] <- x # return - structure(result, class = "query_set") + as_query_set(result) } #' Internal function to build a `query_set` object @@ -246,19 +237,19 @@ build_query_set_distributions <- function(x, ...){ if(is.null(x$identify) & is.null(x$filter)){ # find all expert distributions result <- list( - as_query_distributions_metadata(), - as_query_distributions_data(x) + capture_distributions_metadata(), + capture_distributions_data(x) ) }else{ if(!is.null(x$identify)){ result <- list( collapse_taxa(list(identify = x$identify)) # wrong syntax? ) - result[[2]] <- as_query_distributions_data(x) # NOTE: shouldn't call microfunctions directly + result[[2]] <- capture_distributions_data(x) # NOTE: shouldn't call microfunctions directly }else{ # i.e. !is.null(x$filter) - result <- list(as_query_distributions_data(x)) + result <- list(capture_distributions_data(x)) } } - structure(result, class = "query_set") + as_query_set(result) } \ No newline at end of file diff --git a/R/collect_metadata.R b/R/collect_metadata.R index f279d570..d03ab522 100644 --- a/R/collect_metadata.R +++ b/R/collect_metadata.R @@ -12,7 +12,7 @@ update_attributes <- function(df, type){ #' Often a `.query` will have a `data` slot in place of a `url` #' This may call e.g. `retrieve_cache()` OR an internal object -#' Either way, the string will be a valid function call set in `as_query()` +#' Either way, the string will be a valid function call set in `capture()` #' Our job at this point is to parse that function, NOT just `retrieve_cache()` #' @noRd #' @keywords Internal diff --git a/R/galah_call.R b/R/galah_call.R index 28e7d31a..4bc959d6 100644 --- a/R/galah_call.R +++ b/R/galah_call.R @@ -39,7 +39,7 @@ #' \code{\link[=select.data_request]{select}}, #' \code{\link[=slice_head.data_request]{slice_head()}} or [unnest()]. #' For operations on `_request` objects, see -#' \code{\link[=as_query.data_request]{as_query()}}, +#' [capture()], #' [coalesce()], #' \code{\link[=collapse.data_request]{collapse()}}, #' \code{\link[=compute.data_request]{compute()}} or diff --git a/R/handle_quosures.R b/R/handle_quosures.R index b8f1c29c..db1b924e 100644 --- a/R/handle_quosures.R +++ b/R/handle_quosures.R @@ -481,7 +481,7 @@ parse_c <- function(x, excl){ in_as_or_statements <- glue::glue_collapse( glue::glue("{variable} {logical} '{value}'"), sep = " | ") |> - parse_expr() + rlang::parse_expr() parse_logical(rlang::enquo(in_as_or_statements), rlang::quo_get_env(x)) # pass this to parse_logical } diff --git a/man/galah_call.Rd b/man/galah_call.Rd index 9bed7bce..696d60a1 100644 --- a/man/galah_call.Rd +++ b/man/galah_call.Rd @@ -98,7 +98,7 @@ To amend a request object, use \code{\link[=apply_profile]{apply_profile()}}, \code{\link[=select.data_request]{select}}, \code{\link[=slice_head.data_request]{slice_head()}} or \code{\link[=unnest]{unnest()}}. For operations on \verb{_request} objects, see -\code{\link[=as_query.data_request]{as_query()}}, +\code{\link[=capture]{capture()}}, \code{\link[=coalesce]{coalesce()}}, \code{\link[=collapse.data_request]{collapse()}}, \code{\link[=compute.data_request]{compute()}} or diff --git a/tests/testthat/test-atlas_occurrences.R b/tests/testthat/test-atlas_occurrences.R index 394e7843..1dbbea29 100644 --- a/tests/testthat/test-atlas_occurrences.R +++ b/tests/testthat/test-atlas_occurrences.R @@ -208,7 +208,7 @@ test_that("`atlas_occurrences()` downloads data from a DOI", { # TODO add file name tests }) -# TODO check DOIs still placed correctly in as_query(), collapse() etc +# TODO check DOIs still placed correctly in capture(), collapse() etc # NOTE: This test is *very* slow - worth investigating why test_that("`atlas_occurrences()` places DOI in `attr()` correctly", { diff --git a/tests/testthat/test-authentication.R b/tests/testthat/test-authentication.R index ee222f05..b6e24c30 100644 --- a/tests/testthat/test-authentication.R +++ b/tests/testthat/test-authentication.R @@ -17,7 +17,7 @@ test_that("`request_metadata()` caches type `config` correctly", { x <- request_metadata(type = "config") |> collect() result <- request_metadata(type = "config") |> - as_query() + capture() expect_true(!is.null(result$data)) }) @@ -27,7 +27,7 @@ test_that("`authenticate()` works in-pipe for metadata", { query <- request_metadata(type = "reasons") |> authenticate() - result <- as_query(query) + result <- capture(query) is.null(result$request$authenticate) |> expect_false() diff --git a/tests/testthat/test-dplyr-select.R b/tests/testthat/test-dplyr-select.R index 154f03bb..762e3169 100644 --- a/tests/testthat/test-dplyr-select.R +++ b/tests/testthat/test-dplyr-select.R @@ -194,7 +194,7 @@ test_that("`select()` warns for invalid field names when type = 'species'", { identify("Crinia") |> distinct(speciesID, .keep_all = TRUE) |> select(an_unrecognised_field_name) |> - as_query()}) + capture()}) }) rm(quiet_collect, quiet_occurrences, config_capture) diff --git a/tests/testthat/test-print.R b/tests/testthat/test-print.R index 768f2079..8423e4b9 100644 --- a/tests/testthat/test-print.R +++ b/tests/testthat/test-print.R @@ -36,14 +36,14 @@ test_that("object of class `metadata-request` formats correctly with `identify() test_that("object of class `query` formats correctly", { request_metadata() |> identify("Crinia") |> - as_query() |> + capture() |> expect_snapshot() }) test_that("object of class `computed_query` formats correctly", { x <- request_metadata() |> identify("Crinia") |> - as_query() + capture() class(x) <- c("computed_query", "list") expect_snapshot(x) }) From 804672c762ba0a79d912b29e0e41823dc4e47060 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Thu, 29 Jan 2026 09:34:41 +1100 Subject: [PATCH 68/94] Fix link to package reference in README --- README.Rmd | 2 +- README.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.Rmd b/README.Rmd index d74ff021..960be095 100644 --- a/README.Rmd +++ b/README.Rmd @@ -51,7 +51,7 @@ If you have any comments, questions or suggestions, please [contact us](mailto:s ## Getting started - The [quick start guide](https://galah.ala.org.au/R/articles/quick_start_guide.html) provides an introduction to the package functions. -- For an outline of the package structure, and a list of all the available functions, run `?galah` or view the [reference page](https://galah.ala.org.au/R/index.html). +- For an outline of the package structure, and a list of all the available functions, run `?galah` or view the [reference page](https://galah.ala.org.au/R/reference/index.html). diff --git a/README.md b/README.md index 016882c3..41ee44f6 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ status](https://www.r-pkg.org/badges/version/galah)](https://cran.r-project.org/package=galah) [![Codecov test coverage](https://codecov.io/gh/AtlasOfLivingAustralia/galah-R/branch/main/graph/badge.svg)](https://app.codecov.io/gh/AtlasOfLivingAustralia/galah-R?branch=main) +[![R-CMD-check](https://github.com/AtlasOfLivingAustralia/galah-R/actions/workflows/R-CMD-check.yaml/badge.svg)](https://github.com/AtlasOfLivingAustralia/galah-R/actions/workflows/R-CMD-check.yaml) ## Overview @@ -45,7 +46,7 @@ us](mailto:support@ala.org.au). provides an introduction to the package functions. - For an outline of the package structure, and a list of all the available functions, run `?galah` or view the [reference - page](https://galah.ala.org.au/R/index.html). + page](https://galah.ala.org.au/R/reference/index.html). ## Installation From dc573685358cc96e1aa9e7a30c4c3f1600f28e69 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Mon, 2 Feb 2026 13:49:18 +1100 Subject: [PATCH 69/94] Fix bugs in passing `mint_doi` between functions --- NAMESPACE | 2 ++ R/capture.R | 4 ++-- R/capture_occurrences.R | 2 +- R/coalesce.R | 10 +++++----- R/dplyr-collect.R | 22 +++++++++++++++++----- R/dplyr-compute.R | 6 ++++++ R/utilities_internal.R | 4 ++-- man/coalesce.Rd | 4 ++-- man/collect.data_request.Rd | 5 ++++- man/compute.data_request.Rd | 3 +++ 10 files changed, 44 insertions(+), 18 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 0154f52c..ed00f564 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -22,11 +22,13 @@ S3method(collect,computed_query) S3method(collect,data_request) S3method(collect,files_request) S3method(collect,metadata_request) +S3method(collect,prequery) S3method(collect,query) S3method(collect,query_set) S3method(compute,data_request) S3method(compute,files_request) S3method(compute,metadata_request) +S3method(compute,prequery) S3method(compute,query) S3method(compute,query_set) S3method(count,data_request) diff --git a/R/capture.R b/R/capture.R index 294854df..059b7a5e 100644 --- a/R/capture.R +++ b/R/capture.R @@ -47,8 +47,8 @@ capture <- function(x, ...){ #' @order 2 #' @export capture.data_request <- function(x, - mint_doi = FALSE, - ...){ + mint_doi = FALSE, + ...){ x <- x |> check_authentication() |> check_doi() |> diff --git a/R/capture_occurrences.R b/R/capture_occurrences.R index 79409fdc..4759eca2 100644 --- a/R/capture_occurrences.R +++ b/R/capture_occurrences.R @@ -97,7 +97,7 @@ capture_occurrences_la <- function(.query, dwcHeaders = "true") |> add_email_notify() |> add_email_address(query = .query) |> - add_doi_request(query = .query) + add_doi_request(mint_doi = mint_doi) # build url url <- url_lookup("data/occurrences") |> diff --git a/R/coalesce.R b/R/coalesce.R index 1b622f93..5cae6773 100644 --- a/R/coalesce.R +++ b/R/coalesce.R @@ -32,9 +32,9 @@ coalesce <- function(x, ...){ #' @rdname coalesce #' @order 2 #' @export -coalesce.data_request <- function(x, mint_doi, ...){ +coalesce.data_request <- function(x, mint_doi = FALSE, ...){ x |> - capture() |> + capture(mint_doi = mint_doi, ...) |> coalesce() } @@ -43,7 +43,7 @@ coalesce.data_request <- function(x, mint_doi, ...){ #' @export coalesce.metadata_request <- function(x, ...){ x |> - capture() |> + capture(...) |> coalesce() } @@ -53,7 +53,7 @@ coalesce.metadata_request <- function(x, ...){ coalesce.files_request <- function(x, ...){ x |> - capture() |> + capture(...) |> coalesce() } @@ -62,7 +62,7 @@ coalesce.files_request <- function(x, #' applies to `type = "occurrences"`, and only for supported atlases. #' @order 5 #' @export -coalesce.prequery <- function(x, mint_doi, ...){ +coalesce.prequery <- function(x, mint_doi = FALSE, ...){ if(stringr::str_detect(x$type, "^metadata")){ build_query_set_metadata(x) }else if(stringr::str_detect(x$type, "^files")){ diff --git a/R/dplyr-collect.R b/R/dplyr-collect.R index 23c88806..1eaae256 100644 --- a/R/dplyr-collect.R +++ b/R/dplyr-collect.R @@ -51,7 +51,7 @@ collect.files_request <- function(x, ...){ #' @rdname collect.data_request #' @order 4 #' @export -collect.query <- function(x, ..., wait = TRUE, file = NULL){ +collect.prequery <- function(x, wait = TRUE, file = NULL){ compute(x) |> collect(wait = wait, file = file) } @@ -59,15 +59,27 @@ collect.query <- function(x, ..., wait = TRUE, file = NULL){ #' @rdname collect.data_request #' @order 5 #' @export -collect.query_set <- function(x, ...){ +collect.query <- function(x, ..., wait = TRUE, file = NULL){ + compute(x) |> + collect(wait = wait, file = file) +} + +#' @rdname collect.data_request +#' @order 6 +#' @export +collect.query_set <- function(x, + ..., + wait = TRUE, + file = NULL + ){ x |> - collapse(, ...) |> + collapse(...) |> compute() |> - collect() + collect(wait = wait, file = file) } #' @rdname collect.data_request -#' @order 6 +#' @order 7 #' @export collect.computed_query <- function(x, ..., diff --git a/R/dplyr-compute.R b/R/dplyr-compute.R index d676fed0..40c31385 100644 --- a/R/dplyr-compute.R +++ b/R/dplyr-compute.R @@ -41,6 +41,12 @@ compute.metadata_request <- compute.data_request #' @export compute.files_request <- compute.data_request +# if calling `compute()` after `capture()` +#' @rdname compute.data_request +#' @order 4 +#' @export +compute.prequery <- compute.data_request + # if calling `compute()` on an object extracted from `collapse()` #' @rdname compute.data_request #' @order 5 diff --git a/R/utilities_internal.R b/R/utilities_internal.R index bdcb6636..a668040a 100644 --- a/R/utilities_internal.R +++ b/R/utilities_internal.R @@ -294,8 +294,8 @@ add_email_address <- function(x, query){ #' Add a DOI request #' @noRd #' @keywords Internal -add_doi_request <- function(x, query){ - if(isTRUE(query$mint_doi) & +add_doi_request <- function(x, mint_doi = FALSE){ + if(isTRUE(mint_doi) & potions::pour("atlas", "region") == "Australia"){ x$mintDoi <- TRUE } diff --git a/man/coalesce.Rd b/man/coalesce.Rd index 413ef40f..d6008b0c 100644 --- a/man/coalesce.Rd +++ b/man/coalesce.Rd @@ -12,13 +12,13 @@ \usage{ coalesce(x, ...) -\method{coalesce}{data_request}(x, mint_doi, ...) +\method{coalesce}{data_request}(x, mint_doi = FALSE, ...) \method{coalesce}{metadata_request}(x, ...) \method{coalesce}{files_request}(x, ...) -\method{coalesce}{prequery}(x, mint_doi, ...) +\method{coalesce}{prequery}(x, mint_doi = FALSE, ...) \method{coalesce}{query}(x, ...) diff --git a/man/collect.data_request.Rd b/man/collect.data_request.Rd index d6e68c19..3900e376 100644 --- a/man/collect.data_request.Rd +++ b/man/collect.data_request.Rd @@ -4,6 +4,7 @@ \alias{collect.data_request} \alias{collect.metadata_request} \alias{collect.files_request} +\alias{collect.prequery} \alias{collect.query} \alias{collect.query_set} \alias{collect.computed_query} @@ -15,9 +16,11 @@ \method{collect}{files_request}(x, ...) +\method{collect}{prequery}(x, wait = TRUE, file = NULL) + \method{collect}{query}(x, ..., wait = TRUE, file = NULL) -\method{collect}{query_set}(x, ...) +\method{collect}{query_set}(x, ..., wait = TRUE, file = NULL) \method{collect}{computed_query}(x, ..., wait = TRUE, file = NULL) } diff --git a/man/compute.data_request.Rd b/man/compute.data_request.Rd index d26088b0..5477c5a8 100644 --- a/man/compute.data_request.Rd +++ b/man/compute.data_request.Rd @@ -4,6 +4,7 @@ \alias{compute.data_request} \alias{compute.metadata_request} \alias{compute.files_request} +\alias{compute.prequery} \alias{compute.query} \alias{compute.query_set} \title{Compute a query} @@ -14,6 +15,8 @@ \method{compute}{files_request}(x, ...) +\method{compute}{prequery}(x, ...) + \method{compute}{query}(x, ...) \method{compute}{query_set}(x, ...) From 6ef3a87352a45f7b039dd90c05d23abc71fc8081 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Mon, 2 Feb 2026 20:48:07 +1100 Subject: [PATCH 70/94] Fix bugs in `atlas_media()` Mainly ensuring sound files are downloaded correctly --- R/atlas_media.R | 84 ++++++++------------------ R/utilities_internal.R | 29 +++++++++ tests/testthat/_snaps/print.md | 2 +- tests/testthat/test-atlas_media.R | 25 ++++---- tests/testthat/test-masked-functions.R | 2 +- 5 files changed, 69 insertions(+), 73 deletions(-) diff --git a/R/atlas_media.R b/R/atlas_media.R index 17aa7985..cb179c5d 100644 --- a/R/atlas_media.R +++ b/R/atlas_media.R @@ -64,7 +64,7 @@ atlas_media <- function(request = NULL, # add media content to filters if(length(present_fields) > 0){ # do region-specific filter parsing - media_fq <- parse_regional_media_filters(present_fields) + media_fq <- image_filters(present_fields) # add back to source object if(length(media_fq) > 1){ media_fq <- glue::glue("({glue::glue_collapse(media_fq, ' OR ')})") @@ -74,17 +74,11 @@ atlas_media <- function(request = NULL, query_collapse$url <- httr2::url_build(url) } - # get occurrences + # get occurrences, expand to one row per media entry occ <- query_collapse |> collect(wait = TRUE) |> - tidyr::unnest_longer(col = tidyselect::any_of(present_fields)) - - if(any(colnames(occ) == "all_image_url")){ - occ <- dplyr::rename(occ, "media_id" = "all_image_url") - }else{ - occ$media_id <- build_media_id(occ) - } - + build_media_id(media_fields = present_fields) + # collect media metadata media_query <- request_metadata() |> filter(media == dplyr::pull(occ, "media_id")) @@ -95,59 +89,31 @@ atlas_media <- function(request = NULL, media <- collect(media_query) # join and return - occ_media <- dplyr::right_join(occ, - media, - by = dplyr::join_by("media_id")) - dplyr::relocate(occ_media, "media_id", 1) -} - -#' Set filters that work for media in each atlas -#' @noRd -#' @keywords Internal -parse_regional_media_filters <- function(present_fields, - error_call = rlang::caller_env()){ - - atlas <- potions::pour("atlas", "region") - switch(atlas, - "Austria" = "(all_image_url:*)", - "Australia" = glue::glue("({present_fields}:*)"), - "Brazil" = "(all_image_url:*)", - "Flanders" = "(all_image_url:*)", - "Guatemala" = "(all_image_url:*)", - "Kew" = "(all_image_url:*)", - "Portugal" = "(all_image_url:*)", - "Spain" = {filter_fields <- present_fields |> - stringr::str_remove("s$") |> - paste0("IDsCount") - glue::glue("{filter_fields}:[1 TO *]")}, - "Sweden" = {filter_fields <- present_fields |> - stringr::str_remove("s$") |> - paste0("IDsCount") - glue::glue("{filter_fields}:[1 TO *]")}, - "United Kingdom" = "(all_image_url:*)", # !is.na(all_image_url), - cli::cli_abort("`atlas_media` is not supported for atlas = {atlas}", - call = error_call) - ) + dplyr::right_join(occ, + media, + by = dplyr::join_by("media_id")) } #' Internal function to get media metadata, and create a valid file name #' @noRd #' @keywords Internal -build_media_id <- function(df){ - # create a column that includes media identifiers, regardless of which column they are in - ## NOTE: I haven't found good tidyverse syntax for this yet - x <- rep(NA, nrow(df)) - if(any(colnames(df) == "videos")){ - videos <- !is.na(df$videos) - if(any(videos)){x[videos] <- df$videos[videos]} - } - if(any(colnames(df) == "sounds")){ - sounds <- !is.na(df$sounds) - if(any(sounds)){x[sounds] <- df$sounds[sounds]} - } - if(any(colnames(df) == "images")){ - images <- !is.na(df$images) - if(any(images)){x[images] <- df$images[images]} +build_media_id <- function(df, media_fields){ + if(any(colnames(df) == "all_image_url")){ + df |> + dplyr::mutate("media_id" = "all_image_url", + "media_type" = "images", + .before = 1) |> + dplyr::select(-"images") + }else{ + purrr::map(media_fields, .f = \(a){ + df |> + tidyr::unnest_longer(col = a) |> + dplyr::mutate(media_id = as.character(.data[[a]]), + media_type = as.character(a), + .before = 1) |> + dplyr::filter(!is.na(.data$media_id)) |> + dplyr::select(- tidyselect::any_of(media_fields)) + }) |> + dplyr::bind_rows() } - x } \ No newline at end of file diff --git a/R/utilities_internal.R b/R/utilities_internal.R index a668040a..c323fe1f 100644 --- a/R/utilities_internal.R +++ b/R/utilities_internal.R @@ -414,6 +414,35 @@ image_fields <- function() { } } +#' Set filters that work for media in each atlas +#' @noRd +#' @keywords Internal +image_filters <- function(present_fields, + error_call = rlang::caller_env()){ + + atlas <- potions::pour("atlas", "region") + switch(atlas, + "Austria" = "(all_image_url:*)", + "Australia" = glue::glue("({present_fields}:*)"), + "Brazil" = "(all_image_url:*)", + "Flanders" = "(all_image_url:*)", + "Guatemala" = "(all_image_url:*)", + "Kew" = "(all_image_url:*)", + "Portugal" = "(all_image_url:*)", + "Spain" = {filter_fields <- present_fields |> + stringr::str_remove("s$") |> + paste0("IDsCount") + glue::glue("{filter_fields}:[1 TO *]")}, + "Sweden" = {filter_fields <- present_fields |> + stringr::str_remove("s$") |> + paste0("IDsCount") + glue::glue("{filter_fields}:[1 TO *]")}, + "United Kingdom" = "(all_image_url:*)", # !is.na(all_image_url), + cli::cli_abort("`atlas_media` is not supported for atlas = {atlas}", + call = error_call) + ) +} + #' @noRd #' @keywords Internal species_facets <- function(){ diff --git a/tests/testthat/_snaps/print.md b/tests/testthat/_snaps/print.md index d1ecd02b..770299bd 100644 --- a/tests/testthat/_snaps/print.md +++ b/tests/testthat/_snaps/print.md @@ -54,7 +54,7 @@ # object of class `query` formats correctly Code - as_query(identify(request_metadata(), "Crinia")) + capture(identify(request_metadata(), "Crinia")) Message Object of class query with type metadata/taxa-single * url: https://api.ala.org.au/namematching/api/search?q=Crinia diff --git a/tests/testthat/test-atlas_media.R b/tests/testthat/test-atlas_media.R index 4fe1591d..6b9d36d3 100644 --- a/tests/testthat/test-atlas_media.R +++ b/tests/testthat/test-atlas_media.R @@ -47,8 +47,9 @@ test_that("`atlas_media()` works", { expect_true() }) -test_that("collect_media suggests `galah_config(directory =)` when a temp folder is set as the directory", { +test_that("`collect_media()` works", { skip_if_offline(); skip_on_ci() + capture_config <- purrr_config(email = "ala4r@ala.org.au") atlas_query <- galah_call() |> identify("Anthochaera (Xanthomyza) phrygia") |> # Regent Honeyeater filter(year == 2012) |> @@ -59,11 +60,9 @@ test_that("collect_media suggests `galah_config(directory =)` when a temp folder x <- purrr_config(directory = media_dir) # assigned to prevent message # we don't run tests on this object result <- purrr_collect_media(atlas_query) - result |> - purrr::pluck("messages") |> - stringr::str_detect("To change which file directory media files are saved to") |> - any() |> - expect_true() + media_files <- list.files(media_dir, + pattern = ".jpg$|.mpg$") + expect_true(length(media_files) == result$result$n) unlink(media_dir, recursive = TRUE) }) @@ -127,14 +126,16 @@ test_that("`collapse()` and `collect()` work for `type = 'media'`", { filter(media == df) |> quiet_collapse(thumbnail = TRUE) expect_true(inherits(files_collapse, "query")) - expect_equal(length(files_collapse), 3) - expect_equal(names(files_collapse), c("type", "url", "headers")) + expect_equal(length(files_collapse), 4) + expect_equal(names(files_collapse), + c("type", "url", "headers", "request")) expect_equal(files_collapse$type, "files/media") # compute files_compute <- quiet_compute(files_collapse) expect_true(inherits(files_compute, "computed_query")) - expect_equal(length(files_compute), 3) - expect_equal(names(files_compute), c("type", "url", "headers")) + expect_equal(length(files_compute), 4) + expect_equal(names(files_compute), + c("type", "url", "headers", "request")) # collect files_collect <- quiet_collect(files_compute) expect_s3_class(files_collect, c("tbl_df", "tbl", "data.frame")) @@ -184,12 +185,12 @@ test_that("collect_media handles different file formats", { directory = media_dir) media_data <- galah_call() |> identify("Regent Honeyeater") |> - filter(year == 2024) |> + filter(multimedia %in% c("Sound", "Image"), year == 2024) |> quiet_media() # sample one of each multimedia type to shorten testing time media_summary <- media_data |> dplyr::group_by(multimedia) |> - dplyr::sample_n(size = 1) + dplyr::sample_n(size = 2) expect_equal(sort(unique(media_data$multimedia)), c("Image", "Image | Sound")) result <- purrr_collect_media(media_summary, thumbnail = TRUE) diff --git a/tests/testthat/test-masked-functions.R b/tests/testthat/test-masked-functions.R index dd794e92..32a6fc4f 100644 --- a/tests/testthat/test-masked-functions.R +++ b/tests/testthat/test-masked-functions.R @@ -14,7 +14,7 @@ test_that("`filter` works identically to piped `galah_filter`", { test_that("`select` works identically to piped `galah_select`", { result1 <- galah_call() |> select(year, group = "basic") result2 <- galah_call() |> galah_select(year, group = "basic") - expect_equal(result1, result2) + expect_equal(result1, result2, ignore_attr = TRUE) }) test_that("`group_by` works identically to piped `galah_group_by`", { From 2bf7997e478678df532c63635c7657d2cb2d13b7 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Tue, 3 Feb 2026 08:44:00 +1100 Subject: [PATCH 71/94] update authorship statement Add @shandiya as contributor, convert Matilda to contributor, remove Peggy --- DESCRIPTION | 10 +++++----- inst/CITATION | 6 +++--- man/galah.Rd | 8 ++++++-- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 2800f3cd..7b125a6d 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -11,13 +11,13 @@ Authors@R: family = "Kellie", email = "dax.kellie@csiro.au", role = "aut"), + person(given = "Shandiya", + family = "Balasubramaniam", + email = "shandiya.balasubramaniam@csiro.au", + role = "ctb"), person(given = "Matilda", family = "Stevenson", - role = "aut"), - person(given = "Peggy", - family = "Newman", - email = "peggy.newman@csiro.au", - role = "aut")) + role = "ctb")) Description: The Global Biodiversity Information Facility ('GBIF', ) sources data from an international network of data providers, known as 'nodes'. Several of these nodes - the "living diff --git a/inst/CITATION b/inst/CITATION index de1ad268..2738f5f7 100644 --- a/inst/CITATION +++ b/inst/CITATION @@ -6,9 +6,9 @@ citHeader("To cite galah in publications use:") bibentry(bibtype = "Manual", "title" = "galah: Biodiversity Data from the GBIF Node Network", author = c(person("Martin", "Westgate"), - person("Dax", "Kellie"), - person("Matilda", "Stevenson"), - person("Peggy", "Newman")), + person("Dax", "Kellie"), + person("Shandiya", "Balasubramaniam"), + person("Matilda", "Stevenson")), year = year, note = note, url = "https://CRAN.R-project.org/package=galah") diff --git a/man/galah.Rd b/man/galah.Rd index 34f1a0e8..49639881 100644 --- a/man/galah.Rd +++ b/man/galah.Rd @@ -131,8 +131,12 @@ Useful links: Authors: \itemize{ \item Dax Kellie \email{dax.kellie@csiro.au} - \item Matilda Stevenson - \item Peggy Newman \email{peggy.newman@csiro.au} +} + +Other contributors: +\itemize{ + \item Shandiya Balasubramaniam \email{shandiya.balasubramaniam@csiro.au} [contributor] + \item Matilda Stevenson [contributor] } } From 82e92bc60b5038dc2c448d6de1c43b6dab6ec8ad Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Tue, 3 Feb 2026 13:03:29 +1100 Subject: [PATCH 72/94] Clean up `distinct()` workflow (#275, #267) Fix bugs, add tests, add documentation --- R/atlas_species.R | 18 ++++- R/capture_species.R | 16 +++- R/capture_species_count.R | 30 +++++-- R/dplyr-distinct.R | 117 +++++++++++++++++++++++++-- R/dplyr-filter.R | 4 +- man/distinct.data_request.Rd | 110 +++++++++++++++++++++++-- man/filter.data_request.Rd | 4 +- tests/testthat/test-atlas_species.R | 13 +++ tests/testthat/test-dplyr-distinct.R | 27 +++++-- 9 files changed, 305 insertions(+), 34 deletions(-) diff --git a/R/atlas_species.R b/R/atlas_species.R index 0420bf08..fef48c66 100644 --- a/R/atlas_species.R +++ b/R/atlas_species.R @@ -17,10 +17,24 @@ atlas_species <- function(request = NULL, }else{ args <- c(list(type = "species"), args) } - + # convert to `data_request` object check_atlas_inputs(args) |> - distinct("speciesID", .keep_all = TRUE) |> + check_distinct_for_atlas_species() |> collapse() |> collect() +} + +#' Internal micro-function to ensure that grouping is always applied, +#' but can also be overruled by the user +#' @noRd +#' @keywords Internal +check_distinct_for_atlas_species <- function(.query){ + if(is.null(.query$distinct)){ + .query |> + distinct("speciesID", .keep_all = TRUE) + }else{ + .query$distinct$keep_all <- TRUE + .query + } } \ No newline at end of file diff --git a/R/capture_species.R b/R/capture_species.R index b6758353..ec43e6ab 100644 --- a/R/capture_species.R +++ b/R/capture_species.R @@ -21,10 +21,18 @@ capture_species_atlas <- function(.query){ .query <- .query |> select(group = "taxonomy") } - # determine whether to use `group_by` or `species_facets()` - if(is.null(.query$group_by)){ - .query$group_by <- tibble::tibble(name = species_facets(), + # determine whether to use `distinct()` or `species_facets()` + if(is.null(.query$distinct)){ + .query$distinct <- tibble::tibble(name = species_facets(), type = "field") + }else{ + if(is.na(.query$distinct$name)){ + if(!is.null(.query$group_by)){ + .query$distinct$name <- .query$group_by$name + }else{ + cli::cli_error("No variable supplied to `distinct()`") + } + } } # build a query @@ -35,7 +43,7 @@ capture_species_atlas <- function(.query){ .query$apply_profile), sourceTypeId = 2004, reasonTypeId = potions::pour("user", "download_reason_id"), - facets = .query$group_by$name, + facets = .query$distinct$name, parse_select_species(.query$select)) |> add_email_address(query = .query) |> add_email_notify() diff --git a/R/capture_species_count.R b/R/capture_species_count.R index 7a983ddc..9c2ca51b 100644 --- a/R/capture_species_count.R +++ b/R/capture_species_count.R @@ -20,30 +20,44 @@ capture_species_count <- function(.query, #' @keywords Internal #' @noRd capture_species_count_atlas <- function(identify = NULL, - filter = NULL, - geolocate = NULL, - apply_profile = NULL, - group_by = NULL, - slice_arrange = NULL + filter = NULL, + geolocate = NULL, + apply_profile = NULL, + group_by = NULL, + distinct = NULL, + slice_arrange = NULL ){ + # determine facets + if(is.null(distinct)){ + facet_variable <- species_facets() + }else{ + facet_variable <- distinct$name[[1]] + } + + # get url url <- url_lookup("data/species-count") |> httr2::url_parse() query <- build_query(identify, filter, geolocate, apply_profile = apply_profile) + # set behaviour depending on `group_by()` if(is.null(group_by)){ url$query <- c(query, list(flimit = 1, - facets = species_facets())) + facets = facet_variable)) result <- list(type = "data/species-count", url = httr2::url_build(url), headers = build_headers()) }else{ - facets <- c(as.list(group_by$name), species_facets()) + facets <- c(as.list(group_by$name), + facet_variable) names(facets) <- rep("facets", length(facets)) - url$query <- c(query, facets, parse_slice_arrange(slice_arrange)) + url$query <- c(query, + facets, + parse_slice_arrange(slice_arrange), + list(flimit = -1)) result <- list(type = "data/species-count", url = httr2::url_build(url), headers = build_headers()) diff --git a/R/dplyr-distinct.R b/R/dplyr-distinct.R index 64b1e215..1dccf7f1 100644 --- a/R/dplyr-distinct.R +++ b/R/dplyr-distinct.R @@ -5,11 +5,118 @@ #' @param .data A data frame, data frame extension (e.g. a tibble), or a #' lazy data frame (e.g. from dbplyr or dtplyr). See Methods, below, #' for more details. -#' @param ... Optional variables to use when determining uniqueness. If there -#' are multiple rows for a given combination of inputs, only the first row -#' will be preserved. If omitted, will use all variables in the data frame. -#' @param .keep_all If `TRUE`, keep all variables in .data. If a combination -#' of `...` is not distinct, this keeps the first row of values. +#' @param ... Variables to use when determining uniqueness. Unlike the `dplyr` +#' implementation this must be set for the function to do anything, and only +#' a single variable is used. +#' @param .keep_all If `TRUE`, keep all variables in .data. Defaults to +#' `FALSE` +#' @details +#' This function has several potential uses. In it's default mode, it simply +#' shows the unique values for a supplied field: +#' +#' \preformatted{galah_call() |> +#' distinct(basisOfRecord) |> +#' collect() +#' +#' # A tibble: 9 × 1 +#' basisOfRecord +#' +#' 1 HUMAN_OBSERVATION +#' 2 PRESERVED_SPECIMEN +#' 3 OCCURRENCE +#' 4 MACHINE_OBSERVATION +#' 5 OBSERVATION +#' 6 MATERIAL_SAMPLE +#' 7 LIVING_SPECIMEN +#' 8 FOSSIL_SPECIMEN +#' 9 MATERIAL_CITATION +#' } +#' +#' This is the same result as you would get using [show_values()]: +#' +#' \preformatted{search_all(fields, "basisOfRecord") |> +#' show_values()} +#' +#' Using [distinct()] is somewhat more reliable, however, as it doesn't rely +#' on searching the tibble returned by `show_all(fields)`. It is also more +#' efficient, particularly when caching is turned off. If the goal is to +#' retrieve the _number_ of levels of a factor, use: +#' +#' \preformatted{galah_call() |> +#' distinct(basisOfRecord) |> +#' count() |> +#' collect() +#' +#' # A tibble: 1 × 1 +#' count +#' +#' 1 9 +#' } +#' +#' When the variable passed to [distinct()] in the above example is +#' `speciesID`, this is identical to calling: +#' +#' \preformatted{atlas_counts(type = "species")} +#' +#' You can also pass \code{\link[=group_by.data_request]{group_by()}} +#' to find the number of facets per level of a second variable: +#' +#' \preformatted{galah_call() |> +#' identify("Perameles") |> +#' distinct(speciesID) |> +#' group_by(basisOfRecord) |> +#' count() |> +#' collect() +#' +#' # A tibble: 8 × 2 +#' basisOfRecord count +#' +#' 1 Human observation 7 +#' 2 Preserved specimen 9 +#' 3 Machine observation 2 +#' 4 Observation 3 +#' 5 Occurrence 3 +#' 6 Material Sample 4 +#' 7 Fossil specimen 1 +#' 8 Living specimen 1 +#' } +#' +#' By setting `.keep_all = TRUE`, we get more information on each record. +#' Due to limits on the APIs this is not a perfect analogy for running +#' [dplyr::distinct()] on raw occurrences; but it does allow us to +#' generalise [atlas_species()] to use any taxonomic identifier. For example, +#' we might choose to show data by family instead of species: +#' +#' \preformatted{galah_call() |> +#' identify("Coleoptera") |> +#' distinct(familyID, .keep_all = TRUE) |> +#' collect()} +#' +#' Using [group_by()] is also valid: +#' +#' \preformatted{galah_call() |> +#' filter(year == 2024, +#' genus == "Crinia") |> +#' group_by(speciesID) |> +#' distinct(.keep_all = TRUE) |> +#' collapse()} +#' +#' In this case, \code{\link[=collect.data_request]{collect()}} and +#' [atlas_species()] are synonymous, with the exception that the latter +#' does not require you to set the `.keep_all` argument to `TRUE`. So you +#' could instead use: +#' +#' \preformatted{galah_call() |> +#' identify("Coleoptera") |> +#' distinct(familyID) |> +#' atlas_species()} +#' +#' @examples \dontrun{ +#' galah_call() |> +#' distinct(basisOfRecord) |> +#' count() |> +#' collect() +#' } #' @export distinct.data_request <- function(.data, ..., diff --git a/R/dplyr-filter.R b/R/dplyr-filter.R index dc969c40..cf2a0443 100644 --- a/R/dplyr-filter.R +++ b/R/dplyr-filter.R @@ -85,12 +85,12 @@ #' collect()} #' #' They can then use the `media` field to request media metadata: -#' \preformatted{media_metadata <- galah_call("metadata") |> +#' \preformatted{media_metadata <- request_metadata |> #' filter(media == occurrences) |> #' collect()} #' #' And finally, the metadata tibble can be used to request files: -#' \preformatted{galah_call("files") |> +#' \preformatted{request_files() |> #' filter(media == media_metadata) |> #' collect()} #' diff --git a/man/distinct.data_request.Rd b/man/distinct.data_request.Rd index ee39b677..749aff19 100644 --- a/man/distinct.data_request.Rd +++ b/man/distinct.data_request.Rd @@ -11,14 +11,114 @@ lazy data frame (e.g. from dbplyr or dtplyr). See Methods, below, for more details.} -\item{...}{Optional variables to use when determining uniqueness. If there -are multiple rows for a given combination of inputs, only the first row -will be preserved. If omitted, will use all variables in the data frame.} +\item{...}{Variables to use when determining uniqueness. Unlike the \code{dplyr} +implementation this must be set for the function to do anything, and only +a single variable is used.} -\item{.keep_all}{If \code{TRUE}, keep all variables in .data. If a combination -of \code{...} is not distinct, this keeps the first row of values.} +\item{.keep_all}{If \code{TRUE}, keep all variables in .data. Defaults to +\code{FALSE}} } \description{ Keep only unique/distinct rows from a data frame. This is similar to \code{\link[=unique.data.frame]{unique.data.frame()}} but considerably faster. It is evaluated lazily. } +\details{ +This function has several potential uses. In it's default mode, it simply +shows the unique values for a supplied field: + +\preformatted{galah_call() |> + distinct(basisOfRecord) |> + collect() + +# A tibble: 9 × 1 + basisOfRecord + +1 HUMAN_OBSERVATION +2 PRESERVED_SPECIMEN +3 OCCURRENCE +4 MACHINE_OBSERVATION +5 OBSERVATION +6 MATERIAL_SAMPLE +7 LIVING_SPECIMEN +8 FOSSIL_SPECIMEN +9 MATERIAL_CITATION +} + +This is the same result as you would get using \code{\link[=show_values]{show_values()}}: + +\preformatted{search_all(fields, "basisOfRecord") |> + show_values()} + +Using \code{\link[=distinct]{distinct()}} is somewhat more reliable, however, as it doesn't rely +on searching the tibble returned by \code{show_all(fields)}. It is also more +efficient, particularly when caching is turned off. If the goal is to +retrieve the \emph{number} of levels of a factor, use: + +\preformatted{galah_call() |> + distinct(basisOfRecord) |> + count() |> + collect() + +# A tibble: 1 × 1 + count + +1 9 +} + +When the variable passed to \code{\link[=distinct]{distinct()}} in the above example is +\code{speciesID}, this is identical to calling: + +\preformatted{atlas_counts(type = "species")} + +You can also pass \code{\link[=group_by.data_request]{group_by()}} +to find the number of facets per level of a second variable: + +\preformatted{galah_call() |> + identify("Perameles") |> + distinct(speciesID) |> + group_by(basisOfRecord) |> + count() |> + collect() + +# A tibble: 8 × 2 + basisOfRecord count + +1 Human observation 7 +2 Preserved specimen 9 +3 Machine observation 2 +4 Observation 3 +5 Occurrence 3 +6 Material Sample 4 +7 Fossil specimen 1 +8 Living specimen 1 +} + +By setting \code{.keep_all = TRUE}, we get more information on each record. +Due to limits on the APIs this is not a perfect analogy for running +\code{\link[dplyr:distinct]{dplyr::distinct()}} on raw occurrences; but it does allow us to +generalise \code{\link[=atlas_species]{atlas_species()}} to use any taxonomic identifier. For example, +we might choose to show data by family instead of species: + +\preformatted{galah_call() |> + identify("Coleoptera") |> + distinct(familyID, .keep_all = TRUE) |> + collect()} + +In this case, \code{\link[=collect.data_request]{collect()}} and +\code{\link[=atlas_species]{atlas_species()}} are synonymous, with the exception that the latter +does not require you to set the \code{.keep_all} argument to \code{TRUE}. So you +could instead use: + +\preformatted{galah_call() |> + identify("Coleoptera") |> + distinct(familyID) |> + atlas_species()} +} +\examples{ +\dontrun{ +galah_call() |> + distinct(basisOfRecord) |> + count() |> + collect() +} +} diff --git a/man/filter.data_request.Rd b/man/filter.data_request.Rd index 0e093221..dbcde5c2 100644 --- a/man/filter.data_request.Rd +++ b/man/filter.data_request.Rd @@ -102,12 +102,12 @@ should begin with an occurrence query: collect()} They can then use the \code{media} field to request media metadata: -\preformatted{media_metadata <- galah_call("metadata") |> +\preformatted{media_metadata <- request_metadata |> filter(media == occurrences) |> collect()} And finally, the metadata tibble can be used to request files: -\preformatted{galah_call("files") |> +\preformatted{request_files() |> filter(media == media_metadata) |> collect()} } diff --git a/tests/testthat/test-atlas_species.R b/tests/testthat/test-atlas_species.R index 9578e9ad..5f339b56 100644 --- a/tests/testthat/test-atlas_species.R +++ b/tests/testthat/test-atlas_species.R @@ -156,4 +156,17 @@ test_that("atlas_species reformats column names when empty tibble is returned", expect_s3_class(species, c("tbl_df", "tbl", "data.frame")) }) +test_that("`atlas_species()` accepts `distinct()` to set the grouping variable", { + genera <- galah_call() |> + identify("Limnodynastidae") |> + distinct(genusID) |> + quiet_species() + expect_s3_class(genera, c("tbl_df", "tbl", "data.frame")) + expect_true(nrow(genera) > 4 & nrow(genera) < 10) + expect_gte(ncol(genera), 10) # test that `.keep_all = TRUE` is not required + # as this is implied (and asserted) by calling `atlas_species()` + all(genera$taxon_rank == "genus") |> + expect_true() +}) + rm(quiet_collect, quiet_species) \ No newline at end of file diff --git a/tests/testthat/test-dplyr-distinct.R b/tests/testthat/test-dplyr-distinct.R index 2b0529ff..7d1078f9 100644 --- a/tests/testthat/test-dplyr-distinct.R +++ b/tests/testthat/test-dplyr-distinct.R @@ -99,17 +99,32 @@ test_that("`distinct(.keep_all = TRUE)` sets species queries", { c("species", "species_name", "kingdom")) }) +test_that("`distinct(.keep_all = TRUE)` accepts non-species-level groupings", { + genera <- galah_call() |> + identify("Limnodynastidae") |> + distinct(genusID, .keep_all = TRUE) |> + quiet_collect() + expect_s3_class(genera, c("tbl_df", "tbl", "data.frame")) + expect_true(nrow(genera) > 4 & nrow(genera) < 10) + all(genera$taxon_rank == "genus") |> + expect_true() +}) + test_that("`distinct(variable) |> count()` can be used to count the number of levels", { + # NOTE: This is set to `basisOfRecord` because the number of values is easy to verify + # taxonomic identifiers are more slippery and therefore less reliable to test skip_if_offline(); skip_on_ci() - result <- galah_call() |> - identify("perameles") |> - distinct(taxonConceptID) |> + levels_all <- galah_call() |> + distinct(basisOfRecord) |> + quiet_collect() + levels_count <- galah_call() |> + distinct(basisOfRecord) |> count() |> quiet_collect() - expect_s3_class(result, + expect_s3_class(levels_count, c("tbl_df", "tbl", "data.frame")) - expect_equal(nrow(result), 1) - expect_true(result$count[1] > 1 & result$count[1] < 10) + expect_equal(nrow(levels_count), 1) + expect_equal(nrow(levels_all), levels_count$count) }) test_that("`group_by(something) |> distinct(speciesID) |> count()` gives grouped number of categories", { From eb343e65129bb70fd9c29ca703e1edd7b6712c27 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Tue, 3 Feb 2026 16:26:14 +1100 Subject: [PATCH 73/94] Fix various bugs - enable media downloads for various atlases (mostly broken urls) - re-enable atlas-specific species facets - upgrade UK to pipelines code - various improvements to tests --- R/atlas_media.R | 4 +- R/atlas_species.R | 2 +- R/capture.R | 4 +- R/capture_occurrences.R | 33 --------------- R/collect_metadata.R | 23 +++++++---- R/collect_metadata_unnest.R | 38 ++++++++++++++---- R/collect_occurrences.R | 3 -- R/compute_occurrences.R | 1 - R/sysdata.rda | Bin 6438 -> 6456 bytes R/utilities_internal.R | 15 +++---- data-raw/node_config.csv | 8 ++-- tests/testthat/test-international-Austria.R | 11 ++--- tests/testthat/test-international-Brazil.R | 28 ++++++------- tests/testthat/test-international-GBIF.R | 6 +-- tests/testthat/test-international-Guatemala.R | 2 +- tests/testthat/test-international-Spain.R | 12 ++---- tests/testthat/test-international-Sweden.R | 12 +++--- tests/testthat/test-international-UK.R | 8 ++-- 18 files changed, 95 insertions(+), 115 deletions(-) diff --git a/R/atlas_media.R b/R/atlas_media.R index cb179c5d..12bedbb7 100644 --- a/R/atlas_media.R +++ b/R/atlas_media.R @@ -100,10 +100,10 @@ atlas_media <- function(request = NULL, build_media_id <- function(df, media_fields){ if(any(colnames(df) == "all_image_url")){ df |> - dplyr::mutate("media_id" = "all_image_url", + dplyr::mutate("media_id" = .data$all_image_url, "media_type" = "images", .before = 1) |> - dplyr::select(-"images") + dplyr::select(-"all_image_url") }else{ purrr::map(media_fields, .f = \(a){ df |> diff --git a/R/atlas_species.R b/R/atlas_species.R index fef48c66..178d4e24 100644 --- a/R/atlas_species.R +++ b/R/atlas_species.R @@ -32,7 +32,7 @@ atlas_species <- function(request = NULL, check_distinct_for_atlas_species <- function(.query){ if(is.null(.query$distinct)){ .query |> - distinct("speciesID", .keep_all = TRUE) + distinct(species_facets(), .keep_all = TRUE) }else{ .query$distinct$keep_all <- TRUE .query diff --git a/R/capture.R b/R/capture.R index 059b7a5e..9c0e0714 100644 --- a/R/capture.R +++ b/R/capture.R @@ -191,9 +191,9 @@ check_distinct_count_groupby <- function(x){ # this is clunky, but backwards compatible if(x$type == "species" & !has_distinct){ if(has_count){ - x <- x |> distinct("speciesID", .keep_all = FALSE) + x <- x |> distinct(species_facets(), .keep_all = FALSE) }else{ - x <- x |> distinct("speciesID", .keep_all = TRUE) + x <- x |> distinct(species_facets(), .keep_all = TRUE) } has_distinct <- TRUE } diff --git a/R/capture_occurrences.R b/R/capture_occurrences.R index 4759eca2..1d7f7d85 100644 --- a/R/capture_occurrences.R +++ b/R/capture_occurrences.R @@ -11,43 +11,10 @@ capture_occurrences <- function(.query, call = error_call) } switch(potions::pour("atlas", "region"), - "United Kingdom" = capture_occurrences_uk(.query, ...), "Global" = capture_occurrences_gbif(.query, ...), capture_occurrences_la(.query, ...)) } -#' calculate the query to be returned for the UK atlas -#' @param .query An object of class `data_request()` -#' @noRd -#' @keywords Internal -capture_occurrences_uk <- function(.query, ...){ - # set default columns - if(is.null(.query$select)){ - .query$select <- galah_select(group = "basic") - } - - # build a url - # NOTE: providing an email blocks this from executing (2023-08-30) - url <- url_lookup("data/occurrences") |> - httr2::url_parse() - url$query <- c(build_query(identify = .query$identify, - filter = .query$filter, - location = .query$geolocate, - apply_profile = .query$apply_profile), - fields = "`SELECT_PLACEHOLDER`", - qa = "`ASSERTIONS_PLACEHOLDER`", - sourceTypeId = source_type_id_lookup("United Kingdom"), - fileType = "csv", - reasonTypeId = potions::pour("user", "download_reason_id"), - dwcHeaders = "true") - - # build output - list(type = "data/occurrences", - url = httr2::url_build(url), - headers = build_headers()) |> - as_prequery() -} - #' calculate the query to be returned for GBIF #' @noRd #' @keywords Internal diff --git a/R/collect_metadata.R b/R/collect_metadata.R index d03ab522..62d861c5 100644 --- a/R/collect_metadata.R +++ b/R/collect_metadata.R @@ -141,15 +141,22 @@ collect_collections <- function(.query){ result <- .query |> query_API() result_df <- result |> - dplyr::bind_rows() |> - dplyr::relocate("uid") |> - dplyr::rename("id" = "uid") + dplyr::bind_rows() + if(any(colnames(result_df) == "uid")){ + result_df <- result_df |> + dplyr::relocate("uid") |> + dplyr::rename("id" = "uid") + } } - result_df <- result_df |> - dplyr::rename_with(camel_to_snake_case) |> - parse_arrange() |> - update_attributes(type = "collections") - update_cache(collections = result_df) + + if(nrow(result_df) > 0){ + result_df <- result_df |> + dplyr::rename_with(camel_to_snake_case) |> + parse_arrange() |> + update_attributes(type = "collections") + update_cache(collections = result_df) + } + } parse_select(result_df, .query) } diff --git a/R/collect_metadata_unnest.R b/R/collect_metadata_unnest.R index 2e670272..39c77a57 100644 --- a/R/collect_metadata_unnest.R +++ b/R/collect_metadata_unnest.R @@ -59,16 +59,40 @@ check_missing_fields <- function(x, call){ #' @noRd #' @keywords Internal collect_lists_unnest <- function(.query){ - result <- query_API(.query) |> - dplyr::bind_rows() + + clean_common_names <- function(df){ + if(any(colnames(df) == "commonName")){ + df$commonName <- as.character(df$commonName) + if(any(df$commonName == "NULL")){ + df$commonName[df$commonName == "NULL"] <- NA + } + } + df + } + # extract additional raw fields columns - if (any(colnames(result) %in% "kvpValues")) { - result <- result |> - tidyr::unnest_wider("kvpValues") |> - tidyr::pivot_wider(names_from = "key", - values_from = "value") + clean_kvp_values <- function(df){ + browser() + if(any(colnames(df) == "kvpValues")){ + if(any(lengths(df$kvpValues) > 0)){ + df <- df |> + tidyr::unnest_wider("kvpValues") |> + tidyr::pivot_wider(names_from = "key", + values_from = "value") + } + } + df } + + # get data + result <- query_API(.query) + + # process result |> + purrr::list_transpose() |> + tibble::as_tibble() |> + clean_common_names() |> + clean_kvp_values() |> dplyr::rename_with(camel_to_snake_case) |> parse_rename(.query) |> parse_select(.query) diff --git a/R/collect_occurrences.R b/R/collect_occurrences.R index fd1ff145..e2a37aab 100644 --- a/R/collect_occurrences.R +++ b/R/collect_occurrences.R @@ -16,9 +16,6 @@ collect_occurrences <- function(.query, "Austria" = collect_occurrences_direct(.query, file = file, call = error_call), - "United Kingdom" = collect_occurrences_direct(.query, - file = file, - call = error_call), collect_occurrences_default(.query, wait = wait, file = file, diff --git a/R/compute_occurrences.R b/R/compute_occurrences.R index c36c6034..6a541ab4 100644 --- a/R/compute_occurrences.R +++ b/R/compute_occurrences.R @@ -4,7 +4,6 @@ compute_occurrences <- function(.query){ switch(potions::pour("atlas", "region"), "Austria" = compute_occurrences_la_direct(.query), - "United Kingdom" = compute_occurrences_la_direct(.query), "Global" = compute_occurrences_gbif(.query), compute_occurrences_la(.query)) } diff --git a/R/sysdata.rda b/R/sysdata.rda index 170d6376998c7ed9ea7376fb8d71e9503acc8839..7288a268806e46279ff855df11bb914f22bf1a72 100644 GIT binary patch literal 6456 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 S*uL@q;_gVN3KA6sVKLx0NunA6 literal 6438 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 diff --git a/R/utilities_internal.R b/R/utilities_internal.R index c323fe1f..80ccec4f 100644 --- a/R/utilities_internal.R +++ b/R/utilities_internal.R @@ -354,8 +354,7 @@ default_columns <- function() { "Brazil", "Guatemala", "Kew", - "Portugal", - "United Kingdom")){ + "Portugal")){ c("id", "taxon_name", "taxon_concept_lsid", @@ -378,7 +377,8 @@ default_columns <- function() { }else if(atlas %in% c("Australia", "Flanders", "Spain", - "Sweden")){ + "Sweden", + "United Kingdom")){ c("recordID", # note this requires that the ALA name (`id`) be corrected "scientificName", "taxonConceptID", @@ -401,13 +401,13 @@ image_fields <- function() { "Brazil", "Guatemala", "Kew", - "Portugal", - "United Kingdom")){ + "Portugal")){ "all_image_url" }else if(atlas %in% c("Australia", "Flanders", "Spain", - "Sweden")){ + "Sweden", + "United Kingdom")){ c("multimedia", "images", "sounds", "videos") }else{ cli::cli_abort("Unknown `atlas`") @@ -451,7 +451,8 @@ species_facets <- function(){ "Flanders", "France", "Spain", - "Sweden")) { + "Sweden", + "United Kingdom")) { "speciesID" }else{ "species_guid" diff --git a/data-raw/node_config.csv b/data-raw/node_config.csv index 1254292a..6213c730 100644 --- a/data-raw/node_config.csv +++ b/data-raw/node_config.csv @@ -60,7 +60,7 @@ Brazil,metadata/fields-unnest,https://biocache-service.sibbr.gov.br/biocache-ser Brazil,metadata/licences,https://images.sibbr.gov.br/ws/licence,TRUE Brazil,metadata/lists,https://specieslist.sibbr.gov.br/ws/speciesList/,TRUE Brazil,metadata/lists-unnest,https://specieslist.sibbr.gov.br/ws/speciesListItems/{list_id},TRUE -Brazil,metadata/media,https://images.sibbr.gov.br/ws/image/{id},TRUE +Brazil,metadata/media,https://images.sibbr.gov.br/ws/image/details?id={id},TRUE Brazil,metadata/providers,https://collectory.sibbr.gov.br/collectory/ws/dataProvider,TRUE Brazil,metadata/taxa-single,https://bie-webservice.sibbr.gov.br/bie-index/search?q={name}&pageSize=5,TRUE Brazil,metadata/taxa-unnest,https://bie-webservice.sibbr.gov.br/bie-index/childConcepts/{id},TRUE @@ -188,7 +188,7 @@ Spain,data/occurrences-count-groupby,https://registros-ws.gbif.es/occurrence/fac Spain,data/occurrences-doi,https://doi.gbif.es/doi/{doi_string}/download,TRUE Spain,data/species,https://registros-ws.gbif.es/occurrences/facets/download,TRUE Spain,data/species-count,https://registros-ws.gbif.es/occurrence/facets,TRUE -Spain,files/media,https://imagenes.gbif.es/images/{id}/{size},TRUE +Spain,files/media,https://imagenes.gbif.es/image/{id}/{size},TRUE Spain,metadata/assertions,https://registros-ws.gbif.es/assertions/codes,TRUE Spain,metadata/collections,https://colecciones.gbif.es/ws/collection,TRUE Spain,metadata/datasets,https://colecciones.gbif.es/ws/dataResource,TRUE @@ -198,7 +198,7 @@ Spain,metadata/identifiers,https://name-matching.gbif.es/api/getByTaxonID,TRUE Spain,metadata/licences,https://imagenes.gbif.es/ws/licence,TRUE Spain,metadata/lists,https://listas.gbif.es/ws/speciesList,TRUE Spain,metadata/lists-unnest,https://listas.gbif.es/ws/speciesListItems/{list_id},TRUE -Spain,metadata/media,https://imagenes.gbif.es/ws/images/{id},TRUE +Spain,metadata/media,https://imagenes.gbif.es/ws/image/details?id={id},TRUE Spain,metadata/profiles,https://data-quality.gbif.es/api/v1/data-profiles,TRUE Spain,metadata/profiles-unnest,https://data-quality.gbif.es/api/v1/quality/activeProfile?profileName={profile},TRUE Spain,metadata/providers,https://colecciones.gbif.es/ws/dataProvider,TRUE @@ -229,7 +229,7 @@ Sweden,metadata/reasons,https://logger.biodiversitydata.se/service/logger/reason Sweden,metadata/taxa-multiple,https://namematching.biodiversitydata.se/api/searchByClassification,TRUE Sweden,metadata/taxa-single,https://namematching.biodiversitydata.se/api/search?q={name},TRUE Sweden,metadata/taxa-unnest,https://species.biodiversitydata.se/ws/childConcepts/{id},TRUE -United Kingdom,data/occurrences,https://records-ws.nbnatlas.org/occurrences/index/download,TRUE +United Kingdom,data/occurrences,https://records-ws.nbnatlas.org/occurrences/offline/download,TRUE United Kingdom,data/occurrences-count,https://records-ws.nbnatlas.org/occurrences/search,TRUE United Kingdom,data/occurrences-count-groupby,https://records-ws.nbnatlas.org/occurrence/facets,TRUE United Kingdom,data/species,https://records-ws.nbnatlas.org/occurrences/facets/download,TRUE diff --git a/tests/testthat/test-international-Austria.R b/tests/testthat/test-international-Austria.R index b4eafd04..eec9bb12 100644 --- a/tests/testthat/test-international-Austria.R +++ b/tests/testthat/test-international-Austria.R @@ -250,18 +250,14 @@ test_that("atlas_media() works for Austria", { send_email = FALSE) x <- request_data() |> identify("Mammalia") |> - filter(year == 2010, - # !is.na(all_image_url) - ) |> - # count() |> - # collect() + filter(year == 2010) |> # !is.na(all_image_url) atlas_media() |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") expect_s3_class(x, c("tbl_df", "tbl", "data.frame")) expect_gte(nrow(x), 1) expect_equal(colnames(x)[1:2], - c("media_id", "recordID")) + c("media_id", "media_type")) # download a subset n_downloads <- 5 collect_media(x[seq_len(n_downloads), ]) @@ -270,13 +266,12 @@ test_that("atlas_media() works for Austria", { unlink("temp", recursive = TRUE) }) -## FIXME: atlas_taxonomy doesn't work test_that("atlas_taxonomy works for Austria", { skip_if_offline(); skip_on_ci() y <- galah_call() |> identify("Aves") |> filter(rank >= order) |> - atlas_taxonomy()|> + atlas_taxonomy() |> try(silent = TRUE) skip_if(inherits(y, "try-error"), message = "API not available") # add tests diff --git a/tests/testthat/test-international-Brazil.R b/tests/testthat/test-international-Brazil.R index cfaabd0a..151d0869 100644 --- a/tests/testthat/test-international-Brazil.R +++ b/tests/testthat/test-international-Brazil.R @@ -85,7 +85,7 @@ test_that("search_all(taxa) works for Brazil", { expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) }) -test_that("show_values works for Brazil", { +test_that("`show_values()` works for Brazil", { skip_if_offline(); skip_on_ci() x <- search_fields("basis_of_record") |> show_values() |> @@ -102,7 +102,7 @@ test_that("show_values works for Brazil", { expect_error() }) -test_that("atlas_counts works for Brazil", { +test_that("`atlas_counts()` works for Brazil", { skip_if_offline(); skip_on_ci() x <- atlas_counts() |> dplyr::pull(count) |> @@ -111,7 +111,7 @@ test_that("atlas_counts works for Brazil", { expect_gt(x, 0) }) -test_that("atlas_counts works with type = 'species' for Brazil", { +test_that("`atlas_counts()` works with type = 'species' for Brazil", { skip_if_offline(); skip_on_ci() x <- atlas_counts(type = "species") |> dplyr::pull(count) |> @@ -120,10 +120,10 @@ test_that("atlas_counts works with type = 'species' for Brazil", { expect_gt(x, 0) }) -test_that("atlas_counts works with galah_identify for Brazil", { +test_that("`atlas_counts()` works with `identify()` for Brazil", { skip_if_offline(); skip_on_ci() query1 <- galah_call() |> - galah_identify("Mammalia") |> + identify("Mammalia") |> count() |> collapse() # note: this is set up differently for debugging result1 <- collect(query1) |> @@ -131,7 +131,7 @@ test_that("atlas_counts works with galah_identify for Brazil", { skip_if(inherits(result1, "try-error"), message = "API not available") expect_gt(result1$count, 1) query2 <- galah_call() |> - galah_filter(class == "Mammalia") |> + filter(class == "Mammalia") |> count() |> collapse() result2 <- collect(query2) |> @@ -144,7 +144,7 @@ test_that("atlas_counts works with galah_identify for Brazil", { ## This isn't met for this atlas, for unknown reasons }) -test_that("atlas_counts works with group_by for Brazil", { +test_that("`atlas_counts()` works with `group_by()` for Brazil", { skip_if_offline(); skip_on_ci() result <- galah_call() |> galah_filter(year >= 2020) |> @@ -156,7 +156,7 @@ test_that("atlas_counts works with group_by for Brazil", { expect_equal(names(result), c("year", "count")) }) -test_that("atlas_species works for Brazil", { +test_that("`atlas_species()` works for Brazil", { skip_if_offline(); skip_on_ci() galah_config( atlas = "Brazil", @@ -164,7 +164,7 @@ test_that("atlas_species works for Brazil", { run_checks = FALSE, send_email = FALSE) spp <- galah_call() |> - galah_identify("Carnivora") |> + identify("Carnivora") |> atlas_species() |> try(silent = TRUE) skip_if(inherits(spp, "try-error"), message = "API not available") @@ -174,7 +174,7 @@ test_that("atlas_species works for Brazil", { }) ## FIXME: Caused by taxonomic search issue -test_that("atlas_occurrences works for Brazil", { +test_that("`atlas_occurrences()` works for Brazil", { skip_if_offline(); skip_on_ci() galah_config( atlas = "Brazil", @@ -208,7 +208,7 @@ test_that("atlas_occurrences works for Brazil", { # "Ramphastos toco" # toucan # "Myrmecophaga tridactyla" # anteater -test_that("atlas_media() works for Brazil", { +test_that("`atlas_media()` works for Brazil", { skip_if_offline(); skip_on_ci() galah_config( atlas = "Brazil", @@ -217,11 +217,7 @@ test_that("atlas_media() works for Brazil", { send_email = FALSE) x <- request_data() |> identify("Mammalia") |> - filter(year == 2010 - # !is.na(all_image_url) - ) |> - # count() |> - # collect() + filter(year == 2010) |> atlas_media() |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") diff --git a/tests/testthat/test-international-GBIF.R b/tests/testthat/test-international-GBIF.R index 9ecf077d..5f58dafc 100644 --- a/tests/testthat/test-international-GBIF.R +++ b/tests/testthat/test-international-GBIF.R @@ -41,14 +41,12 @@ test_that("show_values works for GBIF fields", { x <- request_metadata() |> filter(fields == "gbifRegion") |> unnest() |> - collapse() - collect(x) + collect() # traditional syntax y <- search_fields("gbifRegion") |> show_values() # tests - x |> - inherits(c("tbl_df", "tbl", "data.frame")) |> + inherits(x, c("tbl_df", "tbl", "data.frame")) |> expect_true() x |> nrow() |> diff --git a/tests/testthat/test-international-Guatemala.R b/tests/testthat/test-international-Guatemala.R index 24926cb9..c46b759c 100644 --- a/tests/testthat/test-international-Guatemala.R +++ b/tests/testthat/test-international-Guatemala.R @@ -66,7 +66,7 @@ test_that("show_all(profiles) fails for Guatemala", { expect_error(show_all(profiles)) }) -test_that("show_all(lists) works for Guatemala", { +test_that("show_all(profiles) fails for Guatemala", { expect_error(show_all(profiles)) }) diff --git a/tests/testthat/test-international-Spain.R b/tests/testthat/test-international-Spain.R index f7648e27..db7af788 100644 --- a/tests/testthat/test-international-Spain.R +++ b/tests/testthat/test-international-Spain.R @@ -213,13 +213,13 @@ test_that("atlas_counts works with group_by for Spain", { skip_if_offline(); skip_on_ci() result <- galah_call() |> filter(year >= 2000) |> - group_by(basis_of_record) |> + group_by(basisOfRecord) |> count() |> collect() |> try(silent = TRUE) skip_if(inherits(result, "try-error"), message = "API not available") expect_gt(nrow(result), 1) - expect_equal(names(result), c("basis_of_record", "count")) + expect_equal(names(result), c("basisOfRecord", "count")) }) test_that("atlas_counts works with apply_profile for Spain", { @@ -296,18 +296,14 @@ test_that("atlas_media() works for Spain", { send_email = FALSE) x <- request_data() |> identify("Mammalia") |> - filter(year >= 2023 - # imageIDsCount > 0 - ) |> - # count() |> - # collect() + filter(year >= 2023) |> atlas_media() |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") # FIXME: failing here expect_s3_class(x, c("tbl_df", "tbl", "data.frame")) expect_gte(nrow(x), 1) expect_equal(colnames(x)[1:2], - c("media_id", "recordID")) + c("media_id", "media_type")) # download a subset n_downloads <- 5 collect_media(x[seq_len(n_downloads), ]) diff --git a/tests/testthat/test-international-Sweden.R b/tests/testthat/test-international-Sweden.R index 3ac3cdd2..cd757c98 100644 --- a/tests/testthat/test-international-Sweden.R +++ b/tests/testthat/test-international-Sweden.R @@ -1,11 +1,11 @@ # set verbose to off galah_config(verbose = FALSE, run_checks = FALSE) -test_that("swapping to atlas = Sweden works", { +test_that("`galah_config(atlas = Sweden)` works", { expect_message(galah_config(atlas = "Sweden")) }) -test_that("show_all(collections) works for Sweden", { +test_that("`show_all(collections)` works for Sweden", { skip_if_offline(); skip_on_ci() x <- show_all(collections, limit = 10) |> try(silent = TRUE) @@ -14,7 +14,7 @@ test_that("show_all(collections) works for Sweden", { expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) }) -test_that("show_all(datasets) works for Sweden", { +test_that("`show_all(datasets)` works for Sweden", { skip_if_offline(); skip_on_ci() x <- show_all(datasets, limit = 10) |> try(silent = TRUE) @@ -23,7 +23,7 @@ test_that("show_all(datasets) works for Sweden", { expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) }) -test_that("show_all(fields) works for Sweden", { +test_that("`show_all(fields)` works for Sweden", { skip_if_offline(); skip_on_ci() x <- show_all(fields) |> try(silent = TRUE) @@ -32,7 +32,7 @@ test_that("show_all(fields) works for Sweden", { expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) }) -test_that("show_all(licences) works for Sweden", { +test_that("`show_all(licences)` works for Sweden", { skip_if_offline(); skip_on_ci() x <- show_all(licences, limit = 10) |> try(silent = TRUE) @@ -298,7 +298,7 @@ test_that("atlas_media() works for Sweden", { expect_s3_class(x, c("tbl_df", "tbl", "data.frame")) expect_gte(nrow(x), 1) expect_equal(colnames(x)[1:2], - c("media_id", "recordID")) + c("media_id", "media_type")) }) test_that("collect_media() works for Sweden", { diff --git a/tests/testthat/test-international-UK.R b/tests/testthat/test-international-UK.R index c23dcf8f..2b2b802a 100644 --- a/tests/testthat/test-international-UK.R +++ b/tests/testthat/test-international-UK.R @@ -108,7 +108,7 @@ test_that("search_taxa doesn't break with typos", { test_that("show_values works for UK", { skip_if_offline(); skip_on_ci() - x <- search_fields("basis_of_record") |> + x <- search_fields("basisOfRecord") |> show_values() |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") @@ -117,7 +117,7 @@ test_that("show_values works for UK", { test_that("show_list_values works for United Kingdom", { skip_if_offline(); skip_on_ci() - x <- search_lists("dr556") |> + x <- search_lists("dr1445") |> show_values() |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") @@ -142,7 +142,7 @@ test_that("atlas_counts works with type = 'species' for United Kingdom", { expect_gt(x, 0) }) -test_that("atlas_counts works with galah_identify for United Kingdom", { +test_that("atlas_counts works with `identify()` for United Kingdom", { skip_if_offline(); skip_on_ci() result <- galah_call() |> identify("Vulpes") |> @@ -256,7 +256,7 @@ test_that("atlas_media() works for UK", { expect_s3_class(x, c("tbl_df", "tbl", "data.frame")) expect_gte(nrow(x), 1) expect_equal(colnames(x)[1:2], - c("media_id", "recordID")) + c("media_id", "media_type")) # download a subset n_downloads <- 5 collect_media(x[seq_len(n_downloads), ]) From 7d7740919e0d72cd3c17efb3bf623a472a2dd03b Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Wed, 4 Feb 2026 16:34:38 +1100 Subject: [PATCH 74/94] Update GBIF predicates support in `filter()` Basically running now, a few minor bugs still to fix which are being caught by tests --- R/build_predicates.R | 43 ++- R/capture_occurrences_count.R | 3 +- R/collapse_occurrences_count_atlas.R | 1 - R/dplyr-select.R | 10 +- R/handle_quosures_GBIF.R | 32 +- tests/testthat/test-dplyr-filter-GBIF.R | 94 ------ .../test-international-GBIF-predicates.R | 289 ++++++++++++++++++ tests/testthat/test-international-GBIF.R | 150 +-------- 8 files changed, 338 insertions(+), 284 deletions(-) delete mode 100644 tests/testthat/test-dplyr-filter-GBIF.R create mode 100644 tests/testthat/test-international-GBIF-predicates.R diff --git a/R/build_predicates.R b/R/build_predicates.R index aaec8629..9aef6536 100644 --- a/R/build_predicates.R +++ b/R/build_predicates.R @@ -3,34 +3,27 @@ #' @noRd #' @keywords Internal build_predicates <- function(x){ + + # handle newly supplied information + x_identify <- parse_predicates_identify(x$identify) + x_location <- parse_predicates_location(x$geolocate) - # combine provided information - filters_list <- c( - parse_predicates_filter(x), - parse_predicates_identify(x$identify), - parse_predicates_location(x$geolocate)) |> - remove_nulls_from_list() - - # return correctly structured object - if(length(filters_list) < 1){ - NULL - }else{ - names(filters_list) <- NULL # important for parsing with toJSON - list(type = "and", + # for and queries, we extract everything, add new content, then rebuild + if(is_and_query(x)){ + filters_list <- c(x$filter$predicates, x_identify, x_location) |> + remove_nulls_from_list() + names(filters_list) <- NULL + list(type = "and", predicates = filters_list) - # NOTE: This is messy for length-1, but does work - } -} - -#' Cleanly handle filter args -#' @noRd -#' @keywords Internal -parse_predicates_filter <- function(x){ - if(is.null(x)){ - NULL }else{ - if(is_and_query(x)){ - x$filter$predicates + # if we have been given further information, use AND + if(!is.null(x_identify) | !is.null(x_location)){ + filters_list <- c(x$filter, x_identify, x_location) |> # note: not x$filter$predicates + remove_nulls_from_list() + names(filters_list) <- NULL + list(type = "and", + predicates = filters_list) + # otherwise we can pass what we were given (usually an OR statement) }else{ x$filter } diff --git a/R/capture_occurrences_count.R b/R/capture_occurrences_count.R index a00f2f60..01d92e6f 100644 --- a/R/capture_occurrences_count.R +++ b/R/capture_occurrences_count.R @@ -122,7 +122,6 @@ capture_occurrences_count_gbif <- function(identify = NULL, options = list( httpauth = 1, userpwd = user_string), - body = predicates_info, - slot_name = "count") |> + body = predicates_info) |> as_prequery() } diff --git a/R/collapse_occurrences_count_atlas.R b/R/collapse_occurrences_count_atlas.R index 355827f4..0364580b 100644 --- a/R/collapse_occurrences_count_atlas.R +++ b/R/collapse_occurrences_count_atlas.R @@ -187,7 +187,6 @@ check_facet_count <- function(.query, url$query$flimit <- 0 temp_data <- .query temp_data$url <- httr2::url_build(url) - temp_data$slot_name <- NULL result <- query_API(temp_data) if(length(result) < 1){ 0 diff --git a/R/dplyr-select.R b/R/dplyr-select.R index d3e0c705..05c27c14 100644 --- a/R/dplyr-select.R +++ b/R/dplyr-select.R @@ -129,16 +129,16 @@ #' } #' @export select.data_request <- function(.data, ..., group = NULL){ - if(is_gbif()){ - cli::cli_text("`select()` is not supported for GBIF occurrence downloads API v1: skipping") - .data - }else{ + # if(is_gbif()){ + # cli::cli_text("`select()` is not supported for GBIF occurrence downloads API v1: skipping") + # .data + # }else{ dots <- rlang::enquos(..., .ignore_empty = "all") list(quosure = dots, summary = generate_summary(dots)) |> add_group(group) |> update_request_object(.data, select = _) - } + # } } #' @rdname select.data_request diff --git a/R/handle_quosures_GBIF.R b/R/handle_quosures_GBIF.R index 00bba92b..b82ad2a0 100644 --- a/R/handle_quosures_GBIF.R +++ b/R/handle_quosures_GBIF.R @@ -14,14 +14,20 @@ parse_quosures_data_gbif <- function(dots){ if(length(dots) > 0){ predicates <- purrr::map(dots, switch_expr_type_pred) - names(predicates) <- NULL # NOTE: This step is *crucial* - # without it, jsonlite::toJSON() wraps predicates in `{}` instead of `[]` - # which is then rejected by GBIF - if(length(predicates) > 1L){ - result <- list(type = "and", - predicates = result) + # sometimes, because we call `map()`, we end up with predicates + # buried one layer down in the list. Correct this + if(length(predicates) == 1L){ + # if(is.list(predicates[[1]])){ + result <- predicates[[1]] + # } }else{ - result <- predicates + # wipe predicate names + # NOTE: This step is *crucial* + # without it, jsonlite::toJSON() wraps predicates in `{}` instead of `[]` + # which is then rejected by GBIF + names(predicates) <- NULL + result <- list(type = "and", + predicates = predicates) } as_predicates_filter(result) }else{ @@ -118,9 +124,9 @@ parse_relational_pred <- function(x){ if(operator == "!="){ list( type = "not", - list(type = "equals", - key = lhs, - value = rhs)) + predicate = list(type = "equals", + key = lhs, + value = rhs)) # everything else is flat }else{ @@ -169,7 +175,7 @@ parse_logical_pred <- function(x){ #' @noRd #' @keywords internal parse_brackets_pred <- function(x){ - if(length(quo_get_expr(x)) != 2L){ + if(length(rlang::quo_get_expr(x)) != 2L){ filter_error() } try_next_quosure_pred(x) @@ -188,11 +194,11 @@ parse_exclamation_pred <- function(x){ next_section }else{ list(type = "not", - next_section) + predicate = next_section) } }else{ list(type = "not", - next_section) + predicate = next_section) } } diff --git a/tests/testthat/test-dplyr-filter-GBIF.R b/tests/testthat/test-dplyr-filter-GBIF.R deleted file mode 100644 index ea157de3..00000000 --- a/tests/testthat/test-dplyr-filter-GBIF.R +++ /dev/null @@ -1,94 +0,0 @@ -quiet_config <- purrr::quietly(galah_config) -x <- quiet_config(atlas = "GBIF") - -test_that("galah_filter() returns predicates for GBIF", { - x <- galah_filter(year == 2024) - inherits(x, "list") |> - expect_true() - expect_equal(names(x[[1]]), - c("type", "key", "value")) - expect_equal(x[[1]], - list(type = "equals", - key = "YEAR", - value = "2024")) -}) - -# FIXME: `check_fields()` not tested for GBIF - try sending invalid fields to `filter()` - -# only the above test contains information rn -# -# test_that("filter() handles multiple queries for GBIF", { -# result <- galah_call() |> -# filter(year == 2024, basisOfRecord == "HUMAN_OBSERVATION") |> -# collapse() -# -# str(result) -# }) -# -# test_that("filter() handles multiple queries including != for GBIF", { -# result <- galah_call() |> -# filter(year == 2024, countryCode != "AU") -# -# str(result) -# }) -# -# test_that("filter() handles AND for GBIF", { -# result <- galah_call() |> -# filter(year == 2024 & countryCode != "AU") -# -# str(result) -# }) -# -# # assertions? -# -# test_that("filter() handles `between()` for GBIF", { -# galah_call() |> -# filter(dplyr::between(year, 2010, 2020)) |> -# str() -# }) -# -# -# test_that("filter() handles %in% for GBIF", { -# galah_call() |> -# filter(year %in% c(2010, 2020)) |> -# str() -# }) -# -# test_that("filter() handles !() for GBIF", { -# galah_call() |> -# filter(!(year %in% c(2010, 2020))) |> -# str() -# }) -# -# test_that("filter() handles is.na() for GBIF", { -# galah_call() |> -# filter(is.na(country)) |> -# str() -# }) -# -# test_that("filter() handles !is.na() for GBIF", { -# galah_call() |> -# filter(!is.na(country)) |> -# str() -# }) -# -# test_that("filter() handles c() for GBIF", { -# # check when supplied directly -# galah_call() |> -# filter(country == c("AU", "UK", "AZ")) |> -# str() -# -# # and as a vector -# country_vector <- c("AU", "UK", "AZ") -# galah_call() |> -# filter(country == country_vector) |> -# str() -# # effectively parses this as 'in' as per GBIF instructions -# }) -# -# # missing `within` (galah_geolocate()) -# -# # missing `geoDistance` (galah_radius()) - -x <- quiet_config(atlas = "ALA") -rm(x, quiet_config) \ No newline at end of file diff --git a/tests/testthat/test-international-GBIF-predicates.R b/tests/testthat/test-international-GBIF-predicates.R new file mode 100644 index 00000000..9838efec --- /dev/null +++ b/tests/testthat/test-international-GBIF-predicates.R @@ -0,0 +1,289 @@ +quiet_config <- purrr::quietly(galah_config) + +x <- quiet_config(atlas = "GBIF", + username = "atlasoflivingaustralia", + email = "ala4r@ala.org.au", + password = "galah-gbif-test-login") + +test_that("`galah_filter()` returns predicates for GBIF", { + x <- galah_filter(year == 2024) + inherits(x, "list") |> + expect_true() + expect_equal(names(x), + c("type", "key", "value")) + expect_equal(x, + list(type = "equals", + key = "YEAR", + value = "2024") |> + structure(class = c("predicates_filter", "list"))) +}) + +test_that("`filter()` handles a single entry for GBIF", { + skip_if_offline(); skip_on_ci() + x <- galah_call() |> + filter(year == 2024) |> + count() |> + collect() + expect_s3_class(x, c("tbl_df", "tbl", "data.frame")) + expect_equal(nrow(x), 1) + expect_equal(ncol(x), 1) + expect_true(is.integer(x$count)) +}) + +# FIXME: `check_fields()` not tested for GBIF - try sending invalid fields to `filter()` + +test_that("`filter()` handles multiple (`AND`) queries for GBIF", { + skip_if_offline(); skip_on_ci() + # get a count limited by two different categories + x <- galah_call() |> + filter(year == 2024, basisOfRecord == "HUMAN_OBSERVATION") |> + count() |> + collect() + expect_s3_class(x, c("tbl_df", "tbl", "data.frame")) + expect_equal(nrow(x), 1) + expect_equal(ncol(x), 1) + + # group by the first category + y <- galah_call() |> + filter(year == 2024, basisOfRecord == "HUMAN_OBSERVATION") |> + group_by(basisOfRecord) |> + count() |> + collect() + expect_s3_class(y, c("tbl_df", "tbl", "data.frame")) + expect_equal(nrow(y), 1) + expect_equal(ncol(y), 2) + # verify that the inputs are shown in the response + expect_equal(colnames(y)[[1]], "basisOfRecord") + expect_equal(y$basisOfRecord, "HUMAN_OBSERVATION") + + z <- galah_call() |> + filter(year == 2024, basisOfRecord == "HUMAN_OBSERVATION") |> + group_by(year) |> + count() |> + collect() + expect_s3_class(z, c("tbl_df", "tbl", "data.frame")) + expect_equal(nrow(z), 1) + expect_equal(ncol(z), 2) + # verify that the inputs are shown in the response + expect_equal(colnames(z)[[1]], "year") + expect_equal(z$year, "2024") + + # test that all queries return the same sum + expect_equal(x$count, y$count) + expect_equal(x$count, z$count) +}) + +test_that("`count()` works with `identify()` for GBIF", { + skip_if_offline(); skip_on_ci() + # collapse + x <- request_data() |> + identify("Mammalia") |> + filter(year >= 2020, basisOfRecord == "HUMAN_OBSERVATION") |> + group_by(class) |> + count() |> + collect() + expect_equal(nrow(x), 1) + expect_equal(ncol(x), 2) + expect_equal(x$class, "Mammalia") +}) + +test_that("`filter()` handles `OR` and `%in%` for GBIF", { + skip_if_offline(); skip_on_ci() + + # first prove that these facets are collected + x <- request_data() |> + filter(basisOfRecord == "HUMAN_OBSERVATION" | basisOfRecord == "PRESERVED_SPECIMEN") |> + group_by(basisOfRecord) |> + count() |> + collect() + expect_s3_class(x, c("tbl_df", "tbl", "data.frame")) + expect_equal(nrow(x), 2) + expect_equal(ncol(x), 2) + expect_contains(x$basisOfRecord, + c("HUMAN_OBSERVATION", "PRESERVED_SPECIMEN")) + + # then check the sum against a query without `group_by()` + y <- request_data() |> + filter(basisOfRecord == "HUMAN_OBSERVATION" | basisOfRecord == "PRESERVED_SPECIMEN") |> + count() |> + collect() + expect_equal(sum(x$count), y$count) + + # check that %in% gives the same result + z <- request_data() |> + filter(basisOfRecord %in% c("HUMAN_OBSERVATION", "PRESERVED_SPECIMEN")) |> + count() |> + collect() + expect_equal(y$count, z$count) +}) + +test_that("`filter()` handles multiple queries including != for GBIF", { + x <- galah_call() |> + filter(year == 2024, country != "AU") |> # FIXME: countryCode fails with cryptic warning + count() |> + collect() + expect_s3_class(x, c("tbl_df", "tbl", "data.frame")) + expect_equal(nrow(x), 1) + expect_equal(ncol(x), 1) + + # check that `!=` definitely reduces the number of records returned + y <- galah_call() |> + filter(year == 2024) |> + count() |> + collect() + expect_lt(x$count, y$count) +}) + +test_that("`filter()` handles `between()` for GBIF", { + x <- galah_call() |> + filter(dplyr::between(year, 2010, 2020)) |> + group_by(year) |> + count() |> + collect() + expect_s3_class(x, c("tbl_df", "tbl", "data.frame")) + expect_equal(ncol(x), 2) + expect_gt(nrow(x), 8) + expect_lt(nrow(x), 12) + integer_years <- as.integer(x$year) + all(integer_years >= 2010 & integer_years <= 2020) |> + expect_true() +}) + +test_that("filter() handles !() for GBIF", { + + # exclude some levels of basisOfRecord + excluded_categories <- c("OCCURRENCE", "LIVING_SPECIMEN", "HUMAN_OBSERVATION") + x <- galah_call() |> + filter(!(basisOfRecord %in% excluded_categories)) |> + group_by(basisOfRecord) |> + count() |> + collect() + expect_s3_class(x, c("tbl_df", "tbl", "data.frame")) + expect_equal(ncol(x), 2) + expect_gt(nrow(x), 3) + + # get all levels of basisOfRecord + y <- galah_call() |> + group_by(basisOfRecord) |> + count() |> + collect() + + # check that those categories - and only those categories - are missing + missing_categories <- y$basisOfRecord[!(y$basisOfRecord %in% x$basisOfRecord)] + expect_equal(sort(excluded_categories), + sort(missing_categories)) +}) + +test_that("filter() handles `is.na()` for GBIF", { + # missing values + x <- galah_call() |> + filter(is.na(country)) |> + count() |> + collect() + expect_s3_class(x, c("tbl_df", "tbl", "data.frame")) + expect_equal(nrow(x), 1) + expect_equal(ncol(x), 1) + + # present values + y <- galah_call() |> + filter(!is.na(country)) |> + count() |> + collect() + expect_s3_class(y, c("tbl_df", "tbl", "data.frame")) + expect_equal(nrow(y), 1) + expect_equal(ncol(y), 1) + + # all values + z <- galah_call() |> + count() |> + collect() + expect_equal(x$count + y$count, z$count) +}) + +test_that("filter() handles c() for GBIF", { + # effectively parses this as 'in' as per GBIF instructions + country_vector <- c("AU", "US", "NL") + + # check when supplied directly + x <- galah_call() |> + filter(country %in% c("AU", "US", "NL")) |> + group_by(country) |> + count() |> + collect() + expect_s3_class(x, c("tbl_df", "tbl", "data.frame")) + expect_equal(nrow(x), 3) + expect_equal(ncol(x), 2) + expect_contains(x$country, country_vector) + + # and as a vector + y <- galah_call() |> + filter(country == country_vector) |> + group_by(country) |> + count() |> + collect() + expect_s3_class(y, c("tbl_df", "tbl", "data.frame")) + expect_equal(nrow(y), 3) + expect_equal(ncol(y), 2) + expect_contains(y$country, country_vector) + + # direct comparison + expect_identical(x, y) +}) + +test_that("`count()` works with `galah_polygon()` for GBIF", { + skip_if_offline(); skip_on_ci() + # errors when points given clockwise + wkt <- "POLYGON((142.36 -29.01,142.74 -29.01,142.74 -29.39,142.36 -29.39,142.36 -29.01))" + expect_error({galah_call() |> + galah_polygon(wkt) |> + count() |> + collect()}) + # works when points given counter-clockwise + wkt <- "POLYGON((142.36 -29.01,142.36 -29.39,142.74 -29.39,142.74 -29.01,142.36 -29.01))" + result <- galah_call() |> + identify("Mammalia") |> + galah_polygon(wkt) |> + count() |> + collect() + # compare against a taxonomic query in the same place + result_taxa <- galah_call() |> + identify("Mammalia") |> + count() |> + collect() + # compare against a purely spatial query + result_space <- galah_call() |> + galah_polygon(wkt) |> + count() |> + collect() + expect_lt(result$count, result_taxa$count) + expect_lt(result$count, result_space$count) +}) + +test_that("`count()` works with `galah_radius()` for GBIF", { + skip_if_offline(); skip_on_ci() + # ditto for a point and radius + result <- galah_call() |> + identify("Mammalia") |> + galah_radius(lat = -33.7, + lon = 151.3, + radius = 5) |> + count() |> + collect() + result_space <- galah_call() |> + galah_radius(lat = -33.7, + lon = 151.3, + radius = 5) |> + count() |> + collect() + result_taxa <- galah_call() |> + identify("Mammalia") |> + count() |> + collect() + expect_lt(result$count, result_taxa$count) + expect_lt(result$count, result_space$count) +}) + +# TODO: add assertions? + +x <- quiet_config(atlas = "ALA") +rm(x, quiet_config) diff --git a/tests/testthat/test-international-GBIF.R b/tests/testthat/test-international-GBIF.R index 5f58dafc..2edab10f 100644 --- a/tests/testthat/test-international-GBIF.R +++ b/tests/testthat/test-international-GBIF.R @@ -179,89 +179,6 @@ test_that("atlas_counts fails for GBIF when type = 'species'", { galah_config(run_checks = TRUE) -test_that("`count()` works with `filter()` for GBIF", { - skip_if_offline(); skip_on_ci() - # collapse - x <- request_data() |> - filter(year == 2010) |> - count() |> - collapse() - expect_s3_class(x, "query") - expect_equal(length(x), 6) - expect_equal(names(x), - c("type", "url", "headers", "options", - "body", "slot_name")) - expect_equal(x$type, "data/occurrences-count") - # compute - y <- compute(x) - expect_s3_class(y, "computed_query") - # collect - z <- collect(y) - expect_s3_class(z, c("tbl_df", "tbl", "data.frame")) - expect_gt(z$count, 1) - expect_equal(nrow(z), 1) -}) - -test_that("`count` works with `identify` for GBIF", { - skip_if_offline(); skip_on_ci() - # collapse - x <- request_data() |> - identify("Mammalia", "Aves") |> - filter(year >= 2020, basisOfRecord == "HUMAN_OBSERVATION") |> - count() |> - collapse() - expect_s3_class(x, "query") - expect_equal(length(x), 6) - expect_equal(names(x), - c("type", "url", "headers", "options", - "body", "slot_name")) - expect_equal(x$type, "data/occurrences-count") - # compute - y <- compute(x) - expect_s3_class(y, "computed_query") - # collect - z <- collect(y) - expect_s3_class(z, c("tbl_df", "tbl", "data.frame")) - expect_gt(z$count, 1) - expect_equal(nrow(z), 1) -}) - -test_that("`count` works with `group_by` for GBIF", { - skip_if_offline(); skip_on_ci() - x <- galah_call() |> - # identify("Litoria") |> - filter(year >= 2020) |> - group_by(year) |> - count() |> - collapse() - expect_s3_class(x, "query") - expect_equal(length(x), 6) - expect_equal(names(x), c("type", - "url", - "headers", - "options", - "body", - "slot_name")) - expect_equal(x$type, "data/occurrences-count-groupby") - # compute - y <- compute(x) - expect_s3_class(y, "computed_query") - # collect - z <- collect(y) - expect_s3_class(z, c("tbl_df", "tbl", "data.frame")) - expect_gt(nrow(z), 1) - expect_equal(names(z), c("year", "count")) - # group_by fails when an invalid field is given - # NOTE: fails: no checks run at present - expect_error({ - galah_call() |> - identify("Crinia") |> - group_by(species) |> - count() |> - collect() - }) -}) - test_that("`count` works with 2 `group_by` args for GBIF", { skip_if_offline(); skip_on_ci() x <- galah_call() |> @@ -271,12 +188,8 @@ test_that("`count` works with 2 `group_by` args for GBIF", { collapse() expect_s3_class(x, "query") expect_equal(length(x), 6) - expect_equal(names(x), c("type", - "url", - "headers", - "options", - "body", - "slot_name")) + expect_contains(names(x), + c("type", "url", "headers", "request")) # "options", "body" ? expect_equal(x$type, "data/occurrences-count-groupby") # compute y <- compute(x) @@ -300,60 +213,7 @@ test_that("`count` works with 2 `group_by` args for GBIF", { # FIXME: `slice_head()` not tested for GBIF -test_that("`count()` works with `galah_polygon()` for GBIF", { - skip_if_offline(); skip_on_ci() - # errors when points given clockwise - wkt <- "POLYGON((142.36 -29.01,142.74 -29.01,142.74 -29.39,142.36 -29.39,142.36 -29.01))" - expect_error({galah_call() |> - galah_polygon(wkt) |> - count() |> - collect()}) - # works when points given counter-clockwise - wkt <- "POLYGON((142.36 -29.01,142.36 -29.39,142.74 -29.39,142.74 -29.01,142.36 -29.01))" - result <- galah_call() |> - identify("Mammalia") |> - galah_polygon(wkt) |> - count() |> - collect() - # compare against a taxonomic query in the same place - result_taxa <- galah_call() |> - identify("Mammalia") |> - count() |> - collect() - # compare against a purely spatial query - result_space <- galah_call() |> - galah_polygon(wkt) |> - count() |> - collect() - expect_lt(result$count, result_taxa$count) - expect_lt(result$count, result_space$count) -}) - -test_that("`count()` works with `galah_radius()` for GBIF", { - skip_if_offline(); skip_on_ci() - # ditto for a point and radius - result <- galah_call() |> - identify("Mammalia") |> - galah_radius(lat = -33.7, - lon = 151.3, - radius = 5) |> - count() |> - collect() - result_space <- galah_call() |> - galah_radius(lat = -33.7, - lon = 151.3, - radius = 5) |> - count() |> - collect() - result_taxa <- galah_call() |> - identify("Mammalia") |> - count() |> - collect() - expect_lt(result$count, result_taxa$count) - expect_lt(result$count, result_space$count) -}) - -test_that("`count` works with `identify` for GBIF when `run_checks` = TRUE", { +test_that("`count()` works with `identify` for GBIF when `run_checks` = TRUE", { skip_if_offline(); skip_on_ci() galah_config(run_checks = TRUE) # collapse @@ -367,7 +227,7 @@ test_that("`count` works with `identify` for GBIF when `run_checks` = TRUE", { expect_equal(length(x), 6) expect_equal(names(x), c("type", "url", "headers", - "options", "body", "slot_name")) + "options", "body", "request")) expect_equal(x$type, "data/occurrences-count") # compute y <- compute(x) @@ -379,6 +239,8 @@ test_that("`count` works with `identify` for GBIF when `run_checks` = TRUE", { expect_equal(nrow(z), 1) }) +## TODO: Add a more basic occurrences check + test_that("`atlas_occurrences()` works with `galah_polygon()` for GBIF", { skip_if_offline(); skip_on_ci() wkt <- "POLYGON((142.36 -29.01,142.36 -29.39,142.74 -29.39,142.74 -29.01,142.36 -29.01))" From 44a067c4e02969429e85da6c5988b0f07743e821 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Thu, 5 Feb 2026 13:47:58 +1100 Subject: [PATCH 75/94] Support `dplyr::glimpse()` for occurrence queries --- NAMESPACE | 4 ++++ R/capture.R | 44 ++++++++++++++++++++++++------------ R/capture_occurrences.R | 28 ++++++++++++++++++++++- R/collapse_checks.R | 14 +++++++++++- R/collect_occurrences.R | 31 +++++++++++++++++++++++++ R/dplyr-collect.R | 1 + R/dplyr-glimpse.R | 30 ++++++++++++++++++++++++ R/galah-package.R | 2 ++ R/reexports.R | 4 ++++ _pkgdown.yml | 2 ++ man/distinct.data_request.Rd | 9 ++++++++ man/galah.Rd | 2 ++ man/glimpse.data_request.Rd | 24 ++++++++++++++++++++ man/reexports.Rd | 3 ++- 14 files changed, 181 insertions(+), 17 deletions(-) create mode 100644 R/dplyr-glimpse.R create mode 100644 man/glimpse.data_request.Rd diff --git a/NAMESPACE b/NAMESPACE index ed00f564..4690b936 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -36,6 +36,7 @@ S3method(distinct,data_request) S3method(filter,data_request) S3method(filter,files_request) S3method(filter,metadata_request) +S3method(glimpse,data_request) S3method(group_by,data_request) S3method(identify,data_request) S3method(identify,metadata_request) @@ -47,6 +48,7 @@ S3method(print,files_request) S3method(print,galah_config) S3method(print,metadata_filter) S3method(print,metadata_request) +S3method(print,occurrences_glimpse) S3method(print,predicates_filter) S3method(print,prequery) S3method(print,query) @@ -91,6 +93,7 @@ export(galah_polygon) export(galah_radius) export(galah_select) export(geolocate) +export(glimpse) export(group_by) export(identify) export(read_zip) @@ -141,6 +144,7 @@ importFrom(dplyr,compute) importFrom(dplyr,count) importFrom(dplyr,distinct) importFrom(dplyr,filter) +importFrom(dplyr,glimpse) importFrom(dplyr,group_by) importFrom(dplyr,select) importFrom(dplyr,slice_head) diff --git a/R/capture.R b/R/capture.R index 9c0e0714..cae20f7f 100644 --- a/R/capture.R +++ b/R/capture.R @@ -53,12 +53,14 @@ capture.data_request <- function(x, check_authentication() |> check_doi() |> check_distinct_count_groupby() |> + check_glimpse() |> check_slice_arrange() |> enforce_select_query() switch(x$type, "occurrences" = capture_occurrences(x, mint_doi = mint_doi), "occurrences-count" = capture_occurrences_count(x), "occurrences-doi" = capture_occurrences_doi(x), + "occurrences-glimpse" = capture_occurrences_glimpse(x), "species" = capture_species(x), "species-count" = capture_species_count(x), "distributions" = capture_distributions_data(x), @@ -140,20 +142,6 @@ as_prequery <- function(x){ structure(x, class = c("prequery", "list")) } -#' Internal function called by `capture()` -#' @noRd -#' @keywords Internal -count_switch <- function(x){ - x$type <- switch(x$type, - "occurrences" = "occurrences-count", - "occurrences-count" = "occurrences-count", - "species" = "species-count", - "species-count" = "species-count", - "media" = cli::cli_abort("type = 'media' is not supported by `count()`"), - cli::cli_abort("`count()` only supports `type = 'occurrences' or` `'species'`")) - x -} - #' Internal function to ensure that DOIs are parsed properly #' @noRd #' @keywords Internal @@ -274,6 +262,34 @@ check_distinct_count_groupby <- function(x){ } # end has_distinct } # end function +#' Internal function called by `capture()` +#' @noRd +#' @keywords Internal +count_switch <- function(x){ + x$type <- switch(x$type, + "occurrences" = "occurrences-count", + "occurrences-count" = "occurrences-count", + "species" = "species-count", + "species-count" = "species-count", + "media" = cli::cli_abort("type = 'media' is not supported by `count()`"), + cli::cli_abort("`count()` only supports `type = 'occurrences' or` `'species'`")) + x +} + +#' Internal function to capture `glimpse()` calls +#' @noRd +#' @keywords Internal +check_glimpse <- function(x){ + if(!is.null(x$glimpse)){ + if(x$type == "occurrences"){ + x$type <- "occurrences-glimpse" + }else{ + cli::cli_inform("`glimpse()` is only supported for `type =\"occurrences\"") + } + } + x +} + #' Internal function to check `slice` and `arrange` for counts #' @keywords Internal #' @noRd diff --git a/R/capture_occurrences.R b/R/capture_occurrences.R index 1d7f7d85..fbc410f6 100644 --- a/R/capture_occurrences.R +++ b/R/capture_occurrences.R @@ -67,7 +67,7 @@ capture_occurrences_la <- function(.query, add_doi_request(mint_doi = mint_doi) # build url - url <- url_lookup("data/occurrences") |> + url <- url_lookup("data/occurrences") |> httr2::url_parse() url$query <- query @@ -123,4 +123,30 @@ capture_occurrences_doi <- function(.query, headers = build_headers(), download = TRUE) |> as_query() +} + +#' Internal function to convert `data_request` with `type = "occurrences-glimpse"` to a `query` +#' @noRd +#' @keywords Internal +capture_occurrences_glimpse <- function(.query){ + if(is_gbif()){ + # browser() # not coded yet + .query + }else{ + result <- capture_occurrences_la(.query) + url <- httr2::url_parse(result$url) + + # replace path with count API + url$path <- url_lookup("data/occurrences-count") |> + httr2::url_parse() |> + purrr::pluck("path") + + # add a pageSize arg + url$query$pageSize <- 3 + + # rebuild and ship + result$url <- httr2::url_build(url) + result$type <- "data/occurrences-glimpse" + result + } } \ No newline at end of file diff --git a/R/collapse_checks.R b/R/collapse_checks.R index 57d46968..cfab3973 100644 --- a/R/collapse_checks.R +++ b/R/collapse_checks.R @@ -53,9 +53,21 @@ collapse_run_checks <- function(.query, } # check_select() is specifically for parsing fields into urls, # should only be called for occurrences - if(.query$type == "data/occurrences"){ + if(.query$type %in% c("data/occurrences", "data/occurrences-glimpse")){ .query <- check_select(.query, error_call) } + + # after checking, for type = "glimpse", we need to rename the fields query + if(.query$type == "data/occurrences-glimpse"){ + url <- httr2::url_parse(.query$url) + query_names <- names(url$query) + if(any(query_names == "fields")){ + names(url$query)[which(query_names == "fields")] <- "fl" + } + .query$url <- httr2::url_build(url) + } + + # run remaining checks, if requested by the user if(potions::pour("package", "run_checks")) { .query <- .query |> check_reason(error_call) |> diff --git a/R/collect_occurrences.R b/R/collect_occurrences.R index e2a37aab..92150d5e 100644 --- a/R/collect_occurrences.R +++ b/R/collect_occurrences.R @@ -104,6 +104,37 @@ collect_occurrences_doi <- function(.query, } } +#' collect type `data/occurrences-glimpse` +#' @noRd +#' @keywords Internal +collect_occurrences_glimpse <- function(.query){ + result <- query_API(.query) + + # pull required info from API + df_list <- result |> + purrr::pluck("occurrences") |> + # non-standard fields are nested within `otherProperties` + # extract these + purrr::map(\(a){ + if(any(names(a) == "otherProperties")){ + c(a[names(a) != "otherProperties"], + a[["otherProperties"]]) + } + }) + + # create a tibble + df <- dplyr::bind_rows(df_list) + attr(df, "total_n") <- result$totalRecords + + # assign new object for bespoke printing + if(tibble::is_tibble(df)){ + structure(df, + class = c("occurrences_glimpse", "tbl_df", "tbl", "data.frame")) + }else{ + df # not sure what use case this is, but probably NULL + } +} + #' Download failed message #' @noRd #' @keywords Internal diff --git a/R/dplyr-collect.R b/R/dplyr-collect.R index 1eaae256..6e458730 100644 --- a/R/dplyr-collect.R +++ b/R/dplyr-collect.R @@ -99,6 +99,7 @@ collect.computed_query <- function(x, "data/occurrences-count" = collect_occurrences_count(x), "data/occurrences-count-groupby" = collect_occurrences_count(x), "data/occurrences-doi" = collect_occurrences_doi(x, file = file), + "data/occurrences-glimpse" = collect_occurrences_glimpse(x), "data/species"= collect_species(x, file = file), "data/species-count" = collect_species_count(x), # "data/taxonomy" = collect_taxonomy(x), diff --git a/R/dplyr-glimpse.R b/R/dplyr-glimpse.R new file mode 100644 index 00000000..0a1c8ded --- /dev/null +++ b/R/dplyr-glimpse.R @@ -0,0 +1,30 @@ +#' Get a glimpse of your data +#' +#' [glimpse()] is like a transposed version of [print()]: columns run down the page, +#' and data runs across. This makes it possible to see every column in a data +#' frame. It's a little like [str()] applied to a data frame but it tries to +#' show you as much data as possible. This implementation is specific +#' to `galah` and is evaluated lazily. `r lifecycle::badge("experimental")` +#' +#' @details +#' This implementation of [glimpse()] actually involves changing the API call +#' sent to the server, then returning a novel object class with it's own +#' [print()] method. +#' @name glimpse.data_request +#' @export +glimpse.data_request <- function(x, ...){ + update_request_object(x, glimpse = TRUE) +} + +#' @rdname glimpse.data_request +#' @export +print.occurrences_glimpse <- function(x, ...){ + y <- capture.output(dplyr::glimpse(x)) + n_text <- attr(x, 'total_n') |> + formatC(big.mark = ",") + y[[1]] <- glue::glue("Rows: {n_text}") + cli::cli({ + purrr::map(y, cli::cli_text) |> + invisible() + }) +} \ No newline at end of file diff --git a/R/galah-package.R b/R/galah-package.R index e37793e0..81d20471 100644 --- a/R/galah-package.R +++ b/R/galah-package.R @@ -30,10 +30,12 @@ #' #' * [apply_profile()] Restrict to data that pass predefined checks #' * \code{\link[=arrange.data_request]{arrange()}} Arrange rows of a query on the server side +#' * [authenticate()] Authenticate your request via OAUTH in the browser #' * \code{\link[=count.data_request]{count()}} Request counts of the specified data type #' * \code{\link[=distinct.data_request]{distinct()}} Keep distinct/unique rows #' * \code{\link[=filter.data_request]{filter()}} Filter records (see also \code{\link[=filter_object_classes]{filter_object_classes}})) #' * [geolocate()] Spatial filtering of a query +#' * \code{\link[=glimpse]{glimpse()}} Get a glimpse of your data #' * \code{\link[=group_by.data_request]{group_by()}} Group counts by one or more fields #' * \code{\link[=identify.data_request]{identify()}} Search for taxonomic identifiers (see also \code{\link[=taxonomic_searches]{taxonomic_searches}}) #' * \code{\link[=select.data_request]{select()}} Fields to report information for diff --git a/R/reexports.R b/R/reexports.R index 1d7ca44b..9ee74e9c 100644 --- a/R/reexports.R +++ b/R/reexports.R @@ -30,6 +30,10 @@ dplyr::distinct #' @export dplyr::filter +#' @importFrom dplyr glimpse +#' @export +dplyr::glimpse + #' @importFrom dplyr group_by #' @export dplyr::group_by diff --git a/_pkgdown.yml b/_pkgdown.yml index 208b8346..e0d95db5 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -52,10 +52,12 @@ reference: contents: - apply_profile - arrange.data_request + - authenticate - count.data_request - distinct.data_request - filter.data_request - geolocate + - glimpse.data_request - group_by.data_request - identify.data_request - select.data_request diff --git a/man/distinct.data_request.Rd b/man/distinct.data_request.Rd index 749aff19..5d51215c 100644 --- a/man/distinct.data_request.Rd +++ b/man/distinct.data_request.Rd @@ -104,6 +104,15 @@ we might choose to show data by family instead of species: distinct(familyID, .keep_all = TRUE) |> collect()} +Using \code{\link[=group_by]{group_by()}} is also valid: + +\preformatted{galah_call() |> + filter(year == 2024, + genus == "Crinia") |> + group_by(speciesID) |> + distinct(.keep_all = TRUE) |> + collapse()} + In this case, \code{\link[=collect.data_request]{collect()}} and \code{\link[=atlas_species]{atlas_species()}} are synonymous, with the exception that the latter does not require you to set the \code{.keep_all} argument to \code{TRUE}. So you diff --git a/man/galah.Rd b/man/galah.Rd index 49639881..374860fa 100644 --- a/man/galah.Rd +++ b/man/galah.Rd @@ -36,10 +36,12 @@ For those outside Australia, 'galah' is the common name of \itemize{ \item \code{\link[=apply_profile]{apply_profile()}} Restrict to data that pass predefined checks \item \code{\link[=arrange.data_request]{arrange()}} Arrange rows of a query on the server side +\item \code{\link[=authenticate]{authenticate()}} Authenticate your request via OAUTH in the browser \item \code{\link[=count.data_request]{count()}} Request counts of the specified data type \item \code{\link[=distinct.data_request]{distinct()}} Keep distinct/unique rows \item \code{\link[=filter.data_request]{filter()}} Filter records (see also \code{\link[=filter_object_classes]{filter_object_classes}})) \item \code{\link[=geolocate]{geolocate()}} Spatial filtering of a query +\item \code{\link[=glimpse]{glimpse()}} Get a glimpse of your data \item \code{\link[=group_by.data_request]{group_by()}} Group counts by one or more fields \item \code{\link[=identify.data_request]{identify()}} Search for taxonomic identifiers (see also \code{\link[=taxonomic_searches]{taxonomic_searches}}) \item \code{\link[=select.data_request]{select()}} Fields to report information for diff --git a/man/glimpse.data_request.Rd b/man/glimpse.data_request.Rd new file mode 100644 index 00000000..608e8f93 --- /dev/null +++ b/man/glimpse.data_request.Rd @@ -0,0 +1,24 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/dplyr-glimpse.R +\name{glimpse.data_request} +\alias{glimpse.data_request} +\alias{print.occurrences_glimpse} +\title{Get a glimpse of your data} +\usage{ +\method{glimpse}{data_request}(x, ...) + +\method{print}{occurrences_glimpse}(x, ...) +} +\description{ +\code{\link[=glimpse]{glimpse()}} is like a transposed version of \code{\link[=print]{print()}}: columns run down the page, +and data runs across. This makes it possible to see every column in a data +frame. It's a little like \code{\link[=str]{str()}} applied to a data frame but it tries to +show you as much data as possible. (And it always shows the underlying data, +even when applied to a remote data source.) This implementation is specific +to \code{galah} and is evaluated lazily. \ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}} +} +\details{ +This implementation of \code{\link[=glimpse]{glimpse()}} actually involves changing the API call +sent to the server, then returning a novel object class with it's own +\code{\link[=print]{print()}} method. +} diff --git a/man/reexports.Rd b/man/reexports.Rd index dc8dc52c..bd83237d 100644 --- a/man/reexports.Rd +++ b/man/reexports.Rd @@ -11,6 +11,7 @@ \alias{count} \alias{distinct} \alias{filter} +\alias{glimpse} \alias{group_by} \alias{select} \alias{slice_head} @@ -23,7 +24,7 @@ These objects are imported from other packages. Follow the links below to see their documentation. \describe{ - \item{dplyr}{\code{\link[dplyr:count]{add_count}}, \code{\link[dplyr]{arrange}}, \code{\link[dplyr:compute]{collapse}}, \code{\link[dplyr:compute]{collect}}, \code{\link[dplyr]{compute}}, \code{\link[dplyr]{count}}, \code{\link[dplyr]{distinct}}, \code{\link[dplyr]{filter}}, \code{\link[dplyr]{group_by}}, \code{\link[dplyr]{select}}, \code{\link[dplyr:slice]{slice_head}}} + \item{dplyr}{\code{\link[dplyr:count]{add_count}}, \code{\link[dplyr]{arrange}}, \code{\link[dplyr:compute]{collapse}}, \code{\link[dplyr:compute]{collect}}, \code{\link[dplyr]{compute}}, \code{\link[dplyr]{count}}, \code{\link[dplyr]{distinct}}, \code{\link[dplyr]{filter}}, \code{\link[dplyr]{glimpse}}, \code{\link[dplyr]{group_by}}, \code{\link[dplyr]{select}}, \code{\link[dplyr:slice]{slice_head}}} \item{graphics}{\code{\link[graphics]{identify}}} From a604a1bae25ced0394b84c3d8f9a6efab4dc186e Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Fri, 6 Feb 2026 11:38:50 +1100 Subject: [PATCH 76/94] Fix bug where GBIF facet queries failing without a reason being given The variables available for facetting (or search in general) 1. differ from those in the occurrence store, and 2. are not available via API. Have added a means to scrape the source Java script for what fields are accepted by the facet arg --- R/check.R | 48 ++++--- R/coalesce.R | 4 +- R/collect_metadata.R | 14 +- R/sysdata.rda | Bin 6456 -> 7386 bytes R/utilities_internal.R | 25 ++-- data-raw/1_gbif_web_scraping.R | 21 ++- data-raw/2_internal_data.R | 1 + data-raw/gbif_search_fields.csv | 127 ++++++++++++++++++ tests/testthat/test-dplyr-glimpse.R | 10 ++ .../test-international-GBIF-predicates.R | 27 +++- 10 files changed, 240 insertions(+), 37 deletions(-) create mode 100644 data-raw/gbif_search_fields.csv create mode 100644 tests/testthat/test-dplyr-glimpse.R diff --git a/R/check.R b/R/check.R index 1f0cc5f7..97487eda 100644 --- a/R/check.R +++ b/R/check.R @@ -273,34 +273,42 @@ check_field_identities <- function(df, #' @noRd #' @keywords Internal check_fields_gbif_counts <- function(.query){ - # set fields to check against - valid_fields <- .query[["metadata/fields"]]$id + + # First get filters + # set fields that can be queried using predicates or downloaded + valid_download_fields <- .query[["metadata/fields"]] |> + filter(download_field == TRUE) |> + dplyr::pull(id) valid_assertions <- .query[["metadata/assertions"]]$id - valid_any <- c(valid_fields, valid_assertions) - url <- httr2::url_parse(.query$url[1]) - - # get fields from url - skip_fields <- c("limit", "facet", "facetLimit", - "taxonKey", "geometry", "geoDistance") # GBIF-specific fields - query_names <- names(url$query) - fields <- query_names[!(query_names %in% skip_fields)] - # check invalid fields + valid_any <- c(valid_download_fields, valid_assertions) + + # check for invalid fields in predicates + filter_vec <- unlist(.query$body$filter) + filter_keys <- stringr::str_detect(names(filter_vec), "key$") filter_invalid <- NA - if (length(fields) > 0) { + if(any(filter_keys)){ + fields <- filter_vec[filter_keys] |> + snake_to_camel_case() if (!all(fields %in% valid_any)) { invalid_fields <- fields[!(fields %in% valid_any)] filter_invalid <- glue::glue_collapse(invalid_fields, sep = ", ") } } - - # check for invalid facets + + # then facets + # first extract facets group_by_invalid <- NA - if(any(query_names == "facet")){ - fields <- unlist(url$query[which(query_names == "facet")]) - if (!all(fields %in% valid_any)) { - invalid_fields <- fields[!(fields %in% valid_any)] - group_by_invalid <- glue::glue_collapse(invalid_fields, sep = ", ") - } + if(!is.null(.query$body$group_by)){ + facets <- .query$body$group_by$name + # check for invalid facets + valid_search_fields <- .query[["metadata/fields"]] |> + filter(search_field == TRUE) |> + dplyr::pull(id) + if (!all(facets %in% valid_search_fields)) { + invalid_facets <- facets[!(facets %in% valid_search_fields)] + group_by_invalid <- glue::glue_collapse(invalid_facets, sep = ", ") + } + # } } c(filter_invalid, group_by_invalid) diff --git a/R/coalesce.R b/R/coalesce.R index 5cae6773..47c7ee42 100644 --- a/R/coalesce.R +++ b/R/coalesce.R @@ -183,7 +183,9 @@ build_query_set_data <- function(x, mint_doi, ...){ if(any(!fields_absent) | x_type %in% c("species-count", "species")) { result <- c(result, - list(request_metadata("fields") |> capture(), + list(request_metadata("fields") |> + select(tidyselect::everything()) |> # needed to ensure GBIF works + capture(), request_metadata("assertions") |> capture())) }else{ # for living atlases, we need `collapse_fields()` to check the `lsid` field diff --git a/R/collect_metadata.R b/R/collect_metadata.R index 62d861c5..bba5c9e1 100644 --- a/R/collect_metadata.R +++ b/R/collect_metadata.R @@ -263,10 +263,22 @@ collect_fields <- function(.query){ dplyr::bind_rows() if(is_gbif()){ + # we need to join some local metadata to GBIF info + df <- galah:::gbif_internal_archived$search_fields |> + dplyr::mutate(search_field = TRUE) + + # organise information from the API result_df <- result |> dplyr::mutate(id = .data$simpleName, description = .data$qualifiedName, - type = "fields") + type = "fields", + download_field = TRUE) |> + dplyr::full_join(df, by = "id") + + # clean up NAs (without `tidyr`) + result_df$download_field[is.na(result_df$download_field)] <- FALSE + result_df$search_field[is.na(result_df$search_field)] <- FALSE + }else{ # if there is a 'stored' field, use it to filter results if(any(colnames(result) == "stored")){ diff --git a/R/sysdata.rda b/R/sysdata.rda index 7288a268806e46279ff855df11bb914f22bf1a72..1a7ab2f95fb8630fea264457071c9fadd17d8642 100644 GIT binary patch literal 7386 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 Mk}1N3g3W9l0JvK@W&i*H literal 6456 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 S*uL@q;_gVN3KA6sVKLx0NunA6 diff --git a/R/utilities_internal.R b/R/utilities_internal.R index 80ccec4f..f677162f 100644 --- a/R/utilities_internal.R +++ b/R/utilities_internal.R @@ -219,24 +219,25 @@ gbif_upper_case <- function(string){ #' Internal function to handle conversion from upper snake case to camelCase #' Primarily for reversing the action of `gbif_upper_case()` (above) +#' vectorized (kinda) 2026-02-05 #' @noRd #' @keywords internal snake_to_camel_case <- function(string){ # first split into words - split_string <- string |> + string_list <- string |> tolower() |> - strsplit("_") |> - purrr::pluck(!!!list(1)) - - # then amend only multi-word strings - word_count <- length(split_string) - if(word_count > 1){ - c(split_string[1], - stringr::str_to_title(split_string[seq(2, word_count)])) |> - glue::glue_collapse() - }else{ - split_string + strsplit("_") + # then merge multi-word strings + n_words <- lengths(string_list) + if(any(n_words > 1)){ + x <- purrr::map(string_list[n_words > 1], + \(a){ + c(a[[1]], stringr::str_to_title(a[seq(2, length(a))])) |> + paste0(collapse = "") + }) + string_list[n_words > 1] <- x } + unlist(string_list) } ##--------------------------------------------------------------- diff --git a/data-raw/1_gbif_web_scraping.R b/data-raw/1_gbif_web_scraping.R index 75a3fc41..1d1d1925 100644 --- a/data-raw/1_gbif_web_scraping.R +++ b/data-raw/1_gbif_web_scraping.R @@ -7,6 +7,7 @@ library(tibble) # generate tibbles library(dplyr) # data manipulation library(purrr) # extraction from lists library(rvest) # web scraping assertions from gbif.org +library(stringr) # Legacy code: this is now available via API # # tibble of available fields: @@ -53,4 +54,22 @@ lapply(assertions_list, function(a){ }) |> bind_rows() |> mutate(type = "assertions") |> - write_csv("./data-raw/gbif_assertions.csv") \ No newline at end of file + write_csv("./data-raw/gbif_assertions.csv") + +## Also, fields that exist are *not* the same as those that +## can be used for search or faceting. +## Download and store these separately + +gbif_search_url <- "https://raw.githubusercontent.com/gbif/gbif-api/refs/heads/dev/src/main/java/org/gbif/api/model/occurrence/search/OccurrenceSearchParameter.java" +data_raw <- readLines(gbif_search_url) +field_strings <- data_raw[str_detect(data_raw, "^\\s+public final static")] |> + str_replace("^\\s+public final static OccurrenceSearchParameter ", "") |> + str_extract("^\\s*[:graph:]+") |> + trimws() |> + sort() +# can be compared to: +# https://gbif.github.io/gbif-api/apidocs/org/gbif/api/model/occurrence/search/OccurrenceSearchParameter.html + +tibble::tibble(id = snake_to_camel_case(field_strings)) |> + write_csv("./data-raw/gbif_search_fields.csv") + diff --git a/data-raw/2_internal_data.R b/data-raw/2_internal_data.R index 2d68aae0..432e6a55 100644 --- a/data-raw/2_internal_data.R +++ b/data-raw/2_internal_data.R @@ -116,6 +116,7 @@ names(galah_internal_cached) <- stored_types gbif_internal_archived <- list( assertions = read_csv("./data-raw/gbif_assertions.csv"), # fields = read_csv("./data-raw/gbif_fields.csv"), + search_fields = read_csv("./data-raw/gbif_search_fields.csv"), ranks = tibble( id = seq_len(9), name = c("kingdom", "phylum", "class", diff --git a/data-raw/gbif_search_fields.csv b/data-raw/gbif_search_fields.csv new file mode 100644 index 00000000..a844ba7e --- /dev/null +++ b/data-raw/gbif_search_fields.csv @@ -0,0 +1,127 @@ +id +acceptedTaxonKey +associatedSequences +basisOfRecord +bed +biostratigraphy +catalogNumber +checklistKey +classKey +collectionCode +collectionKey +continent +coordinateUncertaintyInMeters +country +crawlId +datasetId +datasetKey +datasetName +day +decimalLatitude +decimalLongitude +degreeOfEstablishment +depth +distanceFromCentroidInMeters +dnaSequenceId +dwcaExtension +earliestAgeOrLowestStage +earliestEonOrLowestEonothem +earliestEpochOrLowestSeries +earliestEraOrLowestErathem +earliestPeriodOrLowestSystem +elevation +endDayOfYear +establishmentMeans +eventDate +eventDateGte +eventId +familyKey +fieldNumber +formation +gadmGid +gadmLevel0Gid +gadmLevel1Gid +gadmLevel2Gid +gadmLevel3Gid +gbifId +gbifRegion +genusKey +geoDistance +geologicalTime +geometry +georeferencedBy +group +hasCoordinate +hasGeospatialIssue +higherGeography +highestBiostratigraphicZone +hostingOrganizationKey +identifiedBy +identifiedById +installationKey +institutionCode +institutionKey +isInCluster +isSequenced +island +islandGroup +issue +iucnRedListCategory +kingdomKey +lastInterpreted +latestAgeOrHighestStage +latestEonOrHighestEonothem +latestEpochOrHighestSeries +latestEraOrHighestErathem +latestPeriodOrHighestSystem +license +lifeStage +lithostratigraphy +locality +lowestBiostratigraphicZone +mediaType +member +modified +month +networkKey +occurrenceId +occurrenceStatus +orderKey +organismId +organismQuantity +organismQuantityType +otherCatalogNumbers +parentEventId +pathway +phylumKey +preparations +previousIdentifications +programme +projectId +protocol +publishedByGbifRegion +publishingCountry +publishingOrg +recordNumber +recordedBy +recordedById +relativeOrganismQuantity +repatriated +sampleSizeUnit +sampleSizeValue +samplingProtocol +scientificName +sex +speciesKey +startDayOfYear +stateProvince +subgenusKey +taxonConceptId +taxonId +taxonKey +taxonomicIssue +taxonomicStatus +typeStatus +verbatimScientificName +waterBody +year diff --git a/tests/testthat/test-dplyr-glimpse.R b/tests/testthat/test-dplyr-glimpse.R new file mode 100644 index 00000000..69967486 --- /dev/null +++ b/tests/testthat/test-dplyr-glimpse.R @@ -0,0 +1,10 @@ +test_that("`glimpse()` returns the correct object class", { + skip_if_offline(); skip_on_ci() + x <- galah_call() |> + filter(year == 2025) |> + glimpse() |> + collect() + expect_s3_class(x, c("occurrences_glimpse", "tbl_df", "tbl", "data.frame")) + expect_equal(nrow(x), 3) + # expect_equal(ncol(x), length(default_columns())) # FIXME: issue with ID/recordID +}) \ No newline at end of file diff --git a/tests/testthat/test-international-GBIF-predicates.R b/tests/testthat/test-international-GBIF-predicates.R index 9838efec..f6610048 100644 --- a/tests/testthat/test-international-GBIF-predicates.R +++ b/tests/testthat/test-international-GBIF-predicates.R @@ -73,19 +73,42 @@ test_that("`filter()` handles multiple (`AND`) queries for GBIF", { expect_equal(x$count, z$count) }) +test_that("`count()` errors when real but non-indexed fields are requested", { + skip_if_offline(); skip_on_ci() + + # invalid fields + galah_call() |> + filter(something == 9) |> + count() |> + collapse() |> + expect_error(label = "Can't use fields that don't exist") + + # real, but not indexed, group_by statement + request_data() |> + filter(class == "Mammalia") |> + group_by(order) |> + count() |> + collapse() |> + expect_error(label = "Can't use fields that don't exist") +}) + test_that("`count()` works with `identify()` for GBIF", { skip_if_offline(); skip_on_ci() # collapse x <- request_data() |> identify("Mammalia") |> filter(year >= 2020, basisOfRecord == "HUMAN_OBSERVATION") |> - group_by(class) |> + group_by(classKey) |> count() |> collect() expect_equal(nrow(x), 1) expect_equal(ncol(x), 2) - expect_equal(x$class, "Mammalia") + expect_equal(x$classKey, "359") }) +# FIXME: fields returned by show_all(fields) are not the same as those accepted by occurrences/search API +# This leads to real field names being passed to this API, but not affecting the result +# accepted fields are here: +# https://techdocs.gbif.org/en/openapi/v1/occurrence#/Searching%20occurrences/searchOccurrence test_that("`filter()` handles `OR` and `%in%` for GBIF", { skip_if_offline(); skip_on_ci() From e4f9f664a6142e700779c9c1c4cb09bb8d7b55a1 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Fri, 6 Feb 2026 11:53:37 +1100 Subject: [PATCH 77/94] rename `coalesce()` as `compound()` to avoid confusion and conflicts with `dplyr` --- NAMESPACE | 14 ++++----- NEWS.md | 2 +- R/capture.R | 6 ++-- R/{coalesce.R => compound.R} | 44 ++++++++++++++-------------- R/dplyr-collapse.R | 8 ++--- R/dplyr-collect.R | 2 +- R/dplyr-compute.R | 2 +- R/galah-package.R | 2 +- R/galah_call.R | 2 +- _pkgdown.yml | 2 +- man/capture.data_request.Rd | 6 ++-- man/collapse.data_request.Rd | 4 +-- man/collect.data_request.Rd | 2 +- man/{coalesce.Rd => compound.Rd} | 40 ++++++++++++------------- man/compute.data_request.Rd | 2 +- man/galah.Rd | 2 +- man/galah_call.Rd | 2 +- man/glimpse.data_request.Rd | 3 +- tests/testthat/_snaps/print.md | 2 +- tests/testthat/test-authentication.R | 8 ++--- tests/testthat/test-print.R | 2 +- 21 files changed, 78 insertions(+), 79 deletions(-) rename R/{coalesce.R => compound.R} (90%) rename man/{coalesce.Rd => compound.Rd} (67%) diff --git a/NAMESPACE b/NAMESPACE index 4690b936..fb3ba101 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -6,12 +6,6 @@ S3method(arrange,metadata_request) S3method(capture,data_request) S3method(capture,files_request) S3method(capture,metadata_request) -S3method(coalesce,data_request) -S3method(coalesce,files_request) -S3method(coalesce,metadata_request) -S3method(coalesce,prequery) -S3method(coalesce,query) -S3method(coalesce,query_set) S3method(collapse,data_request) S3method(collapse,files_request) S3method(collapse,metadata_request) @@ -25,6 +19,12 @@ S3method(collect,metadata_request) S3method(collect,prequery) S3method(collect,query) S3method(collect,query_set) +S3method(compound,data_request) +S3method(compound,files_request) +S3method(compound,metadata_request) +S3method(compound,prequery) +S3method(compound,query) +S3method(compound,query_set) S3method(compute,data_request) S3method(compute,files_request) S3method(compute,metadata_request) @@ -73,10 +73,10 @@ export(atlas_species) export(atlas_taxonomy) export(authenticate) export(capture) -export(coalesce) export(collapse) export(collect) export(collect_media) +export(compound) export(compute) export(count) export(distinct) diff --git a/NEWS.md b/NEWS.md index 69617c74..7e7ee3a1 100644 --- a/NEWS.md +++ b/NEWS.md @@ -8,7 +8,7 @@ ### New & amended functions * `dplyr::distinct()` can be used to find grouped data and summaries, generalising `atlas_species()` -* new functions `as_query()` and `coalesce()` as prequels to `collapse()` +* new functions `as_query()` and `compound()` as prequels to `collapse()` * `galah_call()` is now synonmous with `request_data()` rather than wrapping all `request_` functions; `method` argument is removed. ## Changes to metadata functions diff --git a/R/capture.R b/R/capture.R index cae20f7f..a8ebdeac 100644 --- a/R/capture.R +++ b/R/capture.R @@ -11,12 +11,12 @@ #' properly evaluating a query often requires building and running #' additional queries to populate or validate the requested information. #' A `prequery` object shows what has been requested, before those -#' calls are built by [coalesce()] and evaluated by +#' calls are built by [compound()] and evaluated by #' \code{\link[=collapse.data_request]{collapse()}}. #' For simple cases, this gives the same result as running #' \code{\link[=collapse.data_request]{collapse()}} while the `run_checks` #' argument of [galah_config()] is set to `FALSE`, but is slightly faster. -#' In complex cases, it is simply a precursor to [coalesce()] +#' In complex cases, it is simply a precursor to [compound()] #' @name capture.data_request #' @param x A `_request` object to convert to a `prequery`. #' @param ... Other arguments, currently ignored @@ -32,7 +32,7 @@ #' - `request`: captures the preceeding `_request` object (see [galah_call()]) #' #' @seealso To open a piped query, see [galah_call()]. For alternative -#' operations on `_request` objects, see [coalesce()], +#' operations on `_request` objects, see [compound()], #' \code{\link[=collapse.data_request]{collapse()}}, #' \code{\link[=compute.data_request]{compute()}} or #' \code{\link[=collect.data_request]{collect()}}. diff --git a/R/coalesce.R b/R/compound.R similarity index 90% rename from R/coalesce.R rename to R/compound.R index 47c7ee42..dd527b1a 100644 --- a/R/coalesce.R +++ b/R/compound.R @@ -1,18 +1,18 @@ #' Force evaluation of a database query #' -#' [coalesce()] is an S3 generic function intended to be called before +#' [compound()] is an S3 generic function intended to be called before #' [collapse()]. It is important as it shows the full set of queries #' required to properly evaluate the user's request. This is often broader #' than the single query returned by [collapse()]. If, for example, #' the user's query includes a call to #' \code{\link[=identify.data_request]{identify()}}, then a taxonomic query #' is required to run _before_ the 'final' query is attempted. In relation to -#' other functions that manipulate `_request` objects, [coalesce()] is called +#' other functions that manipulate `_request` objects, [compound()] is called #' within \code{\link[=collapse.data_request]{collapse()}}, and itself #' calls [capture()] internally where required. -#' @rdname coalesce -#' @param x An object to be coalesced. Works for `data_request`, -#' `metadata_request` and `file_request`. +#' @rdname compound +#' @param x An object to be compounded. Works for `data_request`, +#' `metadata_request`, `file_request`, `query` or `prequery`. #' @param ... Other arguments passed to [capture()]. #' @order 1 #' @return An object of class `query_set`, which is simply a list of all `query` @@ -25,44 +25,44 @@ #' \code{\link[=compute.data_request]{compute()}} or #' \code{\link[=collect.data_request]{collect()}}. #' @export -coalesce <- function(x, ...){ - UseMethod("coalesce") +compound <- function(x, ...){ + UseMethod("compound") } -#' @rdname coalesce +#' @rdname compound #' @order 2 #' @export -coalesce.data_request <- function(x, mint_doi = FALSE, ...){ +compound.data_request <- function(x, mint_doi = FALSE, ...){ x |> capture(mint_doi = mint_doi, ...) |> - coalesce() + compound() } -#' @rdname coalesce +#' @rdname compound #' @order 3 #' @export -coalesce.metadata_request <- function(x, ...){ +compound.metadata_request <- function(x, ...){ x |> capture(...) |> - coalesce() + compound() } -#' @rdname coalesce +#' @rdname compound #' @order 4 #' @export -coalesce.files_request <- function(x, +compound.files_request <- function(x, ...){ x |> capture(...) |> - coalesce() + compound() } -#' @rdname coalesce +#' @rdname compound #' @param mint_doi Logical: should a DOI be minted for this download? Only #' applies to `type = "occurrences"`, and only for supported atlases. #' @order 5 #' @export -coalesce.prequery <- function(x, mint_doi = FALSE, ...){ +compound.prequery <- function(x, mint_doi = FALSE, ...){ if(stringr::str_detect(x$type, "^metadata")){ build_query_set_metadata(x) }else if(stringr::str_detect(x$type, "^files")){ @@ -75,18 +75,18 @@ coalesce.prequery <- function(x, mint_doi = FALSE, ...){ } } -#' @rdname coalesce +#' @rdname compound #' @order 6 #' @export -coalesce.query <- function(x, ...){ +compound.query <- function(x, ...){ list(x) |> as_query_set() } -#' @rdname coalesce +#' @rdname compound #' @order 7 #' @export -coalesce.query_set <- function(x, ...){ +compound.query_set <- function(x, ...){ x } diff --git a/R/dplyr-collapse.R b/R/dplyr-collapse.R index 72292e5b..c0afe184 100644 --- a/R/dplyr-collapse.R +++ b/R/dplyr-collapse.R @@ -11,7 +11,7 @@ #' @param x An object to run `collapse()` on. Classes supported by `galah` #' include `data_request`, `metadata_request` and `files_request` for building #' queries; and `prequery`, `query` or `query_set` once constructed (via -#' [capture()] or [coalesce()]). +#' [capture()] or [compound()]). #' @param ... Arguments passed on to [capture()]. #' @return An object of class `query`, which is a list-like object containing #' two or more of the following slots: @@ -26,13 +26,13 @@ #' - Any other information retained from the preceeding `_request` object (see [capture()]) #' #' @seealso To open a piped query, see [galah_call()]. For alternative -#' operations on `_request` objects, see [capture()], [coalesce()], +#' operations on `_request` objects, see [capture()], [compound()], #' \code{\link[=compute.data_request]{compute()}} or #' \code{\link[=collect.data_request]{collect()}}. #' @export collapse.data_request <- function(x, ...){ x |> - coalesce(...) |> + compound(...) |> collapse() } @@ -58,7 +58,7 @@ collapse.query <- function(x, ...){ x } -# if calling `collapse()` after `coalesce()` +# if calling `collapse()` after `compound()` #' @rdname collapse.data_request #' @order 6 #' @export diff --git a/R/dplyr-collect.R b/R/dplyr-collect.R index 6e458730..4aaea1ac 100644 --- a/R/dplyr-collect.R +++ b/R/dplyr-collect.R @@ -20,7 +20,7 @@ #' `wait` is set to `FALSE`), this function returns an object of class `query` #' that can be used to recheck the download at a later time. #' @seealso To open a piped query, see [galah_call()]. For alternative -#' operations on `_request` objects, see [capture()], [coalesce()], +#' operations on `_request` objects, see [capture()], [compound()], #' \code{\link[=collapse.data_request]{collapse()}} or #' \code{\link[=compute.data_request]{compute()}}. #' @export diff --git a/R/dplyr-compute.R b/R/dplyr-compute.R index 40c31385..a672833d 100644 --- a/R/dplyr-compute.R +++ b/R/dplyr-compute.R @@ -18,7 +18,7 @@ #' `query` except for occurrence data, where it also contains information on the #' status of the request. #' @seealso To open a piped query, see [galah_call()]. For alternative -#' operations on `_request` objects, see [capture()], [coalesce()], +#' operations on `_request` objects, see [capture()], [compound()], #' \code{\link[=collapse.data_request]{collapse()}}, #' \code{\link[=collect.data_request]{collect()}}. #' @export diff --git a/R/galah-package.R b/R/galah-package.R index 81d20471..e63016b0 100644 --- a/R/galah-package.R +++ b/R/galah-package.R @@ -45,7 +45,7 @@ #' **Create and execute a query** #' #' * [capture()] Convert a request into a `prequery` or `query` -#' * [coalesce()] Convert an object into a `query_set` showing all calls needed for evaluation +#' * [compound()] Convert an object into a `query_set` showing all calls needed for evaluation #' * \code{\link[=collapse.data_request]{collapse()}} Convert an object to a valid `query` #' * \code{\link[=compute.data_request]{compute()}} Compute a query #' * \code{\link[=collect.data_request]{collect()}} Retrieve a database query diff --git a/R/galah_call.R b/R/galah_call.R index 4bc959d6..0ddc0b3b 100644 --- a/R/galah_call.R +++ b/R/galah_call.R @@ -40,7 +40,7 @@ #' \code{\link[=slice_head.data_request]{slice_head()}} or [unnest()]. #' For operations on `_request` objects, see #' [capture()], -#' [coalesce()], +#' [compound()], #' \code{\link[=collapse.data_request]{collapse()}}, #' \code{\link[=compute.data_request]{compute()}} or #' \code{\link[=collect.data_request]{collect()}}. diff --git a/_pkgdown.yml b/_pkgdown.yml index e0d95db5..025d9236 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -67,7 +67,7 @@ reference: - title: Create and execute a query contents: - capture - - coalesce + - compound - collapse.data_request - compute.data_request - collect.data_request diff --git a/man/capture.data_request.Rd b/man/capture.data_request.Rd index 62884c19..78a67b21 100644 --- a/man/capture.data_request.Rd +++ b/man/capture.data_request.Rd @@ -56,16 +56,16 @@ using \code{\link[=collapse.data_request]{collapse()}}. However, properly evaluating a query often requires building and running additional queries to populate or validate the requested information. A \code{prequery} object shows what has been requested, before those -calls are built by \code{\link[=coalesce]{coalesce()}} and evaluated by +calls are built by \code{\link[=compound]{compound()}} and evaluated by \code{\link[=collapse.data_request]{collapse()}}. For simple cases, this gives the same result as running \code{\link[=collapse.data_request]{collapse()}} while the \code{run_checks} argument of \code{\link[=galah_config]{galah_config()}} is set to \code{FALSE}, but is slightly faster. -In complex cases, it is simply a precursor to \code{\link[=coalesce]{coalesce()}} +In complex cases, it is simply a precursor to \code{\link[=compound]{compound()}} } \seealso{ To open a piped query, see \code{\link[=galah_call]{galah_call()}}. For alternative -operations on \verb{_request} objects, see \code{\link[=coalesce]{coalesce()}}, +operations on \verb{_request} objects, see \code{\link[=compound]{compound()}}, \code{\link[=collapse.data_request]{collapse()}}, \code{\link[=compute.data_request]{compute()}} or \code{\link[=collect.data_request]{collect()}}. diff --git a/man/collapse.data_request.Rd b/man/collapse.data_request.Rd index 284fa312..faa05468 100644 --- a/man/collapse.data_request.Rd +++ b/man/collapse.data_request.Rd @@ -25,7 +25,7 @@ \item{x}{An object to run \code{collapse()} on. Classes supported by \code{galah} include \code{data_request}, \code{metadata_request} and \code{files_request} for building queries; and \code{prequery}, \code{query} or \code{query_set} once constructed (via -\code{\link[=capture]{capture()}} or \code{\link[=coalesce]{coalesce()}}).} +\code{\link[=capture]{capture()}} or \code{\link[=compound]{compound()}}).} \item{...}{Arguments passed on to \code{\link[=capture]{capture()}}.} } @@ -55,7 +55,7 @@ the \code{method} argument. } \seealso{ To open a piped query, see \code{\link[=galah_call]{galah_call()}}. For alternative -operations on \verb{_request} objects, see \code{\link[=capture]{capture()}}, \code{\link[=coalesce]{coalesce()}}, +operations on \verb{_request} objects, see \code{\link[=capture]{capture()}}, \code{\link[=compound]{compound()}}, \code{\link[=compute.data_request]{compute()}} or \code{\link[=collect.data_request]{collect()}}. } diff --git a/man/collect.data_request.Rd b/man/collect.data_request.Rd index 3900e376..ef4306ca 100644 --- a/man/collect.data_request.Rd +++ b/man/collect.data_request.Rd @@ -52,7 +52,7 @@ default way to end a piped query begun with \code{\link[=galah_call]{galah_call( } \seealso{ To open a piped query, see \code{\link[=galah_call]{galah_call()}}. For alternative -operations on \verb{_request} objects, see \code{\link[=capture]{capture()}}, \code{\link[=coalesce]{coalesce()}}, +operations on \verb{_request} objects, see \code{\link[=capture]{capture()}}, \code{\link[=compound]{compound()}}, \code{\link[=collapse.data_request]{collapse()}} or \code{\link[=compute.data_request]{compute()}}. } diff --git a/man/coalesce.Rd b/man/compound.Rd similarity index 67% rename from man/coalesce.Rd rename to man/compound.Rd index d6008b0c..dab2b85d 100644 --- a/man/coalesce.Rd +++ b/man/compound.Rd @@ -1,32 +1,32 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/coalesce.R -\name{coalesce} -\alias{coalesce} -\alias{coalesce.data_request} -\alias{coalesce.metadata_request} -\alias{coalesce.files_request} -\alias{coalesce.prequery} -\alias{coalesce.query} -\alias{coalesce.query_set} +% Please edit documentation in R/compound.R +\name{compound} +\alias{compound} +\alias{compound.data_request} +\alias{compound.metadata_request} +\alias{compound.files_request} +\alias{compound.prequery} +\alias{compound.query} +\alias{compound.query_set} \title{Force evaluation of a database query} \usage{ -coalesce(x, ...) +compound(x, ...) -\method{coalesce}{data_request}(x, mint_doi = FALSE, ...) +\method{compound}{data_request}(x, mint_doi = FALSE, ...) -\method{coalesce}{metadata_request}(x, ...) +\method{compound}{metadata_request}(x, ...) -\method{coalesce}{files_request}(x, ...) +\method{compound}{files_request}(x, ...) -\method{coalesce}{prequery}(x, mint_doi = FALSE, ...) +\method{compound}{prequery}(x, mint_doi = FALSE, ...) -\method{coalesce}{query}(x, ...) +\method{compound}{query}(x, ...) -\method{coalesce}{query_set}(x, ...) +\method{compound}{query_set}(x, ...) } \arguments{ -\item{x}{An object to be coalesced. Works for \code{data_request}, -\code{metadata_request} and \code{file_request}.} +\item{x}{An object to be compounded. Works for \code{data_request}, +\code{metadata_request}, \code{file_request}, \code{query} or \code{prequery}.} \item{...}{Other arguments passed to \code{\link[=capture]{capture()}}.} @@ -40,14 +40,14 @@ listed in the order in which they will be evaluated, meaning the query that the user has actually requested will be placed last. } \description{ -\code{\link[=coalesce]{coalesce()}} is an S3 generic function intended to be called before +\code{\link[=compound]{compound()}} is an S3 generic function intended to be called before \code{\link[=collapse]{collapse()}}. It is important as it shows the full set of queries required to properly evaluate the user's request. This is often broader than the single query returned by \code{\link[=collapse]{collapse()}}. If, for example, the user's query includes a call to \code{\link[=identify.data_request]{identify()}}, then a taxonomic query is required to run \emph{before} the 'final' query is attempted. In relation to -other functions that manipulate \verb{_request} objects, \code{\link[=coalesce]{coalesce()}} is called +other functions that manipulate \verb{_request} objects, \code{\link[=compound]{compound()}} is called within \code{\link[=collapse.data_request]{collapse()}}, and itself calls \code{\link[=capture]{capture()}} internally where required. } diff --git a/man/compute.data_request.Rd b/man/compute.data_request.Rd index 5477c5a8..5f291332 100644 --- a/man/compute.data_request.Rd +++ b/man/compute.data_request.Rd @@ -45,7 +45,7 @@ prevents execution of new code until the server-side process is complete. } \seealso{ To open a piped query, see \code{\link[=galah_call]{galah_call()}}. For alternative -operations on \verb{_request} objects, see \code{\link[=capture]{capture()}}, \code{\link[=coalesce]{coalesce()}}, +operations on \verb{_request} objects, see \code{\link[=capture]{capture()}}, \code{\link[=compound]{compound()}}, \code{\link[=collapse.data_request]{collapse()}}, \code{\link[=collect.data_request]{collect()}}. } diff --git a/man/galah.Rd b/man/galah.Rd index 374860fa..5ce4c2b0 100644 --- a/man/galah.Rd +++ b/man/galah.Rd @@ -52,7 +52,7 @@ For those outside Australia, 'galah' is the common name of \strong{Create and execute a query} \itemize{ \item \code{\link[=capture]{capture()}} Convert a request into a \code{prequery} or \code{query} -\item \code{\link[=coalesce]{coalesce()}} Convert an object into a \code{query_set} showing all calls needed for evaluation +\item \code{\link[=compound]{compound()}} Convert an object into a \code{query_set} showing all calls needed for evaluation \item \code{\link[=collapse.data_request]{collapse()}} Convert an object to a valid \code{query} \item \code{\link[=compute.data_request]{compute()}} Compute a query \item \code{\link[=collect.data_request]{collect()}} Retrieve a database query diff --git a/man/galah_call.Rd b/man/galah_call.Rd index 696d60a1..dbf71850 100644 --- a/man/galah_call.Rd +++ b/man/galah_call.Rd @@ -99,7 +99,7 @@ To amend a request object, use \code{\link[=apply_profile]{apply_profile()}}, \code{\link[=slice_head.data_request]{slice_head()}} or \code{\link[=unnest]{unnest()}}. For operations on \verb{_request} objects, see \code{\link[=capture]{capture()}}, -\code{\link[=coalesce]{coalesce()}}, +\code{\link[=compound]{compound()}}, \code{\link[=collapse.data_request]{collapse()}}, \code{\link[=compute.data_request]{compute()}} or \code{\link[=collect.data_request]{collect()}}. diff --git a/man/glimpse.data_request.Rd b/man/glimpse.data_request.Rd index 608e8f93..a58d8f1b 100644 --- a/man/glimpse.data_request.Rd +++ b/man/glimpse.data_request.Rd @@ -13,8 +13,7 @@ \code{\link[=glimpse]{glimpse()}} is like a transposed version of \code{\link[=print]{print()}}: columns run down the page, and data runs across. This makes it possible to see every column in a data frame. It's a little like \code{\link[=str]{str()}} applied to a data frame but it tries to -show you as much data as possible. (And it always shows the underlying data, -even when applied to a remote data source.) This implementation is specific +show you as much data as possible. This implementation is specific to \code{galah} and is evaluated lazily. \ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}} } \details{ diff --git a/tests/testthat/_snaps/print.md b/tests/testthat/_snaps/print.md index 770299bd..7e78d4dd 100644 --- a/tests/testthat/_snaps/print.md +++ b/tests/testthat/_snaps/print.md @@ -70,7 +70,7 @@ # object of class `query_set` formats correctly Code - coalesce(filter(galah_call(), basisOfRecord == "HUMAN_OBSERVATION")) + compound(filter(galah_call(), basisOfRecord == "HUMAN_OBSERVATION")) Message Object of class query_set containing 4 queries: * metadata/fields url: https://api.ala.org.au/occurrences/index/fields diff --git a/tests/testthat/test-authentication.R b/tests/testthat/test-authentication.R index b6e24c30..c2c8cc99 100644 --- a/tests/testthat/test-authentication.R +++ b/tests/testthat/test-authentication.R @@ -31,7 +31,7 @@ test_that("`authenticate()` works in-pipe for metadata", { is.null(result$request$authenticate) |> expect_false() - result2 <- coalesce(result) + result2 <- compound(result) expect_equal(length(result2), 2) purrr::map(result2, \(a){a$type}) |> unlist() |> @@ -47,7 +47,7 @@ test_that("`authenticate()` works in-pipe for occurrences", { authenticate() |> identify("Litoria dentata") |> filter(year == 2025) |> - coalesce() + compound() expect_equal(length(query), 6) is.null(query[[6]]$request$authenticate) |> expect_false() @@ -80,13 +80,13 @@ test_that("setting `authentication` to `TRUE` changes data returned", { # convert to query set first x_queryset <- galah_call() |> filter(species_list_uid == "dr491") |> - coalesce() + compound() expect_equal(length(x_queryset), 5) expect_equal(x_queryset[[1]]$type, "metadata/config") is.null(x_queryset[[5]]$authenticate) |> expect_false() - # unclear whether it is _critical_ for coalesce() to source `show_all_config()` here + # unclear whether it is _critical_ for compound() to source `show_all_config()` here # but some use cases it probably is necessary, and for the others it is # 'free' because of caching, so probably best to leave it for now diff --git a/tests/testthat/test-print.R b/tests/testthat/test-print.R index 8423e4b9..33602bd8 100644 --- a/tests/testthat/test-print.R +++ b/tests/testthat/test-print.R @@ -51,7 +51,7 @@ test_that("object of class `computed_query` formats correctly", { test_that("object of class `query_set` formats correctly", { galah_call() |> filter(basisOfRecord == "HUMAN_OBSERVATION") |> - coalesce() |> + compound() |> expect_snapshot() }) From 88df77a78b70f55dcd298f1f4202fd501e10d4bc Mon Sep 17 00:00:00 2001 From: Dax Kellie <43838326+daxkellie@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:08:48 +1100 Subject: [PATCH 78/94] Fix `show_values()` for lists --- R/collect_metadata_unnest.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/collect_metadata_unnest.R b/R/collect_metadata_unnest.R index 39c77a57..740dc24d 100644 --- a/R/collect_metadata_unnest.R +++ b/R/collect_metadata_unnest.R @@ -72,10 +72,10 @@ collect_lists_unnest <- function(.query){ # extract additional raw fields columns clean_kvp_values <- function(df){ - browser() if(any(colnames(df) == "kvpValues")){ if(any(lengths(df$kvpValues) > 0)){ df <- df |> + tidyr::unnest(cols = "kvpValues") |> tidyr::unnest_wider("kvpValues") |> tidyr::pivot_wider(names_from = "key", values_from = "value") From 911c3f8ae690aa21ffa7aaa6bd399a1556319a0c Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Fri, 6 Feb 2026 16:41:19 +1100 Subject: [PATCH 79/94] First set of changes identified by `check()` --- R/atlas_media.R | 5 +- R/capture_species.R | 2 +- R/check.R | 7 +-- R/collect_distributions.R | 8 +-- R/collect_metadata.R | 6 +- R/collect_metadata_unnest.R | 7 +-- R/collect_taxa.R | 2 +- R/compound.R | 4 +- R/dplyr-glimpse.R | 5 +- R/filter_object_classes.R | 5 +- R/galah_bbox.R | 4 +- R/galah_polygon.R | 4 +- R/handle_quosures.R | 2 +- R/print.R | 4 +- R/search_all.R | 2 + R/show_values.R | 2 +- R/utilities_internal.R | 4 +- SPECIES_EXAMPLE.R | 57 ------------------- man/filter_object_classes.Rd | 7 ++- man/glimpse.data_request.Rd | 5 ++ man/search_all.Rd | 3 + tests/testthat/test-atlas_species.R | 2 + tests/testthat/test-authentication.R | 4 +- tests/testthat/test-dplyr-count.R | 2 + tests/testthat/test-dplyr-distinct.R | 1 + tests/testthat/test-galah_polygon.R | 2 +- .../test-international-GBIF-predicates.R | 11 +++- tests/testthat/test-request_metadata_select.R | 6 +- tests/testthat/test-show_all.R | 1 + 29 files changed, 76 insertions(+), 98 deletions(-) delete mode 100644 SPECIES_EXAMPLE.R diff --git a/R/atlas_media.R b/R/atlas_media.R index 12bedbb7..9380e494 100644 --- a/R/atlas_media.R +++ b/R/atlas_media.R @@ -107,12 +107,15 @@ build_media_id <- function(df, media_fields){ }else{ purrr::map(media_fields, .f = \(a){ df |> - tidyr::unnest_longer(col = a) |> + tidyr::unnest_longer(col = tidyselect::any_of(a)) |> dplyr::mutate(media_id = as.character(.data[[a]]), media_type = as.character(a), .before = 1) |> dplyr::filter(!is.na(.data$media_id)) |> dplyr::select(- tidyselect::any_of(media_fields)) + # break pipe at this point because tidyselect can't handle -any_of() + # keep_cols <- colnames(result)[!(colnames(result) %in% media_fields)] + # dplyr::select(result, tidyselect::any_of(keep_cols)) }) |> dplyr::bind_rows() } diff --git a/R/capture_species.R b/R/capture_species.R index ec43e6ab..e323bcce 100644 --- a/R/capture_species.R +++ b/R/capture_species.R @@ -30,7 +30,7 @@ capture_species_atlas <- function(.query){ if(!is.null(.query$group_by)){ .query$distinct$name <- .query$group_by$name }else{ - cli::cli_error("No variable supplied to `distinct()`") + cli::cli_abort("No variable supplied to `distinct()`") } } } diff --git a/R/check.R b/R/check.R index 97487eda..ff39b7d1 100644 --- a/R/check.R +++ b/R/check.R @@ -277,8 +277,8 @@ check_fields_gbif_counts <- function(.query){ # First get filters # set fields that can be queried using predicates or downloaded valid_download_fields <- .query[["metadata/fields"]] |> - filter(download_field == TRUE) |> - dplyr::pull(id) + dplyr::filter(.data$download_field == TRUE) |> + dplyr::pull("id") valid_assertions <- .query[["metadata/assertions"]]$id valid_any <- c(valid_download_fields, valid_assertions) @@ -302,13 +302,12 @@ check_fields_gbif_counts <- function(.query){ facets <- .query$body$group_by$name # check for invalid facets valid_search_fields <- .query[["metadata/fields"]] |> - filter(search_field == TRUE) |> + dplyr::filter(.data$search_field == TRUE) |> dplyr::pull(id) if (!all(facets %in% valid_search_fields)) { invalid_facets <- facets[!(facets %in% valid_search_fields)] group_by_invalid <- glue::glue_collapse(invalid_facets, sep = ", ") } - # } } c(filter_invalid, group_by_invalid) diff --git a/R/collect_distributions.R b/R/collect_distributions.R index 202c1485..c23f4a65 100644 --- a/R/collect_distributions.R +++ b/R/collect_distributions.R @@ -5,8 +5,8 @@ collect_distributions <- function(.query){ result <- query_API(.query) # NOTE: below is consistent with `collect_distributions_metadata()` (+ geometry) result <- result |> - bind_rows() |> - select("gid", + dplyr::bind_rows() |> + dplyr::select("gid", "family", "genus_name", "scientific", @@ -16,14 +16,14 @@ collect_distributions <- function(.query){ "area_km", "data_resource_uid", "geometry") |> - rename( + dplyr::rename( "id" = "gid", # this is chosen as ID because it is called by later APIs "genus" = "genus_name", "species" = "scientific", "taxon_concept_id" = "lsid", "label" = "area_name", "common_name" = "common_nam") |> - mutate("common_name" = trimws(.data$common_name)) + dplyr::mutate("common_name" = trimws(.data$common_name)) result$geometry <- sf::st_as_sfc(result$geometry, crs=4326) return(sf::st_as_sf(result)) } \ No newline at end of file diff --git a/R/collect_metadata.R b/R/collect_metadata.R index bba5c9e1..583ca0bb 100644 --- a/R/collect_metadata.R +++ b/R/collect_metadata.R @@ -40,7 +40,7 @@ tidy_list_columns <- function(x){ list_names <- names(x)[list_check] list_tibbles <- purrr::map(list_names, \(a){ - tibble::tibble({{a}} := list(x[[a]])) + tibble::tibble({{a}} == list(x[[a]])) }) }else{ list_tibbles <- NULL @@ -51,7 +51,7 @@ tidy_list_columns <- function(x){ make_nulls_safe() |> tibble::as_tibble() |> dplyr::bind_cols(list_tibbles) |> - dplyr::select(!!!names(x)) # reorder columns to same as `x` + dplyr::select(tidyselect::any_of(names(x))) # reorder columns to same as `x` } #' Internal function to ensure rows can be converted to tibble @@ -264,7 +264,7 @@ collect_fields <- function(.query){ if(is_gbif()){ # we need to join some local metadata to GBIF info - df <- galah:::gbif_internal_archived$search_fields |> + df <- gbif_internal_archived$search_fields |> dplyr::mutate(search_field = TRUE) # organise information from the API diff --git a/R/collect_metadata_unnest.R b/R/collect_metadata_unnest.R index 39c77a57..db9b7e56 100644 --- a/R/collect_metadata_unnest.R +++ b/R/collect_metadata_unnest.R @@ -18,7 +18,7 @@ collect_fields_unnest <- function(.query, purrr::pluck(!!!list("facets", 1, "counts")) |> dplyr::bind_rows() |> dplyr::rename_with(camel_to_snake_case) |> - dplyr::rename({{facet}} := "name") |> + dplyr::rename({{facet}} == "name") |> parse_select(.query) }else{ @@ -33,10 +33,10 @@ collect_fields_unnest <- function(.query, if(nrow(result) > 0){ result |> dplyr::mutate( - field_name := stringr::str_extract(result$i18nCode, "(?<=\\.).*"), + field_name == stringr::str_extract(result$i18nCode, "(?<=\\.).*"), .before = 1) |> dplyr::rename_with(camel_to_snake_case) |> - dplyr::rename({{facet}} := "field_name") |> + dplyr::rename({{facet}} == "field_name") |> parse_select(.query) }else{ # i.e. catch empty results result @@ -72,7 +72,6 @@ collect_lists_unnest <- function(.query){ # extract additional raw fields columns clean_kvp_values <- function(df){ - browser() if(any(colnames(df) == "kvpValues")){ if(any(lengths(df$kvpValues) > 0)){ df <- df |> diff --git a/R/collect_taxa.R b/R/collect_taxa.R index 8daeec81..2d4337d3 100644 --- a/R/collect_taxa.R +++ b/R/collect_taxa.R @@ -189,7 +189,7 @@ collect_identifiers <- function(.query){ # we avoid `is_gbif()` here because other atlases use GBIF APIs result$success <- TRUE result <- result |> - dplyr::relocate(success, .before = 1) |> + dplyr::relocate("success", .before = 1) |> parse_rename(.query) } diff --git a/R/compound.R b/R/compound.R index dd527b1a..83eb9ced 100644 --- a/R/compound.R +++ b/R/compound.R @@ -66,7 +66,7 @@ compound.prequery <- function(x, mint_doi = FALSE, ...){ if(stringr::str_detect(x$type, "^metadata")){ build_query_set_metadata(x) }else if(stringr::str_detect(x$type, "^files")){ - list(as_query_media_files(x, ...)) |> + list(capture_media_files(x, ...)) |> as_query_set() }else if(x$type == "data/distribtions"){ build_query_set_distributions(x) @@ -245,7 +245,7 @@ build_query_set_distributions <- function(x, ...){ }else{ if(!is.null(x$identify)){ result <- list( - collapse_taxa(list(identify = x$identify)) # wrong syntax? + capture_taxa(list(identify = x$identify)) # wrong syntax? ) result[[2]] <- capture_distributions_data(x) # NOTE: shouldn't call microfunctions directly }else{ diff --git a/R/dplyr-glimpse.R b/R/dplyr-glimpse.R index 0a1c8ded..7a5cd8a8 100644 --- a/R/dplyr-glimpse.R +++ b/R/dplyr-glimpse.R @@ -5,7 +5,8 @@ #' frame. It's a little like [str()] applied to a data frame but it tries to #' show you as much data as possible. This implementation is specific #' to `galah` and is evaluated lazily. `r lifecycle::badge("experimental")` -#' +#' @param x An object of class `data_request` +#' @param ... Other arguments, currently ignored #' @details #' This implementation of [glimpse()] actually involves changing the API call #' sent to the server, then returning a novel object class with it's own @@ -19,7 +20,7 @@ glimpse.data_request <- function(x, ...){ #' @rdname glimpse.data_request #' @export print.occurrences_glimpse <- function(x, ...){ - y <- capture.output(dplyr::glimpse(x)) + y <- utils::capture.output(dplyr::glimpse(x)) n_text <- attr(x, 'total_n') |> formatC(big.mark = ",") y[[1]] <- glue::glue("Rows: {n_text}") diff --git a/R/filter_object_classes.R b/R/filter_object_classes.R index 0d182978..97d23932 100644 --- a/R/filter_object_classes.R +++ b/R/filter_object_classes.R @@ -2,8 +2,8 @@ #' #' In galah, there are several ways to provide filter information. To ensure #' these are handled and printed correctly, they are assigned classes -#' @param x a list -#' @rdname filter_object_classes +#' @param x a list, or object of supported class +#' @name filter_object_classes #' @order 1 #' @export as_data_filter <- function(x){ @@ -44,6 +44,7 @@ as_files_filter <- function(x){ # Print functions for the above #' @rdname filter_object_classes +#' @param ... Additional arguments, currently ignored #' @order 5 #' @export print.data_filter <- function(x, ...){ diff --git a/R/galah_bbox.R b/R/galah_bbox.R index e3ff76e0..af6f954a 100644 --- a/R/galah_bbox.R +++ b/R/galah_bbox.R @@ -87,10 +87,10 @@ galah_bbox <- function(...) { } else { if (inherits(query, c("sf", "sfc"))) { query <- query |> - sf::st_bbox(crs = st_crs("WGS84")) + sf::st_bbox(crs = sf::st_crs("WGS84")) bbox_coords <- round(query, 5) query <- query |> - sf::st_as_sfc(crs = st_crs("WGS84")) # FIXME: should we define the projection? + sf::st_as_sfc(crs = sf::st_crs("WGS84")) # FIXME: should we define the projection? } } } diff --git a/R/galah_polygon.R b/R/galah_polygon.R index 3e9ebc3f..fb2d8f9d 100644 --- a/R/galah_polygon.R +++ b/R/galah_polygon.R @@ -120,8 +120,8 @@ parse_polygon <- function(query, } else { # remove space after "POLYGON" if present - if(str_detect(query, "POLYGON \\(\\(")) - query <- string::str_replace(query, "POLYGON \\(\\(", "POLYGON\\(\\(") + if(stringr::str_detect(query, "POLYGON \\(\\(")) + query <- stringr::str_replace(query, "POLYGON \\(\\(", "POLYGON\\(\\(") if (stringr::str_detect(query, "POLYGON") & !stringr::str_detect(query, "MULTIPOLYGON")) { diff --git a/R/handle_quosures.R b/R/handle_quosures.R index db1b924e..2d568051 100644 --- a/R/handle_quosures.R +++ b/R/handle_quosures.R @@ -468,7 +468,7 @@ parse_in <- function(x, excl){ #' @noRd #' @keywords internal parse_c <- function(x, excl){ - if(length(quo_get_expr(x)) < 2L){ + if(length(rlang::quo_get_expr(x)) < 2L){ filter_error() } # convert to logical format using OR statements diff --git a/R/print.R b/R/print.R index cb1db2e8..38306524 100644 --- a/R/print.R +++ b/R/print.R @@ -182,10 +182,10 @@ print.prequery <- print.query print.computed_query <- function(x, ...){ # calculate arrange/slice info if(!is.null(x$arrange)){ - arrange <- galah_pale_green(glue("\n + arrange <- galah_pale_green(glue::glue("\n arrange: {x$arrange$variable} ({x$arrange$direction})")) if(x$arrange$slice_called == TRUE){ - slice <- galah_pale_green(glue("\n + slice <- galah_pale_green(glue::glue("\n slice: {x$arrange$slice_n}")) }else{ slice <- NULL diff --git a/R/search_all.R b/R/search_all.R index d7b5b061..e1aa5240 100644 --- a/R/search_all.R +++ b/R/search_all.R @@ -23,6 +23,8 @@ #' @param all_fields `r lifecycle::badge("experimental")` If `TRUE`, #' `show_values()` also returns all columns available from the API, rather #' than the 'default' columns traditionally provided via galah. +#' @param One or more objects accepted by the taxonomic lookup services. See +#' [taxonomic_searches] for details #' @details There are six categories of information, each with their own #' specific sub-functions to look-up each type of information. #' The available types of information for `search_all()` are: diff --git a/R/show_values.R b/R/show_values.R index ec3201f7..cbbbcb70 100644 --- a/R/show_values.R +++ b/R/show_values.R @@ -99,7 +99,7 @@ show_values <- function(df, if(isTRUE(all_fields)){ request_metadata() |> filter({{type}} == {{match_name}}) |> - select(everything()) |> + select(tidyselect::everything()) |> unnest() |> collect() }else{ diff --git a/R/utilities_internal.R b/R/utilities_internal.R index f677162f..fd9f8ddb 100644 --- a/R/utilities_internal.R +++ b/R/utilities_internal.R @@ -483,8 +483,8 @@ reasons_supported <- function(){ atlas <- potions::pour("atlas", "region") supported_atlases <- request_metadata(type = "apis") |> collect() |> - dplyr::filter(type == "metadata/reasons") |> - dplyr::pull(atlas) + dplyr::filter(.data$type == "metadata/reasons") |> + dplyr::pull("atlas") atlas %in% supported_atlases } diff --git a/SPECIES_EXAMPLE.R b/SPECIES_EXAMPLE.R deleted file mode 100644 index df3885ed..00000000 --- a/SPECIES_EXAMPLE.R +++ /dev/null @@ -1,57 +0,0 @@ -# tests of dplyr syntax for converting occurrences to useful species-level -# summaries within a single pipe. - -# load libraries -library(galah) -library(dplyr) - -# get example occurrence dataset -galah_config(email = "martinjwestgate@gmail.com") -testdata <- galah_call() |> - identify("perameles") |> - select(speciesID, group = "basic") |> - filter(year == 2020) |> - collect() - -# number of records -testdata |> - count() - -# number of records per species -testdata |> - count(speciesID) - -# number of species (aka `atlas_counts(type = "species")`) -testdata |> - summarize(count = n_distinct(speciesID)) - -# one row per species (aka `atlas_species()`) -testdata |> - distinct(speciesID, .keep_all = TRUE) -# Noting that this requires care with `select()` to return sensible results - -# show only which species are present in the query (aka `show_values()`) -testdata |> - distinct(speciesID, .keep_all = FALSE) - -# get species (and optionally, record) counts grouped by a second factor -testdata |> - group_by(basisOfRecord) |> - summarize(n_records = n(), - n_spp = n_distinct(speciesID)) - -# get one row per species, showing number of records -testdata |> -testdata |> - group_by(speciesID) |> - mutate(count = n()) |> - distinct(.keep_all = TRUE) - - -# example species download for comparison purposes -# noting syntax may change -testspecies <- galah_call() |> - identify("perameles") |> - filter(year == 2020) |> - group_by(speciesID) |> - collect() \ No newline at end of file diff --git a/man/filter_object_classes.Rd b/man/filter_object_classes.Rd index d81a0a4a..548920de 100644 --- a/man/filter_object_classes.Rd +++ b/man/filter_object_classes.Rd @@ -1,6 +1,7 @@ % Generated by roxygen2: do not edit by hand % Please edit documentation in R/filter_object_classes.R -\name{as_data_filter} +\name{filter_object_classes} +\alias{filter_object_classes} \alias{as_data_filter} \alias{as_predicates_filter} \alias{as_metadata_filter} @@ -28,7 +29,9 @@ as_files_filter(x) \method{print}{files_filter}(x, ...) } \arguments{ -\item{x}{a list} +\item{x}{a list, or object of supported class} + +\item{...}{Additional arguments, currently ignored} } \description{ In galah, there are several ways to provide filter information. To ensure diff --git a/man/glimpse.data_request.Rd b/man/glimpse.data_request.Rd index a58d8f1b..d83bb614 100644 --- a/man/glimpse.data_request.Rd +++ b/man/glimpse.data_request.Rd @@ -9,6 +9,11 @@ \method{print}{occurrences_glimpse}(x, ...) } +\arguments{ +\item{x}{An object of class \code{data_request}} + +\item{...}{Other arguments, currently ignored} +} \description{ \code{\link[=glimpse]{glimpse()}} is like a transposed version of \code{\link[=print]{print()}}: columns run down the page, and data runs across. This makes it possible to see every column in a data diff --git a/man/search_all.Rd b/man/search_all.Rd index 8f491d77..d9b0c709 100644 --- a/man/search_all.Rd +++ b/man/search_all.Rd @@ -60,6 +60,9 @@ case-sensitive.} \item{all_fields}{\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}} If \code{TRUE}, \code{show_values()} also returns all columns available from the API, rather than the 'default' columns traditionally provided via galah.} + +\item{One}{or more objects accepted by the taxonomic lookup services. See +\link{taxonomic_searches} for details} } \value{ An object of class \code{tbl_df} and \code{data.frame} (aka a tibble) diff --git a/tests/testthat/test-atlas_species.R b/tests/testthat/test-atlas_species.R index 5f339b56..d06167ba 100644 --- a/tests/testthat/test-atlas_species.R +++ b/tests/testthat/test-atlas_species.R @@ -157,6 +157,8 @@ test_that("atlas_species reformats column names when empty tibble is returned", }) test_that("`atlas_species()` accepts `distinct()` to set the grouping variable", { + skip_if_offline(); skip_on_ci() + genera <- galah_call() |> identify("Limnodynastidae") |> distinct(genusID) |> diff --git a/tests/testthat/test-authentication.R b/tests/testthat/test-authentication.R index c2c8cc99..9729d5ab 100644 --- a/tests/testthat/test-authentication.R +++ b/tests/testthat/test-authentication.R @@ -1,7 +1,7 @@ quiet_config <- purrr::quietly(galah_config) test_that("`request_metadata()` works for type = `config`", { - skip_on_ci(); skip_on_cran() + skip_if_offline(); skip_on_ci() result <- request_metadata(type = "config") |> collect() expect_equal(nrow(result), 1) @@ -12,7 +12,7 @@ test_that("`request_metadata()` works for type = `config`", { }) test_that("`request_metadata()` caches type `config` correctly", { - skip_on_ci(); skip_on_cran() + skip_if_offline(); skip_on_ci() reset_cache() x <- request_metadata(type = "config") |> collect() diff --git a/tests/testthat/test-dplyr-count.R b/tests/testthat/test-dplyr-count.R index 9c035123..56e1fb7f 100644 --- a/tests/testthat/test-dplyr-count.R +++ b/tests/testthat/test-dplyr-count.R @@ -114,6 +114,7 @@ test_that("`count()` handles 'taxonConceptID' as a 'group by' variable", { }) test_that("`count()` handles 'speciesID' as a 'group by' variable", { + skip_if_offline(); skip_on_ci() # FIXME: Currently returns colname `speciesID.https://biodiversity.org` counts <- galah_call() |> filter(year == 1900, @@ -306,6 +307,7 @@ test_that("`group_by()` works when > 1 `filter()`", { }) test_that("`select()` works for count queries", { + skip_if_offline(); skip_on_ci() x <- galah_call() |> filter(year == 2024, genus == "Crinia") |> diff --git a/tests/testthat/test-dplyr-distinct.R b/tests/testthat/test-dplyr-distinct.R index 7d1078f9..ed105765 100644 --- a/tests/testthat/test-dplyr-distinct.R +++ b/tests/testthat/test-dplyr-distinct.R @@ -100,6 +100,7 @@ test_that("`distinct(.keep_all = TRUE)` sets species queries", { }) test_that("`distinct(.keep_all = TRUE)` accepts non-species-level groupings", { + skip_if_offline(); skip_on_ci() genera <- galah_call() |> identify("Limnodynastidae") |> distinct(genusID, .keep_all = TRUE) |> diff --git a/tests/testthat/test-galah_polygon.R b/tests/testthat/test-galah_polygon.R index b2e71b41..97506ae0 100644 --- a/tests/testthat/test-galah_polygon.R +++ b/tests/testthat/test-galah_polygon.R @@ -21,7 +21,7 @@ test_that("galah_polygon checks inputs", { }) test_that("galah_polygon finds polygon errors 1" , { - skip_on_cran() + skip_if_offline(); skip_on_ci() invalid_wkt <- "POLYGON((145.71622941565508 -32.17848852726597,))" expect_error(galah_polygon(invalid_wkt)) # NOTE: diff --git a/tests/testthat/test-international-GBIF-predicates.R b/tests/testthat/test-international-GBIF-predicates.R index f6610048..9091a638 100644 --- a/tests/testthat/test-international-GBIF-predicates.R +++ b/tests/testthat/test-international-GBIF-predicates.R @@ -19,7 +19,7 @@ test_that("`galah_filter()` returns predicates for GBIF", { }) test_that("`filter()` handles a single entry for GBIF", { - skip_if_offline(); skip_on_ci() + skip_if_offline(); skip_on_ci() x <- galah_call() |> filter(year == 2024) |> count() |> @@ -141,6 +141,8 @@ test_that("`filter()` handles `OR` and `%in%` for GBIF", { }) test_that("`filter()` handles multiple queries including != for GBIF", { + skip_if_offline(); skip_on_ci() + x <- galah_call() |> filter(year == 2024, country != "AU") |> # FIXME: countryCode fails with cryptic warning count() |> @@ -158,6 +160,8 @@ test_that("`filter()` handles multiple queries including != for GBIF", { }) test_that("`filter()` handles `between()` for GBIF", { + skip_if_offline(); skip_on_ci() + x <- galah_call() |> filter(dplyr::between(year, 2010, 2020)) |> group_by(year) |> @@ -173,6 +177,7 @@ test_that("`filter()` handles `between()` for GBIF", { }) test_that("filter() handles !() for GBIF", { + skip_if_offline(); skip_on_ci() # exclude some levels of basisOfRecord excluded_categories <- c("OCCURRENCE", "LIVING_SPECIMEN", "HUMAN_OBSERVATION") @@ -198,6 +203,8 @@ test_that("filter() handles !() for GBIF", { }) test_that("filter() handles `is.na()` for GBIF", { + skip_if_offline(); skip_on_ci() + # missing values x <- galah_call() |> filter(is.na(country)) |> @@ -224,6 +231,8 @@ test_that("filter() handles `is.na()` for GBIF", { }) test_that("filter() handles c() for GBIF", { + skip_if_offline(); skip_on_ci() + # effectively parses this as 'in' as per GBIF instructions country_vector <- c("AU", "US", "NL") diff --git a/tests/testthat/test-request_metadata_select.R b/tests/testthat/test-request_metadata_select.R index f0621800..8c7cd8de 100644 --- a/tests/testthat/test-request_metadata_select.R +++ b/tests/testthat/test-request_metadata_select.R @@ -102,7 +102,7 @@ test_that("`request_metadata()` works with `select()` for remote APIs *without* }) test_that("`request_metadata()` works with `select()` for remote APIs *with* default columns", { - skip_if_offline() + skip_if_offline(); skip_on_ci() type_list <- c("assertions", "fields", "licences", @@ -174,6 +174,8 @@ test_that("`request_metadata()` works with `select()` for remote APIs *with* def }) test_that("`request_metdata()` works with `select()` for `type = 'taxa'`", { + skip_if_offline(); skip_on_ci() + query <- request_metadata() |> identify("Crinia") |> select(everything()) |> @@ -197,6 +199,7 @@ test_that("`request_metdata()` works with `select()` for `type = 'taxa'`", { }) test_that("`request_metdata()` works with `select()` for complex taxa", { + skip_if_offline(); skip_on_ci() crinia_tibble <- tibble::tibble(kingdom = "Animalia", phylum = "Chordata", genus = "Crinia") @@ -223,6 +226,7 @@ test_that("`request_metdata()` works with `select()` for complex taxa", { }) test_that("`request_metdata()` works with `select()` for `type = 'identifiers'`", { + skip_if_offline(); skip_on_ci() tcid <- search_taxa("Chordata") |> dplyr::pull("taxon_concept_id") query <- request_metadata() |> diff --git a/tests/testthat/test-show_all.R b/tests/testthat/test-show_all.R index 76d27667..89684ce9 100644 --- a/tests/testthat/test-show_all.R +++ b/tests/testthat/test-show_all.R @@ -45,6 +45,7 @@ test_that("all `show_all()` functions return correctly with all syntax", { # lists queries are noisy, so run separately test_that("`show_all_lists()` functions work correctly", { + skip_if_offline(); skip_on_ci() quiet_lists <- function(...){ list_fun <- purrr::quietly(show_all_lists) list_fun(...) |> From 3c445bc4e40e63c52aa1757d65f4e1ace38e4b52 Mon Sep 17 00:00:00 2001 From: Dax Kellie <43838326+daxkellie@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:30:39 +1100 Subject: [PATCH 80/94] edit vignette to reflect gbif doi changes --- vignettes/download-data-reproducibly.Rmd | 8 +++----- vignettes/download-data-reproducibly.Rmd.orig | 8 +++----- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/vignettes/download-data-reproducibly.Rmd b/vignettes/download-data-reproducibly.Rmd index 40e452d4..fc210145 100644 --- a/vignettes/download-data-reproducibly.Rmd +++ b/vignettes/download-data-reproducibly.Rmd @@ -62,11 +62,9 @@ In galah, you can also add a DOI to your download query. You just need to add `mint_doi = TRUE` to `atlas_occurrences()`. A unique DOI will be assigned to the resulting object once the query is run. -Please note that DOI generation and reload is currently supported only for -queries to the Atlas of Living Australia. GBIF provides a DOI with every -download, but at the time of writing, they don't provide an API to use -that DOI to download the dataset a second time. Other atlases don't support -DOIs at all (yet). +Please note that as of version 2.2.0, DOI generation and reload is currently +supported for queries to the Atlas of Living Australia and GBIF. +Other atlases don't support DOIs at all (yet). diff --git a/vignettes/download-data-reproducibly.Rmd.orig b/vignettes/download-data-reproducibly.Rmd.orig index 2cbf2024..f22f940b 100644 --- a/vignettes/download-data-reproducibly.Rmd.orig +++ b/vignettes/download-data-reproducibly.Rmd.orig @@ -62,11 +62,9 @@ In galah, you can also add a DOI to your download query. You just need to add `mint_doi = TRUE` to `atlas_occurrences()`. A unique DOI will be assigned to the resulting object once the query is run. -Please note that DOI generation and reload is currently supported only for -queries to the Atlas of Living Australia. GBIF provides a DOI with every -download, but at the time of writing, they don't provide an API to use -that DOI to download the dataset a second time. Other atlases don't support -DOIs at all (yet). +Please note that as of version 2.2.0, DOI generation and reload is currently +supported for queries to the Atlas of Living Australia and GBIF. +Other atlases don't support DOIs at all (yet). ```{r} From 2236b104eecaab85413f4c743d968d1dd5d1f826 Mon Sep 17 00:00:00 2001 From: Dax Kellie <43838326+daxkellie@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:31:14 +1100 Subject: [PATCH 81/94] minor improvements to cli messages --- R/check_queue.R | 3 ++- R/collect_occurrences.R | 37 ++++++++++++++++++++++++++++++++++--- R/compute_occurrences.R | 4 +--- R/query_API.R | 8 +++++--- 4 files changed, 42 insertions(+), 10 deletions(-) diff --git a/R/check_queue.R b/R/check_queue.R index 3b94d8f9..252c4aab 100644 --- a/R/check_queue.R +++ b/R/check_queue.R @@ -29,7 +29,8 @@ check_queue_loop <- function(.query){ iter <- 1 verbose <- potions::pour("package", "verbose", .pkg = "galah") if(verbose){ - cli::cli_text("Current queue length: {current_queue}") + position <- glue::glue("Current queue length: {current_queue}") + cli::cli_text(position) } while(continue == TRUE){ .query <- check_occurrence_status(.query) diff --git a/R/collect_occurrences.R b/R/collect_occurrences.R index 92150d5e..bd91d823 100644 --- a/R/collect_occurrences.R +++ b/R/collect_occurrences.R @@ -44,13 +44,15 @@ collect_occurrences_default <- function(.query, wait, file, call){ # check queue download_response <- check_queue(.query, wait = wait) if(is.null(download_response)){ - cli::cli_abort("No response from selected atlas", + cli::cli_abort("No response from selected atlas.", call = call) } # get data if(potions::pour("package", "verbose", .pkg = "galah") & download_response$status == "complete") { - cli::cli_text("Downloading") + + scrolly_dots_message("Downloading") + # cli::cli_par() } # sometimes lookup info critical, but not others - unclear when/why! if(any(names(download_response) == "download_url")){ @@ -143,4 +145,33 @@ download_failed_message <- function(call){ i = "This usually suggests a problem with the download itself, rather than the API.", i = "Consider checking that a file has been created in the expected location.") |> cli::cli_abort(call = call) -} \ No newline at end of file +} + + + +#' Theatrics +#' @noRd +#' @keywords Internal +scrolly_dots_message <- function(message) { + + spinny <- cli::make_spinner( + which = "simpleDotsScrolling", + template = paste0(message, " {spin}") + ) + + # update the spinner 100 times + lapply(1:100, function(x) { + spinny$spin() + wait(.001) + }) + + # clear the spinner from the status bar + # spinny$finish() +} + +#' Wait time +#' @noRd +#' @keywords Internal +wait <- function(seconds = 1) { + Sys.sleep(seconds) +} diff --git a/R/compute_occurrences.R b/R/compute_occurrences.R index 6a541ab4..f38baef0 100644 --- a/R/compute_occurrences.R +++ b/R/compute_occurrences.R @@ -44,12 +44,10 @@ compute_occurrences_la <- function(.query){ check_occurrence_response() if(potions::pour("package", "verbose")){ n_records <- status_code$total_records - cli::cli_par() if(!is.null(.query$request$authenticate)){ cli::cli_text("Query sent including JWT token") } - cli::cli_text("Request for {n_records} occurrences placed in queue") - cli::cli_end() + cli::cli_text("Request for {n_records} occurrences placed in queue.") } # return a useful object c(list(type = "data/occurrences"), diff --git a/R/query_API.R b/R/query_API.R index 7f0c1b69..15f23b61 100644 --- a/R/query_API.R +++ b/R/query_API.R @@ -22,7 +22,8 @@ query_API <- function(.query, query_API_internal(data_tr, error_call = error_call) }, - .progress = set_progress_bar_behaviour(nrow(.query$url) > 1)) + .progress = set_progress_bar_behaviour(nrow(.query$url) > 1) + ) # next handle multiple `body` arguments # this is currently limited to GBIF count requests with > 1 `group_by` args }else if(inherits(.query$body, "data.frame")){ @@ -35,7 +36,8 @@ query_API <- function(.query, error_call = error_call)) a }, - .progress = set_progress_bar_behaviour(length(.query$body) > 1)) |> + .progress = set_progress_bar_behaviour(nrow(.query$url) > 1) + ) |> dplyr::bind_rows() # finally, some queries are 'simple'; one `url`, one or no `body` args # these we just run without any looping. @@ -53,7 +55,7 @@ set_progress_bar_behaviour <- function(criteria){ verbose <- potions::pour("package", "verbose", .pkg = "galah") & criteria if(verbose){ - progress_bar <- list(name = "Querying API", + progress_bar <- list(format = "Querying API | {pb_bar} {pb_percent}", clear = TRUE) }else{ progress_bar <- FALSE From bfb48273c533864d175a0e51e7bed99b30b617c4 Mon Sep 17 00:00:00 2001 From: Dax Kellie <43838326+daxkellie@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:31:33 +1100 Subject: [PATCH 82/94] some edits to docs --- R/capture.R | 23 ++++++++++++++++++----- R/galah_config.R | 31 ++++++++++++++++++------------- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/R/capture.R b/R/capture.R index a8ebdeac..fe65d116 100644 --- a/R/capture.R +++ b/R/capture.R @@ -1,15 +1,28 @@ #' Capture a request #' +#' @description #' The first step in evaluating a request is to capture and parse the #' information it contains. The resulting object has class `prequery` -#' for those requiring further processing or `query` for those that don't. +#' for those requiring further processing or `query` for those that don't. +#' A `prequery` object shows what has been requested by a user in a given +#' `galah_call()`. +#' #' @details #' Typically, queries in galah are piped using [galah_call()], which builds #' an object of class `"data_request"`; or [request_metadata()] or -#' [request_files()]. All these objects can be converted to class `"query"` -#' using \code{\link[=collapse.data_request]{collapse()}}. However, -#' properly evaluating a query often requires building and running -#' additional queries to populate or validate the requested information. +#' [request_files()]. Under the hood, [galah_call()] constructs a query +#' ([collapse()]), sends it to an API ([compute()]), and returns its +#' contents ([collect()]). +#' +#' For [collapse()] to construct a complete query (class `"query"`), +#' it often requires additional, smaller API calls to validate pieces +#' of information used to construct the full user request. +#' Underlying [collapse()] are these +#' smaller but necessary steps: +#' * [capture()] parses the basic structure of a user request +#' * [compound()] combines all necessary API calls to construct the final query +#' * [collapse()] constructs the complete query +#' #' A `prequery` object shows what has been requested, before those #' calls are built by [compound()] and evaluated by #' \code{\link[=collapse.data_request]{collapse()}}. diff --git a/R/galah_config.R b/R/galah_config.R index cbd666a3..49598e86 100644 --- a/R/galah_config.R +++ b/R/galah_config.R @@ -1,10 +1,13 @@ #' View or set package behaviour -#' +#' +#' @description #' The `galah` package supports queries to a number of different data providers, #' and once selected, it is desirable that all later queries are sent to that #' organisation. Rather than supply this information separately in each -#' query, therefore, it is more parsimonious to cache that information centrally -#' and call it as needed, which is what this function supports. Beyond choosing +#' query, it is more parsimonious to cache it centrally +#' and call it as needed, which is what this function supports. +#' +#' Beyond choosing #' an organisation, there are several other use cases for caching. Many #' GBIF nodes require the user to supply a registered email address, #' password, and (in some cases) a reason for downloading data, all stored via @@ -19,23 +22,25 @@ #' Valid arguments to this function are: #' #' * `atlas` string: Living Atlas to point to, Australia by default. Can be -#' an organisation name, acronym, or region (see [show_all_atlases()] for -#' admissible values) -#' * `authenticate` logical: should `galah` authenticate your queries using -#' JWT tokens? Defaults to `FALSE`. +#' an organisation name, acronym, or region (see [show_all_atlases()] for +#' admissible values) +#' * `authenticate` logical: Should `galah` use authenticate your queries using +#' JWT tokens? Defaults to `FALSE`. If `TRUE`, user credentials are +#' verified prior to sending a query. This can allow users with special +#' access to download additional information in `galah`. #' * `caching` logical: should metadata query results be cached in `options()`? #' Defaults to `TRUE` for improved stability and speed. -#' * `directory` string: the directory to use for the disk cache. +#' * `directory` string: The directory to use for the disk cache. #' By default this is a temporary directory, which means that results will #' only be cached within an R session and cleared automatically when the user #' exits R. The user may wish to set this to a non-temporary directory for #' caching across sessions. The directory must exist on the file system. #' * `download_reason_id` numeric or string: the "download reason" required. -#' by some ALA services, either as a numeric ID (currently 0--13) -#' or a string (see `show_all(reasons)` for a list of valid ID codes and -#' names). By default this is NA. Some ALA services require a valid -#' download_reason_id code, either specified here or directly to the -#' associated R function. +#' by some ALA services, either as a numeric ID (currently 0--13) +#' or a string (see `show_all(reasons)` for a list of valid ID codes and +#' names). By default this is NA. Some ALA services require a valid +#' download_reason_id code, either specified here or directly to the +#' associated R function. #' * `email` string: An email address that has been registered with the chosen #' atlas. For the ALA, you can register at #' [this address](https://auth.ala.org.au/userdetails/registration/createAccount). From 997d55032c508eaf60a2666c18b0e7b8860e2661 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Fri, 6 Feb 2026 19:00:36 +1100 Subject: [PATCH 83/94] More bug fixes (inc for those from previous commit) --- NAMESPACE | 2 + R/capture.R | 3 +- R/collect_metadata_unnest.R | 6 +-- R/dplyr-collect.R | 8 ++-- R/galah-package.R | 1 + R/search_all.R | 2 +- man/capture.data_request.Rd | 2 +- man/collect.data_request.Rd | 4 +- man/search_all.Rd | 2 +- tests/testthat/test-atlas_media.R | 4 +- tests/testthat/test-dplyr-distinct.R | 39 ++++++++++---------- tests/testthat/test-international-Flanders.R | 4 +- tests/testthat/test-international-Kew.R | 2 +- tests/testthat/test-international-Spain.R | 14 ------- 14 files changed, 42 insertions(+), 51 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index fb3ba101..3b579a90 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -5,6 +5,7 @@ S3method(arrange,data_request) S3method(arrange,metadata_request) S3method(capture,data_request) S3method(capture,files_request) +S3method(capture,list) S3method(capture,metadata_request) S3method(collapse,data_request) S3method(collapse,files_request) @@ -151,5 +152,6 @@ importFrom(dplyr,slice_head) importFrom(graphics,identify) importFrom(lifecycle,badge) importFrom(rlang,.data) +importFrom(rlang,`:=`) importFrom(rlang,caller_env) importFrom(sf,st_crop) diff --git a/R/capture.R b/R/capture.R index a8ebdeac..8529557b 100644 --- a/R/capture.R +++ b/R/capture.R @@ -124,7 +124,8 @@ capture.files_request <- function(x, #' @rdname capture.data_request #' @order 5 -capture.list <- function(x){ +#' @export +capture.list <- function(x, ...){ as_prequery(x) } diff --git a/R/collect_metadata_unnest.R b/R/collect_metadata_unnest.R index 336956f6..b42ba151 100644 --- a/R/collect_metadata_unnest.R +++ b/R/collect_metadata_unnest.R @@ -18,7 +18,7 @@ collect_fields_unnest <- function(.query, purrr::pluck(!!!list("facets", 1, "counts")) |> dplyr::bind_rows() |> dplyr::rename_with(camel_to_snake_case) |> - dplyr::rename({{facet}} == "name") |> + dplyr::rename({{facet}} := "name") |> parse_select(.query) }else{ @@ -33,10 +33,10 @@ collect_fields_unnest <- function(.query, if(nrow(result) > 0){ result |> dplyr::mutate( - field_name == stringr::str_extract(result$i18nCode, "(?<=\\.).*"), + field_name = stringr::str_extract(.data$i18nCode, "(?<=\\.).*"), .before = 1) |> dplyr::rename_with(camel_to_snake_case) |> - dplyr::rename({{facet}} == "field_name") |> + dplyr::rename({{facet}} := "field_name") |> parse_select(.query) }else{ # i.e. catch empty results result diff --git a/R/dplyr-collect.R b/R/dplyr-collect.R index 4aaea1ac..081dc0b9 100644 --- a/R/dplyr-collect.R +++ b/R/dplyr-collect.R @@ -51,17 +51,17 @@ collect.files_request <- function(x, ...){ #' @rdname collect.data_request #' @order 4 #' @export -collect.prequery <- function(x, wait = TRUE, file = NULL){ +collect.prequery <- function(x, ...){ compute(x) |> - collect(wait = wait, file = file) + collect(...) } #' @rdname collect.data_request #' @order 5 #' @export -collect.query <- function(x, ..., wait = TRUE, file = NULL){ +collect.query <- function(x, ...){ compute(x) |> - collect(wait = wait, file = file) + collect(...) } #' @rdname collect.data_request diff --git a/R/galah-package.R b/R/galah-package.R index e63016b0..e8cc6004 100644 --- a/R/galah-package.R +++ b/R/galah-package.R @@ -108,6 +108,7 @@ #' #' @importFrom rlang caller_env #' @importFrom rlang .data +#' @importFrom rlang `:=` #' @keywords internal "_PACKAGE" diff --git a/R/search_all.R b/R/search_all.R index e1aa5240..1af3beb9 100644 --- a/R/search_all.R +++ b/R/search_all.R @@ -23,7 +23,7 @@ #' @param all_fields `r lifecycle::badge("experimental")` If `TRUE`, #' `show_values()` also returns all columns available from the API, rather #' than the 'default' columns traditionally provided via galah. -#' @param One or more objects accepted by the taxonomic lookup services. See +#' @param ... One or more objects accepted by the taxonomic lookup services. See #' [taxonomic_searches] for details #' @details There are six categories of information, each with their own #' specific sub-functions to look-up each type of information. diff --git a/man/capture.data_request.Rd b/man/capture.data_request.Rd index 78a67b21..7514d2e8 100644 --- a/man/capture.data_request.Rd +++ b/man/capture.data_request.Rd @@ -16,7 +16,7 @@ capture(x, ...) \method{capture}{files_request}(x, thumbnail = FALSE, ...) -\method{capture}{list}(x) +\method{capture}{list}(x, ...) } \arguments{ \item{x}{A \verb{_request} object to convert to a \code{prequery}.} diff --git a/man/collect.data_request.Rd b/man/collect.data_request.Rd index ef4306ca..4153b2a9 100644 --- a/man/collect.data_request.Rd +++ b/man/collect.data_request.Rd @@ -16,9 +16,9 @@ \method{collect}{files_request}(x, ...) -\method{collect}{prequery}(x, wait = TRUE, file = NULL) +\method{collect}{prequery}(x, ...) -\method{collect}{query}(x, ..., wait = TRUE, file = NULL) +\method{collect}{query}(x, ...) \method{collect}{query_set}(x, ..., wait = TRUE, file = NULL) diff --git a/man/search_all.Rd b/man/search_all.Rd index d9b0c709..4e96d6c8 100644 --- a/man/search_all.Rd +++ b/man/search_all.Rd @@ -61,7 +61,7 @@ case-sensitive.} \code{show_values()} also returns all columns available from the API, rather than the 'default' columns traditionally provided via galah.} -\item{One}{or more objects accepted by the taxonomic lookup services. See +\item{...}{One or more objects accepted by the taxonomic lookup services. See \link{taxonomic_searches} for details} } \value{ diff --git a/tests/testthat/test-atlas_media.R b/tests/testthat/test-atlas_media.R index 6b9d36d3..4b2a8540 100644 --- a/tests/testthat/test-atlas_media.R +++ b/tests/testthat/test-atlas_media.R @@ -189,7 +189,7 @@ test_that("collect_media handles different file formats", { quiet_media() # sample one of each multimedia type to shorten testing time media_summary <- media_data |> - dplyr::group_by(multimedia) |> + dplyr::group_by(media_type) |> dplyr::sample_n(size = 2) expect_equal(sort(unique(media_data$multimedia)), c("Image", "Image | Sound")) @@ -203,7 +203,7 @@ test_that("collect_media handles different file formats", { # properly. This is important for sound files which may not load properly if # thumbnail settings are ignored. - # also worth testing that `thumbnail` is ignored for sounds + # FIXME: worth testing that `thumbnail` is ignored for sounds }) test_that("collect_media handles thumbnails", { diff --git a/tests/testthat/test-dplyr-distinct.R b/tests/testthat/test-dplyr-distinct.R index ed105765..fd8edb05 100644 --- a/tests/testthat/test-dplyr-distinct.R +++ b/tests/testthat/test-dplyr-distinct.R @@ -142,25 +142,26 @@ test_that("`group_by(something) |> distinct(speciesID) |> count()` gives grouped expect_true() }) -test_that("`add_count() |> distinct()` adds record counts to each species", { - skip_if_offline(); skip_on_ci() - query <- galah_call() |> - filter(year == 2024, - genus == "Crinia") |> - group_by(speciesID) |> - add_count() |> - distinct(.keep_all = TRUE) |> - collapse() - expect_equal(query$type, "data/species") - x <- quiet_collect(query) - expect_s3_class(x, - c("tbl_df", "tbl", "data.frame")) - expect_equal(length(unique(x$speciesID)), - nrow(x)) -}) +# FIXME: not implemented +# test_that("`add_count() |> distinct()` adds record counts to each species", { +# skip_if_offline(); skip_on_ci() +# query <- galah_call() |> +# filter(year == 2024, +# genus == "Crinia") |> +# group_by(speciesID) |> +# add_count() |> +# distinct(.keep_all = TRUE) |> +# collapse() +# expect_equal(query$type, "data/species") +# x <- quiet_collect(query) +# expect_s3_class(x, +# c("tbl_df", "tbl", "data.frame")) +# expect_equal(length(unique(x$speciesID)), +# nrow(x)) +# }) -test_that("add_count() without `distinct()` just adds a column of 1s", { - skip("not built") -}) +# test_that("add_count() without `distinct()` just adds a column of 1s", { +# skip("not built") +# }) rm(quiet_collect) \ No newline at end of file diff --git a/tests/testthat/test-international-Flanders.R b/tests/testthat/test-international-Flanders.R index 056ddbdf..cdba7536 100644 --- a/tests/testthat/test-international-Flanders.R +++ b/tests/testthat/test-international-Flanders.R @@ -1,5 +1,3 @@ -# Tests commented out until Flanders atlas is operational - # set verbose to off galah_config(verbose = FALSE, run_checks = FALSE) @@ -199,6 +197,8 @@ test_that("atlas_counts works with galah_identify for Flanders", { 0.1) # i.e. <1% margin of error }) +# FIXME test glimpse() + test_that("atlas_counts works with group_by for Flanders", { skip_if_offline(); skip_on_ci() result <- galah_call() |> diff --git a/tests/testthat/test-international-Kew.R b/tests/testthat/test-international-Kew.R index 915235a3..df8467a3 100644 --- a/tests/testthat/test-international-Kew.R +++ b/tests/testthat/test-international-Kew.R @@ -190,7 +190,7 @@ test_that("`atlas_species()` works for Kew", { try(silent = TRUE) skip_if(inherits(spp, "try-error"), message = "API not available") expect_gt(nrow(spp), 20) - expect_equal(ncol(spp), 10) # Note: some atlasses have 11 cols + expect_gte(ncol(spp), 10) expect_s3_class(spp, c("tbl_df", "tbl", "data.frame")) }) diff --git a/tests/testthat/test-international-Spain.R b/tests/testthat/test-international-Spain.R index db7af788..774b162b 100644 --- a/tests/testthat/test-international-Spain.R +++ b/tests/testthat/test-international-Spain.R @@ -222,20 +222,6 @@ test_that("atlas_counts works with group_by for Spain", { expect_equal(names(result), c("basisOfRecord", "count")) }) -test_that("atlas_counts works with apply_profile for Spain", { - skip_if_offline(); skip_on_ci() - without_profile <- galah_call() |> - count() |> - collect() - with_profile <- galah_call() |> - apply_profile(LA) |> - count() |> - collect() - expect_gt(with_profile$count, 0) - expect_equal(class(without_profile), class(with_profile)) - expect_lt(with_profile$count, without_profile$count) -}) - test_that("atlas_species works for Spain", { skip_if_offline(); skip_on_ci() galah_config( From 60adb6cee4d44cdfbfb2a576d63f09c6fa9aa9ea Mon Sep 17 00:00:00 2001 From: Dax Kellie <43838326+daxkellie@users.noreply.github.com> Date: Mon, 9 Feb 2026 15:19:54 +1100 Subject: [PATCH 84/94] make `galah_call()` workflow clearer in docs --- R/capture.R | 34 ++++++++++++++++------------------ R/compound.R | 22 +++++++++++++++++++--- R/dplyr-collapse.R | 26 ++++++++++++++++++++++---- R/dplyr-collect.R | 21 ++++++++++++++++++--- R/dplyr-compute.R | 36 ++++++++++++++++++++++++++++-------- 5 files changed, 103 insertions(+), 36 deletions(-) diff --git a/R/capture.R b/R/capture.R index fe65d116..47e636af 100644 --- a/R/capture.R +++ b/R/capture.R @@ -4,35 +4,33 @@ #' The first step in evaluating a request is to capture and parse the #' information it contains. The resulting object has class `prequery` #' for those requiring further processing or `query` for those that don't. -#' A `prequery` object shows what has been requested by a user in a given -#' `galah_call()`. +#' A `prequery` object shows the basic structure of what has been requested by +#' a user in a given [galah_call()]. #' +#' @name capture.data_request +#' @param x A `_request` object to convert to a `prequery`. +#' @param ... Other arguments, currently ignored #' @details #' Typically, queries in galah are piped using [galah_call()], which builds #' an object of class `"data_request"`; or [request_metadata()] or -#' [request_files()]. Under the hood, [galah_call()] constructs a query -#' ([collapse()]), sends it to an API ([compute()]), and returns its -#' contents ([collect()]). +#' [request_files()]. Under the hood, [galah_call()] consists of a series of +#' step-wise functions that run in order: #' -#' For [collapse()] to construct a complete query (class `"query"`), -#' it often requires additional, smaller API calls to validate pieces -#' of information used to construct the full user request. -#' Underlying [collapse()] are these -#' smaller but necessary steps: -#' * [capture()] parses the basic structure of a user request -#' * [compound()] combines all necessary API calls to construct the final query -#' * [collapse()] constructs the complete query -#' +#' [capture()] → [compound()] → +#' \code{\link[=collapse.data_request]{collapse()}} → +#' \code{\link[=compute.data_request]{compute()}} → +#' \code{\link[=collect.data_request]{collect()}} +#' +#' [capture()] is the first of the [galah_call()] workflow, and it parses the +#' basic structure of a user request, returned as a `prequery` object. #' A `prequery` object shows what has been requested, before those #' calls are built by [compound()] and evaluated by #' \code{\link[=collapse.data_request]{collapse()}}. #' For simple cases, this gives the same result as running #' \code{\link[=collapse.data_request]{collapse()}} while the `run_checks` #' argument of [galah_config()] is set to `FALSE`, but is slightly faster. -#' In complex cases, it is simply a precursor to [compound()] -#' @name capture.data_request -#' @param x A `_request` object to convert to a `prequery`. -#' @param ... Other arguments, currently ignored +#' In complex cases, it is simply a precursor to [compound()]. +#' #' @order 1 #' @return Either an object of class `prequery` when further processing is #' required; or `query` when it is not. Both classes are structurally identical, diff --git a/R/compound.R b/R/compound.R index 83eb9ced..179a2308 100644 --- a/R/compound.R +++ b/R/compound.R @@ -1,8 +1,10 @@ #' Force evaluation of a database query #' -#' [compound()] is an S3 generic function intended to be called before -#' [collapse()]. It is important as it shows the full set of queries -#' required to properly evaluate the user's request. This is often broader +#' @description +#' [compound()] shows the full set of queries +#' required to properly evaluate the user's request, run prior to [collapse()]. +#' +#' The number of total queries to send for a single data request is often broader #' than the single query returned by [collapse()]. If, for example, #' the user's query includes a call to #' \code{\link[=identify.data_request]{identify()}}, then a taxonomic query @@ -14,6 +16,20 @@ #' @param x An object to be compounded. Works for `data_request`, #' `metadata_request`, `file_request`, `query` or `prequery`. #' @param ... Other arguments passed to [capture()]. +#' @details +#' Typically, queries in galah are piped using [galah_call()], which builds +#' an object of class `"data_request"`; or [request_metadata()] or +#' [request_files()]. Under the hood, [galah_call()] consists of a series of +#' step-wise functions that run in order: +#' +#' [capture()] → [compound()] → +#' \code{\link[=collapse.data_request]{collapse()}} → +#' \code{\link[=compute.data_request]{compute()}} → +#' \code{\link[=collect.data_request]{collect()}} +#' +#' [compound()] is the second of the [galah_call()] workflow, and it collates +#' the complete list of queries required to send in order to meet the user's +#' data request, returned by \code{\link[=collapse.data_request]{collapse()}}. #' @order 1 #' @return An object of class `query_set`, which is simply a list of all `query` #' objects required to properly evaluate the specified request. Objects are diff --git a/R/dplyr-collapse.R b/R/dplyr-collapse.R index c0afe184..d05df58b 100644 --- a/R/dplyr-collapse.R +++ b/R/dplyr-collapse.R @@ -1,11 +1,12 @@ #' Generate a query #' -#' This function constructs a query so it can be inspected before being sent. It -#' is typically called at the end of a pipe begun with [galah_call()]. Objects +#' Constructs a query so it can be inspected before being sent. `collapse()` can +#' be called at the end of a pipe that begins with [galah_call()] to return the +#' constructed user query generated by the user's data request +#' (a `query` object). Objects #' of class `data_request` (created using [request_data()]), `metadata_request` #' (from [request_metadata()]) or `files_request` (from [request_files()]) are -#' all supported. Any of these objects can be created using [galah_call()] via -#' the `method` argument. +#' all supported. #' @name collapse.data_request #' @order 1 #' @param x An object to run `collapse()` on. Classes supported by `galah` @@ -13,6 +14,23 @@ #' queries; and `prequery`, `query` or `query_set` once constructed (via #' [capture()] or [compound()]). #' @param ... Arguments passed on to [capture()]. +#' @details +#' Typically, queries in galah are piped using [galah_call()], which builds +#' an object of class `"data_request"`; or [request_metadata()] or +#' [request_files()]. Under the hood, [galah_call()] consists of a series of +#' step-wise functions that run in order: +#' +#' [capture()] → [compound()] → +#' \code{\link[=collapse.data_request]{collapse()}} → +#' \code{\link[=compute.data_request]{compute()}} → +#' \code{\link[=collect.data_request]{collect()}} +#' +#' \code{\link[=collapse.data_request]{collapse()}} constructs a complete +#' user query, ready to be sent by +#' \code{\link[=compute.data_request]{compute()}}. +#' Information required to construct a complete user query are +#' provided by [capture()] and [compound()], preceding functions to +#' parse and combine all required API calls necessary to build a user's query. #' @return An object of class `query`, which is a list-like object containing #' two or more of the following slots: #' diff --git a/R/dplyr-collect.R b/R/dplyr-collect.R index 4aaea1ac..34653201 100644 --- a/R/dplyr-collect.R +++ b/R/dplyr-collect.R @@ -1,7 +1,8 @@ #' Retrieve a database query -#' -#' This function retrieves the specified query from the server. It is the -#' default way to end a piped query begun with [galah_call()]. +#' +#' @description +#' Retrieve the result of a query from the server. It is the +#' default way to end a piped query that begins with [galah_call()]. #' @name collect.data_request #' @order 1 #' @param x An object of class `data_request`, `metadata_request` or @@ -15,6 +16,20 @@ #' @param file (Optional) file name. If not given, will be set to `data` with #' date and time added. The file path (directory) is always given by #' `galah_config()$package$directory`. +#' @details +#' Typically, queries in galah are piped using [galah_call()], which builds +#' an object of class `"data_request"`; or [request_metadata()] or +#' [request_files()]. Under the hood, [galah_call()] consists of a series of +#' step-wise functions that run in order: +#' +#' [capture()] → [compound()] → +#' \code{\link[=collapse.data_request]{collapse()}} → +#' \code{\link[=compute.data_request]{compute()}} → +#' \code{\link[=collect.data_request]{collect()}} +#' +#' \code{\link[=collect.data_request]{collect()}} is the final step of the +#' [galah_call()] workflow, and it retrieves the result of a +#' query once it is processed by the server. #' @return In most cases, `collect()` returns a `tibble` containing requested #' data. Where the requested data are not yet ready (i.e. for occurrences when #' `wait` is set to `FALSE`), this function returns an object of class `query` diff --git a/R/dplyr-compute.R b/R/dplyr-compute.R index a672833d..f286177c 100644 --- a/R/dplyr-compute.R +++ b/R/dplyr-compute.R @@ -1,19 +1,39 @@ #' Compute a query #' -#' This function sends a request for information to a server. This is only -#' useful for processes that run a server-side process, as it separates the -#' submission of the request from its' retrieval. Within galah, this is used -#' exclusively for generating occurrence queries, where calling -#' \code{\link[=compute.data_request]{compute()}} and then passing -#' the resulting `query` object to \code{\link[=collect.data_request]{collect()}} -#' at a later time can be preferable to calling [atlas_occurrences()], which +#' @description +#' Sends a request for information to a server. This is useful +#' for requests that run a server-side process, as it separates the +#' submission of the request from its retrieval. +#' +#' Within galah, `compute()` is generally hidden as it is one part of the overall +#' process to complete a `data_request`, +#' `metadata_request` or `file_request`. However, calling +#' \code{\link[=compute.data_request]{compute()}} at the +#' end of a [galah_call()] sends a request to be completed server-side +#' (i.e., outside of R), and the result can be returned in R by +#' calling \code{\link[=collect.data_request]{collect()}} +#' at a later time. This can be preferable to calling [atlas_occurrences()], which #' prevents execution of new code until the server-side process is complete. #' @name compute.data_request #' @order 1 #' @param x An object of class `data_request`, `metadata_request` or #' `files_request` (i.e. constructed using a pipe) or `query` -#' (i.e. constructed by `collapse()`) +#' (i.e. constructed by \code{\link[=collapse.data_request]{collapse()}}) #' @param ... Arguments passed on to other methods +#' @details +#' Typically, queries in galah are piped using [galah_call()], which builds +#' an object of class `"data_request"`; or [request_metadata()] or +#' [request_files()]. Under the hood, [galah_call()] consists of a series of +#' step-wise functions that run in order: +#' +#' [capture()] → [compound()] → +#' \code{\link[=collapse.data_request]{collapse()}} → +#' \code{\link[=compute.data_request]{compute()}} → +#' \code{\link[=collect.data_request]{collect()}} +#' +#' \code{\link[=compute.data_request]{compute()}} sends a query to a server, +#' which, once completed, can be retrieved using +#' \code{\link[=collect.data_request]{collect()}}. #' @return An object of class `computed_query`, which is identical to class #' `query` except for occurrence data, where it also contains information on the #' status of the request. From 99f3bdcf4d5ee81e75a463b49e3f417963b30367 Mon Sep 17 00:00:00 2001 From: Dax Kellie <43838326+daxkellie@users.noreply.github.com> Date: Mon, 9 Feb 2026 15:31:27 +1100 Subject: [PATCH 85/94] Edit NEWS prior to release --- NEWS.md | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/NEWS.md b/NEWS.md index 7e7ee3a1..b9881bc1 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,31 +1,31 @@ # galah 2.2.0 ### Improved organisational support -* `filter()` now builds predicate queries natively when atlas is set to `GBIF`. Filter now uses an object-oriented workflow. -* DOIs now supported for `GBIF` -* Kew gardens and Flanders living atlases added -* authentication supported for ALA users within `galah_config()` +* `filter()` now builds predicate queries natively when atlas is set to `GBIF`. `filter()` now uses an object-oriented workflow. +* DOIs now supported for `GBIF`. +* Kew gardens and Flanders living atlases added (#256). +* Authentication supported for ALA users within `galah_config()` (#189). ### New & amended functions -* `dplyr::distinct()` can be used to find grouped data and summaries, generalising `atlas_species()` -* new functions `as_query()` and `compound()` as prequels to `collapse()` +* `dplyr::distinct()` can be used to find grouped data and summaries, generalising `atlas_species()` (#284). +* New functions `as_query()` and `compound()` as prequels to `collapse()` (#278). * `galah_call()` is now synonmous with `request_data()` rather than wrapping all `request_` functions; `method` argument is removed. ## Changes to metadata functions -* all metadata requests now accept `select()` -* metadata types that support `unnest()` now also support `filter()` when unnest is not supplied -* all `show_all()` and `search_all()` functions gain an `all_fields` argument -* metadata now supports list-columns where the API returns nested data -* metadata functions now return columns names in `snake_case` rather than `camelCase` -* all metadata functions support caching, and are affected by re-introduced `caching` argument in `galah_config()` (set to `TRUE` by default) -* media metadata now uses a different API to return more relevant information - -### Minor and internal changes, bug fixes -* Move to `testthat` 3rd edition for improved test functionality -* move to `{cli}` for `print()` calls, not `cat()` -* reduce usage of `@importFrom` in favour of `pkg::fun()` syntax, as per R style guide -* `basisOfRecord` now included as default field (i.e. with `select(group = "basic")`) (#281) -* `query` objects now have a `request` slot showing the request that generated them +* All metadata requests now accept `select()`. +* Metadata types that support `unnest()` now also support `filter()` when `unnest()` is not supplied. +* All `show_all()` and `search_all()` functions gain an `all_fields` argument. +* Metadata now supports list-columns where the API returns nested data. +* Metadata functions now return columns names in `snake_case` rather than `camelCase`. +* All metadata functions support caching, and are affected by re-introduced `caching` argument in `galah_config()` (set to `TRUE` by default). +* Media metadata now uses a different API to return more relevant information. + +### Minor improvements and bug fixes +* Move to `testthat` 3rd edition for improved test functionality. +* Move to `{cli}` for `print()` calls, not `cat()`. +* Reduce usage of `@importFrom` in favour of `pkg::fun()` syntax, as per R style guide. +* `basisOfRecord` now included as default field (i.e. with `select(group = "basic")`). (#281) +* `query` objects now have a `request` slot showing the request that generated them . # galah 2.1.2 From fbc48c1836274546782a2902ce880540619949cb Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Mon, 9 Feb 2026 15:51:00 +1100 Subject: [PATCH 86/94] Fix errors shown by GBIF tests Mainly fixes to predicate construction, large `group_by()` queries, and occurrence downloads --- R/build_predicates.R | 59 ++++++++------ R/collapse_checks.R | 2 + R/collapse_occurrences.R | 7 +- R/collapse_occurrences_count_gbif.R | 76 +++++++++++-------- R/filter_object_classes.R | 2 +- R/handle_request_objects.R | 23 +++++- .../test-international-GBIF-predicates.R | 16 ++-- tests/testthat/test-international-GBIF.R | 30 ++++++-- 8 files changed, 140 insertions(+), 75 deletions(-) diff --git a/R/build_predicates.R b/R/build_predicates.R index 9aef6536..6e240663 100644 --- a/R/build_predicates.R +++ b/R/build_predicates.R @@ -1,32 +1,35 @@ #' join all queries #' NOTE: There is a maximum of 101k entries in total. Should be possible to enforce that here +#' @param an object of class `query_set` #' @noRd #' @keywords Internal build_predicates <- function(x){ - - # handle newly supplied information - x_identify <- parse_predicates_identify(x$identify) - x_location <- parse_predicates_location(x$geolocate) + x$filter |> + join_predicates(parse_predicates_identify(x$identify)) |> + join_predicates(parse_predicates_location(x$geolocate)) - # for and queries, we extract everything, add new content, then rebuild - if(is_and_query(x)){ - filters_list <- c(x$filter$predicates, x_identify, x_location) |> - remove_nulls_from_list() +} + +#' Internal function to join predicates together +#' @param a predicate +#' @param ... two or more predicates to join to x +join_predicates <- function(x, y = NULL){ + + if(is.null(y)){ + x + }else{ + # for `and` queries, we extract everything, add new content, then rebuild + + if(is_and_query(x)){ + x <- x$predicates + } + + # add content together + filters_list <- c(check_predicate_wrapping(x), + check_predicate_wrapping(y)) names(filters_list) <- NULL list(type = "and", predicates = filters_list) - }else{ - # if we have been given further information, use AND - if(!is.null(x_identify) | !is.null(x_location)){ - filters_list <- c(x$filter, x_identify, x_location) |> # note: not x$filter$predicates - remove_nulls_from_list() - names(filters_list) <- NULL - list(type = "and", - predicates = filters_list) - # otherwise we can pass what we were given (usually an OR statement) - }else{ - x$filter - } } } @@ -34,8 +37,8 @@ build_predicates <- function(x){ #' @noRd #' @keywords Internal is_and_query <- function(x){ - if(purrr::pluck_exists(x, "filter", "type")){ - if(purrr::pluck(x, "filter", "type") == "and"){ + if(purrr::pluck_exists(x, "type")){ + if(purrr::pluck(x, "type") == "and"){ TRUE }else{ FALSE @@ -45,6 +48,18 @@ is_and_query <- function(x){ } } +#' simple check to ensure lists are joined correctly +#' @noRd +#' @keywords Internal +check_predicate_wrapping <- function(x){ + if(length(x) > 2 & + any(names(x) %in% c("in", "key", "value"))){ + list(x) + }else{ + x + } +} + #' handle taxonomic queries #' @noRd #' @keywords Internal diff --git a/R/collapse_checks.R b/R/collapse_checks.R index cfab3973..89452074 100644 --- a/R/collapse_checks.R +++ b/R/collapse_checks.R @@ -51,11 +51,13 @@ collapse_run_checks <- function(.query, if(.query$type %in% c("data/occurrences", "data/species")){ .query <- check_login(.query, error_call) } + # check_select() is specifically for parsing fields into urls, # should only be called for occurrences if(.query$type %in% c("data/occurrences", "data/occurrences-glimpse")){ .query <- check_select(.query, error_call) } + # NOTE: the naming convention here is misleading; should probably be `parse_select()` # after checking, for type = "glimpse", we need to rename the fields query if(.query$type == "data/occurrences-glimpse"){ diff --git a/R/collapse_occurrences.R b/R/collapse_occurrences.R index e51b7492..5a6b6b72 100644 --- a/R/collapse_occurrences.R +++ b/R/collapse_occurrences.R @@ -5,12 +5,11 @@ collapse_occurrences <- function(x){ if(any(names(x) == "body")){ result <- list( creator = potions::pour("user", "username", .pkg = "galah"), - notificationAddresses = potions::pour("user", "email", .pkg = "galah"), + notificationAddresses = list(potions::pour("user", "email", .pkg = "galah")), sendNotification = potions::pour("package", "send_email", .pkg = "galah"), - format = x$format, + format = x$body$format, predicate = build_predicates(x$body)) |> - jsonlite::toJSON(auto_unbox = TRUE, - pretty = TRUE) + jsonlite::toJSON(auto_unbox = TRUE, pretty = TRUE) x$body <- result x } diff --git a/R/collapse_occurrences_count_gbif.R b/R/collapse_occurrences_count_gbif.R index f2382e41..e0561fdf 100644 --- a/R/collapse_occurrences_count_gbif.R +++ b/R/collapse_occurrences_count_gbif.R @@ -34,50 +34,60 @@ collapse_occurrences_count_gbif_groupby_crossed <- function(x){ # get a 'basic' query, showing facets for each variable separately facets <- collapse_occurrences_count_gbif_groupby_basic(x) |> - collect() - - # then order queries by decreasing number of levels - facet_order <- facets |> - select(-"count") |> - purrr::map(.f = \(a){length(which(!is.na(a)))}) |> - unlist() |> - sort() + collect() |> + dplyr::relocate(x$request$group_by$name) # place in supplied order # build filters for our next round of queries # for 3 facets, we need to expand our crossed variables - if(length(facet_order) > 2){ + if(ncol(facets) > 3){ + filters <- facets |> - select(!!!names(facet_order[1:2])) |> + select(!!!names(facets[1:2])) |> purrr::map(.f = \(a){a[!is.na(a)]}) |> - expand.grid() - facet <- names(facet_order)[3] + expand.grid(stringsAsFactors = FALSE) + facet <- colnames(facets)[3] + + z <- x$request + z$slice_arrange <- NULL # this is a hack to avoid messy object update code + filter_1 <- colnames(filters)[1] + filter_2 <- colnames(filters)[2] + + body_list <- purrr::map( + split(filters, seq_len(nrow(filters))), + \(a){ + value_1 <- a[[1]][[1]] + value_2 <- a[[2]][[1]] + result <- z |> + filter({{filter_1}} == {{value_1}}, + {{filter_2}} == {{value_2}}) |> + group_by(facet) |> + collapse() + result$body + }) + + # for 2 facets, we just select the levels we need }else{ - variable <- names(facet_order)[1] filters <- facets |> - select(!!!(names(facet_order)[1])) |> + select(!!!(colnames(facets)[1])) |> tidyr::drop_na() - facet <- names(facet_order)[2] + facet <- colnames(facets)[2] + # extract a query that we can update with new parameters + z <- x$request + z$slice_arrange <- NULL # this is a hack to avoid messy object update code + filter_name <- colnames(filters)[1] + # create multiple new queries from the old one + # this works because new calls to `filter()` *adds* to a query + # while new calls to `group_by()` *replaces* old entries + body_list <- purrr::map(filters[[1]], \(a){ + result <- z |> + filter({{filter_name}} == {{a}}) |> + group_by(facet) |> + collapse() + result$body + }) } - # convert our tibble of new filters into predicate entries - predicate_list <- tibble_to_predicate(filters) - - # create separate `body` (JSON) files for each query, accounting for `filter` - # and `facet` - body_list <- purrr::map(predicate_list, .f = \(a){ - temp_obj <- x$body - temp_obj$filter <- c(temp_obj$filter, a) - temp_obj$group_by <- NULL - list(predicate = build_predicates(temp_obj), - limit = 0, - facets = tibble::tibble(name = facet) |> - parse_predicates_groupby()) |> - remove_nulls_from_list() |> - jsonlite::toJSON(auto_unbox = TRUE, - pretty = TRUE) - }) - # add predicates to tibble; tibble to `body`; return filters$predicate <- body_list x$body <- filters diff --git a/R/filter_object_classes.R b/R/filter_object_classes.R index 97d23932..e7268762 100644 --- a/R/filter_object_classes.R +++ b/R/filter_object_classes.R @@ -59,7 +59,7 @@ print.data_filter <- function(x, ...){ print.predicates_filter <- function(x, ...){ # object of class `predicates_filter` predicates_string <- glue::glue_collapse(unlist(x), sep = " ") - glue::glue("Object of class `predictes_filter`: {predicates_string}") |> + glue::glue("Object of class `predicates_filter`: {predicates_string}") |> cat() } diff --git a/R/handle_request_objects.R b/R/handle_request_objects.R index b5b1e556..202a62d1 100644 --- a/R/handle_request_objects.R +++ b/R/handle_request_objects.R @@ -64,9 +64,9 @@ update_request_object <- function(x, "search_term") }, "filter" = { - bind_unique_rows(x[[dot_names]], - dots[[dot_names]], - "query") + update_filter(x[[dot_names]], + dots[[dot_names]], + "query") }, "select" = { update_select(x[[dot_names]], @@ -104,6 +104,23 @@ update_select <- function(x, y){ add_group(group = group_vec) } +## FIXME: do we need `update_identify()` to support GBIF, +## using same logic as `update_filter()`? + +#' Internal function to join together two `filter` objects +update_filter <- function(x, y, column){ + if(!all(class(x) == class(y))){ + cli::cli_abort("Cannot join unlike filter objects") + } + # first join predicates + if(inherits(x, "predicates_filter") ){ + join_predicates(x, y) + }else{ # then tibbles + bind_unique_rows(x, y, column) + } +} + + #' Internal function to join tibbles by row #' @noRd #' @keywords Internal diff --git a/tests/testthat/test-international-GBIF-predicates.R b/tests/testthat/test-international-GBIF-predicates.R index 9091a638..5e782243 100644 --- a/tests/testthat/test-international-GBIF-predicates.R +++ b/tests/testthat/test-international-GBIF-predicates.R @@ -81,7 +81,7 @@ test_that("`count()` errors when real but non-indexed fields are requested", { filter(something == 9) |> count() |> collapse() |> - expect_error(label = "Can't use fields that don't exist") + expect_error() # real, but not indexed, group_by statement request_data() |> @@ -89,7 +89,7 @@ test_that("`count()` errors when real but non-indexed fields are requested", { group_by(order) |> count() |> collapse() |> - expect_error(label = "Can't use fields that don't exist") + expect_error() }) test_that("`count()` works with `identify()` for GBIF", { @@ -265,11 +265,13 @@ test_that("filter() handles c() for GBIF", { test_that("`count()` works with `galah_polygon()` for GBIF", { skip_if_offline(); skip_on_ci() # errors when points given clockwise - wkt <- "POLYGON((142.36 -29.01,142.74 -29.01,142.74 -29.39,142.36 -29.39,142.36 -29.01))" - expect_error({galah_call() |> - galah_polygon(wkt) |> - count() |> - collect()}) + # FIXME: This has been disableed at some point + # `st_sfc()` has a `check_ring_dir` argument that might help + # wkt <- "POLYGON((142.36 -29.01,142.74 -29.01,142.74 -29.39,142.36 -29.39,142.36 -29.01))" + # expect_error({galah_call() |> + # galah_polygon(wkt) |> + # count() |> + # collect()}) # works when points given counter-clockwise wkt <- "POLYGON((142.36 -29.01,142.36 -29.39,142.74 -29.39,142.74 -29.01,142.36 -29.01))" result <- galah_call() |> diff --git a/tests/testthat/test-international-GBIF.R b/tests/testthat/test-international-GBIF.R index 2edab10f..19d24f6b 100644 --- a/tests/testthat/test-international-GBIF.R +++ b/tests/testthat/test-international-GBIF.R @@ -187,7 +187,7 @@ test_that("`count` works with 2 `group_by` args for GBIF", { count() |> collapse() expect_s3_class(x, "query") - expect_equal(length(x), 6) + # expect_equal(length(x), 6) # for some reason this fails on Positron?!?!?!! expect_contains(names(x), c("type", "url", "headers", "request")) # "options", "body" ? expect_equal(x$type, "data/occurrences-count-groupby") @@ -198,7 +198,13 @@ test_that("`count` works with 2 `group_by` args for GBIF", { z <- collect(y) expect_s3_class(z, c("tbl_df", "tbl", "data.frame")) expect_gt(nrow(z), 1) - expect_equal(names(z), c("year", "count")) + expect_equal(names(z), c("year", "basisOfRecord", "count")) + # in early versions, iterating by a variable wasn't working properly + # check that same level of second variable differs across first variable + z_counts <- z |> + dplyr::filter(basisOfRecord == "HUMAN_OBSERVATION") |> + dplyr::pull(count) + expect_true(max(z_counts) - min(z_counts) > 0) }) ## group_by fails when an invalid field is given @@ -240,6 +246,20 @@ test_that("`count()` works with `identify` for GBIF when `run_checks` = TRUE", { }) ## TODO: Add a more basic occurrences check +test_that("`atlas_occurrences()` works for GBIF", { + galah_config(atlas = "GBIF", + username = "atlasoflivingaustralia", + email = "ala4r@ala.org.au", + password = "galah-gbif-test-login") + x <- galah_call() |> + filter(year == 1890, + classKey == "359", + country == "AU") |> + collect() + expect_s3_class(x, c("tbl_df", "tbl", "data.frame")) + expect_gt(nrow(x), 10) + expect_gt(ncol(x), 10) +}) test_that("`atlas_occurrences()` works with `galah_polygon()` for GBIF", { skip_if_offline(); skip_on_ci() @@ -285,9 +305,9 @@ test_that("atlas_species works for GBIF", { identify("Litoria") |> collapse() expect_s3_class(x, "query") - expect_equal(length(x), 5) + expect_equal(length(x), 6) expect_equal(names(x), - c("type", "url", "headers", "options", "body")) + c("type", "url", "headers", "options", "body", "request")) expect_equal(x$type, "data/species") y <- compute(x) expect_s3_class(y, "computed_query") @@ -324,7 +344,7 @@ test_that("`collapse()` et al. work for GBIF with `type = 'occurrences'`", { # NOTE: the above query should return 72 records (tested 2025-06-10) expect_s3_class(x, "query") expect_equal(names(x), - c("type", "url", "headers", "options", "body")) + c("type", "url", "headers", "options", "body", "request")) expect_equal(x$type, "data/occurrences") # compute y <- compute(x) From 06d8be4277d82e8518ad2a0752b3c89eb4e7d872 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Mon, 9 Feb 2026 16:24:20 +1100 Subject: [PATCH 87/94] Fix bugs identified in `check()` --- NAMESPACE | 1 + R/build_predicates.R | 2 ++ R/check.R | 2 +- R/handle_request_objects.R | 4 +++- R/onload.R | 2 +- R/print.R | 2 ++ 6 files changed, 10 insertions(+), 3 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 3b579a90..a24ad62f 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -137,6 +137,7 @@ export(show_values) export(slice_head) export(st_crop) export(unnest) +importFrom(crayon,make_style) importFrom(dplyr,add_count) importFrom(dplyr,arrange) importFrom(dplyr,collapse) diff --git a/R/build_predicates.R b/R/build_predicates.R index 6e240663..b7e44f02 100644 --- a/R/build_predicates.R +++ b/R/build_predicates.R @@ -13,6 +13,8 @@ build_predicates <- function(x){ #' Internal function to join predicates together #' @param a predicate #' @param ... two or more predicates to join to x +#' @noRd +#' @keywords Internal join_predicates <- function(x, y = NULL){ if(is.null(y)){ diff --git a/R/check.R b/R/check.R index ff39b7d1..1625bf1f 100644 --- a/R/check.R +++ b/R/check.R @@ -303,7 +303,7 @@ check_fields_gbif_counts <- function(.query){ # check for invalid facets valid_search_fields <- .query[["metadata/fields"]] |> dplyr::filter(.data$search_field == TRUE) |> - dplyr::pull(id) + dplyr::pull("id") if (!all(facets %in% valid_search_fields)) { invalid_facets <- facets[!(facets %in% valid_search_fields)] group_by_invalid <- glue::glue_collapse(invalid_facets, sep = ", ") diff --git a/R/handle_request_objects.R b/R/handle_request_objects.R index 202a62d1..c2b6fb4d 100644 --- a/R/handle_request_objects.R +++ b/R/handle_request_objects.R @@ -100,7 +100,7 @@ update_select <- function(x, y){ group_vec <- NULL } result |> - generate_summary(dots) |> + generate_summary() |> add_group(group = group_vec) } @@ -108,6 +108,8 @@ update_select <- function(x, y){ ## using same logic as `update_filter()`? #' Internal function to join together two `filter` objects +#' @noRd +#' @keywords Internal update_filter <- function(x, y, column){ if(!all(class(x) == class(y))){ cli::cli_abort("Cannot join unlike filter objects") diff --git a/R/onload.R b/R/onload.R index ec501895..caa431f5 100644 --- a/R/onload.R +++ b/R/onload.R @@ -1,7 +1,7 @@ #' Set-up for galah during loading #' @noRd #' @keywords Internal -.onLoad <- function(libname, pkgname) { +.onAttach <- function(libname, pkgname) { if (pkgname == "galah") { # set up storage of standard information via {potions} diff --git a/R/print.R b/R/print.R index 38306524..baeb48ec 100644 --- a/R/print.R +++ b/R/print.R @@ -334,6 +334,8 @@ hide_secrets <- function(string){ } #' Pink for printing primary text (e.g. object names) to the console +#' Note: `@importFrom` here added to avoid CRAN checks flagging crayon as not required +#' @importFrom crayon make_style #' @noRd #' @keywords Internal galah_pink <- crayon::make_style("#bf2a6d") From 355104adeedec43557db043fcacd3a6fc2d5439a Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Mon, 9 Feb 2026 17:04:00 +1100 Subject: [PATCH 88/94] Minor updates to help files and QSG --- DESCRIPTION | 2 +- NEWS.md | 6 +- R/capture.R | 10 ++- R/compound.R | 10 ++- R/dplyr-collapse.R | 12 +-- R/dplyr-collect.R | 10 ++- R/dplyr-compute.R | 10 ++- R/galah_call.R | 1 + man/capture.data_request.Rd | 24 +++-- man/collapse.data_request.Rd | 31 +++++-- man/collect.data_request.Rd | 21 ++++- man/compound.Rd | 24 ++++- man/compute.data_request.Rd | 38 ++++++-- man/galah_config.Rd | 14 +-- vignettes/quick_start_guide.Rmd | 126 ++++++++++++++++++--------- vignettes/quick_start_guide.Rmd.orig | 48 +++++----- 16 files changed, 269 insertions(+), 118 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 7b125a6d..b8a98704 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Package: galah Type: Package Title: Biodiversity Data from the GBIF Node Network -Version: 2.2.0.9999 +Version: 2.2.0 Authors@R: c(person(given = "Martin", family = "Westgate", diff --git a/NEWS.md b/NEWS.md index b9881bc1..bc4a1a48 100644 --- a/NEWS.md +++ b/NEWS.md @@ -4,16 +4,16 @@ * `filter()` now builds predicate queries natively when atlas is set to `GBIF`. `filter()` now uses an object-oriented workflow. * DOIs now supported for `GBIF`. * Kew gardens and Flanders living atlases added (#256). -* Authentication supported for ALA users within `galah_config()` (#189). +* Authentication supported for ALA users (#189). ### New & amended functions * `dplyr::distinct()` can be used to find grouped data and summaries, generalising `atlas_species()` (#284). -* New functions `as_query()` and `compound()` as prequels to `collapse()` (#278). +* New functions `capture()` and `compound()` as prequels to `collapse()` (#278). * `galah_call()` is now synonmous with `request_data()` rather than wrapping all `request_` functions; `method` argument is removed. ## Changes to metadata functions * All metadata requests now accept `select()`. -* Metadata types that support `unnest()` now also support `filter()` when `unnest()` is not supplied. +* Metadata types that support `unnest()` now also support `filter()`, even when `unnest()` is not supplied. * All `show_all()` and `search_all()` functions gain an `all_fields` argument. * Metadata now supports list-columns where the API returns nested data. * Metadata functions now return columns names in `snake_case` rather than `camelCase`. diff --git a/R/capture.R b/R/capture.R index 3ebc9385..f70a0f90 100644 --- a/R/capture.R +++ b/R/capture.R @@ -11,10 +11,12 @@ #' @param x A `_request` object to convert to a `prequery`. #' @param ... Other arguments, currently ignored #' @details -#' Typically, queries in galah are piped using [galah_call()], which builds -#' an object of class `"data_request"`; or [request_metadata()] or -#' [request_files()]. Under the hood, [galah_call()] consists of a series of -#' step-wise functions that run in order: +#' `galah` uses an object-based pipeline to convert piped requests into +#' valid queries, and to enact those queries with the specified organisation. +#' Typically, requests open with [galah_call()] - though [request_metadata()] +#' and [request_files()] are also valid - and end with +#' \code{\link[=collect.data_request]{collect()}}. Under the hood, +#' the sequence of functions is as follows: #' #' [capture()] → [compound()] → #' \code{\link[=collapse.data_request]{collapse()}} → diff --git a/R/compound.R b/R/compound.R index 179a2308..e89b6d69 100644 --- a/R/compound.R +++ b/R/compound.R @@ -17,10 +17,12 @@ #' `metadata_request`, `file_request`, `query` or `prequery`. #' @param ... Other arguments passed to [capture()]. #' @details -#' Typically, queries in galah are piped using [galah_call()], which builds -#' an object of class `"data_request"`; or [request_metadata()] or -#' [request_files()]. Under the hood, [galah_call()] consists of a series of -#' step-wise functions that run in order: +#' `galah` uses an object-based pipeline to convert piped requests into +#' valid queries, and to enact those queries with the specified organisation. +#' Typically, requests open with [galah_call()] - though [request_metadata()] +#' and [request_files()] are also valid - and end with +#' \code{\link[=collect.data_request]{collect()}}. Under the hood, +#' the sequence of functions is as follows: #' #' [capture()] → [compound()] → #' \code{\link[=collapse.data_request]{collapse()}} → diff --git a/R/dplyr-collapse.R b/R/dplyr-collapse.R index d05df58b..6dc2e8ba 100644 --- a/R/dplyr-collapse.R +++ b/R/dplyr-collapse.R @@ -15,10 +15,12 @@ #' [capture()] or [compound()]). #' @param ... Arguments passed on to [capture()]. #' @details -#' Typically, queries in galah are piped using [galah_call()], which builds -#' an object of class `"data_request"`; or [request_metadata()] or -#' [request_files()]. Under the hood, [galah_call()] consists of a series of -#' step-wise functions that run in order: +#' `galah` uses an object-based pipeline to convert piped requests into +#' valid queries, and to enact those queries with the specified organisation. +#' Typically, requests open with [galah_call()] - though [request_metadata()] +#' and [request_files()] are also valid - and end with +#' \code{\link[=collect.data_request]{collect()}}. Under the hood, +#' the sequence of functions is as follows: #' #' [capture()] → [compound()] → #' \code{\link[=collapse.data_request]{collapse()}} → @@ -41,7 +43,7 @@ #' - `headers`: headers to be sent with the API call #' - `body`: body section of the API call #' - `options`: options section of the API call -#' - Any other information retained from the preceeding `_request` object (see [capture()]) +#' - `request`: captures the supplied `_request` object (see [galah_call()]) #' #' @seealso To open a piped query, see [galah_call()]. For alternative #' operations on `_request` objects, see [capture()], [compound()], diff --git a/R/dplyr-collect.R b/R/dplyr-collect.R index 44c35f35..170bfc42 100644 --- a/R/dplyr-collect.R +++ b/R/dplyr-collect.R @@ -17,10 +17,12 @@ #' date and time added. The file path (directory) is always given by #' `galah_config()$package$directory`. #' @details -#' Typically, queries in galah are piped using [galah_call()], which builds -#' an object of class `"data_request"`; or [request_metadata()] or -#' [request_files()]. Under the hood, [galah_call()] consists of a series of -#' step-wise functions that run in order: +#' `galah` uses an object-based pipeline to convert piped requests into +#' valid queries, and to enact those queries with the specified organisation. +#' Typically, requests open with [galah_call()] - though [request_metadata()] +#' and [request_files()] are also valid - and end with +#' \code{\link[=collect.data_request]{collect()}}. Under the hood, +#' the sequence of functions is as follows: #' #' [capture()] → [compound()] → #' \code{\link[=collapse.data_request]{collapse()}} → diff --git a/R/dplyr-compute.R b/R/dplyr-compute.R index f286177c..3a9b285e 100644 --- a/R/dplyr-compute.R +++ b/R/dplyr-compute.R @@ -21,10 +21,12 @@ #' (i.e. constructed by \code{\link[=collapse.data_request]{collapse()}}) #' @param ... Arguments passed on to other methods #' @details -#' Typically, queries in galah are piped using [galah_call()], which builds -#' an object of class `"data_request"`; or [request_metadata()] or -#' [request_files()]. Under the hood, [galah_call()] consists of a series of -#' step-wise functions that run in order: +#' `galah` uses an object-based pipeline to convert piped requests into +#' valid queries, and to enact those queries with the specified organisation. +#' Typically, requests open with [galah_call()] - though [request_metadata()] +#' and [request_files()] are also valid - and end with +#' \code{\link[=collect.data_request]{collect()}}. Under the hood, +#' the sequence of functions is as follows: #' #' [capture()] → [compound()] → #' \code{\link[=collapse.data_request]{collapse()}} → diff --git a/R/galah_call.R b/R/galah_call.R index 0ddc0b3b..262afcdc 100644 --- a/R/galah_call.R +++ b/R/galah_call.R @@ -34,6 +34,7 @@ #' \code{\link[=count.data_request]{count()}}, #' \code{\link[=distinct.data_request]{distinct()}}, #' \code{\link[=filter.data_request]{filter()}}, +#' \code{\link[=glimpse.data_request]{glimpse()}}, #' \code{\link[=group_by.data_request]{group_by()}}, #' \code{\link[=identify.data_request]{identify()}}, #' \code{\link[=select.data_request]{select}}, diff --git a/man/capture.data_request.Rd b/man/capture.data_request.Rd index 7514d2e8..db7e2247 100644 --- a/man/capture.data_request.Rd +++ b/man/capture.data_request.Rd @@ -47,21 +47,31 @@ being list-like and containing at the following slots: The first step in evaluating a request is to capture and parse the information it contains. The resulting object has class \code{prequery} for those requiring further processing or \code{query} for those that don't. +A \code{prequery} object shows the basic structure of what has been requested by +a user in a given \code{\link[=galah_call]{galah_call()}}. } \details{ -Typically, queries in galah are piped using \code{\link[=galah_call]{galah_call()}}, which builds -an object of class \code{"data_request"}; or \code{\link[=request_metadata]{request_metadata()}} or -\code{\link[=request_files]{request_files()}}. All these objects can be converted to class \code{"query"} -using \code{\link[=collapse.data_request]{collapse()}}. However, -properly evaluating a query often requires building and running -additional queries to populate or validate the requested information. +\code{galah} uses an object-based pipeline to convert piped requests into +valid queries, and to enact those queries with the specified organisation. +Typically, requests open with \code{\link[=galah_call]{galah_call()}} - though \code{\link[=request_metadata]{request_metadata()}} +and \code{\link[=request_files]{request_files()}} are also valid - and end with +\code{\link[=collect.data_request]{collect()}}. Under the hood, +the sequence of functions is as follows: + +\code{\link[=capture]{capture()}} → \code{\link[=compound]{compound()}} → +\code{\link[=collapse.data_request]{collapse()}} → +\code{\link[=compute.data_request]{compute()}} → +\code{\link[=collect.data_request]{collect()}} + +\code{\link[=capture]{capture()}} is the first of the \code{\link[=galah_call]{galah_call()}} workflow, and it parses the +basic structure of a user request, returned as a \code{prequery} object. A \code{prequery} object shows what has been requested, before those calls are built by \code{\link[=compound]{compound()}} and evaluated by \code{\link[=collapse.data_request]{collapse()}}. For simple cases, this gives the same result as running \code{\link[=collapse.data_request]{collapse()}} while the \code{run_checks} argument of \code{\link[=galah_config]{galah_config()}} is set to \code{FALSE}, but is slightly faster. -In complex cases, it is simply a precursor to \code{\link[=compound]{compound()}} +In complex cases, it is simply a precursor to \code{\link[=compound]{compound()}}. } \seealso{ To open a piped query, see \code{\link[=galah_call]{galah_call()}}. For alternative diff --git a/man/collapse.data_request.Rd b/man/collapse.data_request.Rd index faa05468..018842f3 100644 --- a/man/collapse.data_request.Rd +++ b/man/collapse.data_request.Rd @@ -42,16 +42,37 @@ two or more of the following slots: \item \code{headers}: headers to be sent with the API call \item \code{body}: body section of the API call \item \code{options}: options section of the API call -\item Any other information retained from the preceeding \verb{_request} object (see \code{\link[=capture]{capture()}}) +\item \code{request}: captures the supplied \verb{_request} object (see \code{\link[=galah_call]{galah_call()}}) } } \description{ -This function constructs a query so it can be inspected before being sent. It -is typically called at the end of a pipe begun with \code{\link[=galah_call]{galah_call()}}. Objects +Constructs a query so it can be inspected before being sent. \code{collapse()} can +be called at the end of a pipe that begins with \code{\link[=galah_call]{galah_call()}} to return the +constructed user query generated by the user's data request +(a \code{query} object). Objects of class \code{data_request} (created using \code{\link[=request_data]{request_data()}}), \code{metadata_request} (from \code{\link[=request_metadata]{request_metadata()}}) or \code{files_request} (from \code{\link[=request_files]{request_files()}}) are -all supported. Any of these objects can be created using \code{\link[=galah_call]{galah_call()}} via -the \code{method} argument. +all supported. +} +\details{ +\code{galah} uses an object-based pipeline to convert piped requests into +valid queries, and to enact those queries with the specified organisation. +Typically, requests open with \code{\link[=galah_call]{galah_call()}} - though \code{\link[=request_metadata]{request_metadata()}} +and \code{\link[=request_files]{request_files()}} are also valid - and end with +\code{\link[=collect.data_request]{collect()}}. Under the hood, +the sequence of functions is as follows: + +\code{\link[=capture]{capture()}} → \code{\link[=compound]{compound()}} → +\code{\link[=collapse.data_request]{collapse()}} → +\code{\link[=compute.data_request]{compute()}} → +\code{\link[=collect.data_request]{collect()}} + +\code{\link[=collapse.data_request]{collapse()}} constructs a complete +user query, ready to be sent by +\code{\link[=compute.data_request]{compute()}}. +Information required to construct a complete user query are +provided by \code{\link[=capture]{capture()}} and \code{\link[=compound]{compound()}}, preceding functions to +parse and combine all required API calls necessary to build a user's query. } \seealso{ To open a piped query, see \code{\link[=galah_call]{galah_call()}}. For alternative diff --git a/man/collect.data_request.Rd b/man/collect.data_request.Rd index 4153b2a9..6ee2ad06 100644 --- a/man/collect.data_request.Rd +++ b/man/collect.data_request.Rd @@ -47,8 +47,25 @@ data. Where the requested data are not yet ready (i.e. for occurrences when that can be used to recheck the download at a later time. } \description{ -This function retrieves the specified query from the server. It is the -default way to end a piped query begun with \code{\link[=galah_call]{galah_call()}}. +Retrieve the result of a query from the server. It is the +default way to end a piped query that begins with \code{\link[=galah_call]{galah_call()}}. +} +\details{ +\code{galah} uses an object-based pipeline to convert piped requests into +valid queries, and to enact those queries with the specified organisation. +Typically, requests open with \code{\link[=galah_call]{galah_call()}} - though \code{\link[=request_metadata]{request_metadata()}} +and \code{\link[=request_files]{request_files()}} are also valid - and end with +\code{\link[=collect.data_request]{collect()}}. Under the hood, +the sequence of functions is as follows: + +\code{\link[=capture]{capture()}} → \code{\link[=compound]{compound()}} → +\code{\link[=collapse.data_request]{collapse()}} → +\code{\link[=compute.data_request]{compute()}} → +\code{\link[=collect.data_request]{collect()}} + +\code{\link[=collect.data_request]{collect()}} is the final step of the +\code{\link[=galah_call]{galah_call()}} workflow, and it retrieves the result of a +query once it is processed by the server. } \seealso{ To open a piped query, see \code{\link[=galah_call]{galah_call()}}. For alternative diff --git a/man/compound.Rd b/man/compound.Rd index dab2b85d..3b23dc04 100644 --- a/man/compound.Rd +++ b/man/compound.Rd @@ -40,9 +40,10 @@ listed in the order in which they will be evaluated, meaning the query that the user has actually requested will be placed last. } \description{ -\code{\link[=compound]{compound()}} is an S3 generic function intended to be called before -\code{\link[=collapse]{collapse()}}. It is important as it shows the full set of queries -required to properly evaluate the user's request. This is often broader +\code{\link[=compound]{compound()}} shows the full set of queries +required to properly evaluate the user's request, run prior to \code{\link[=collapse]{collapse()}}. + +The number of total queries to send for a single data request is often broader than the single query returned by \code{\link[=collapse]{collapse()}}. If, for example, the user's query includes a call to \code{\link[=identify.data_request]{identify()}}, then a taxonomic query @@ -51,6 +52,23 @@ other functions that manipulate \verb{_request} objects, \code{\link[=compound]{ within \code{\link[=collapse.data_request]{collapse()}}, and itself calls \code{\link[=capture]{capture()}} internally where required. } +\details{ +\code{galah} uses an object-based pipeline to convert piped requests into +valid queries, and to enact those queries with the specified organisation. +Typically, requests open with \code{\link[=galah_call]{galah_call()}} - though \code{\link[=request_metadata]{request_metadata()}} +and \code{\link[=request_files]{request_files()}} are also valid - and end with +\code{\link[=collect.data_request]{collect()}}. Under the hood, +the sequence of functions is as follows: + +\code{\link[=capture]{capture()}} → \code{\link[=compound]{compound()}} → +\code{\link[=collapse.data_request]{collapse()}} → +\code{\link[=compute.data_request]{compute()}} → +\code{\link[=collect.data_request]{collect()}} + +\code{\link[=compound]{compound()}} is the second of the \code{\link[=galah_call]{galah_call()}} workflow, and it collates +the complete list of queries required to send in order to meet the user's +data request, returned by \code{\link[=collapse.data_request]{collapse()}}. +} \seealso{ To open a piped query, see \code{\link[=galah_call]{galah_call()}}. For alternative operations on \verb{_request} objects, see \code{\link[=capture]{capture()}}, diff --git a/man/compute.data_request.Rd b/man/compute.data_request.Rd index 5f291332..196677f2 100644 --- a/man/compute.data_request.Rd +++ b/man/compute.data_request.Rd @@ -24,7 +24,7 @@ \arguments{ \item{x}{An object of class \code{data_request}, \code{metadata_request} or \code{files_request} (i.e. constructed using a pipe) or \code{query} -(i.e. constructed by \code{collapse()})} +(i.e. constructed by \code{\link[=collapse.data_request]{collapse()}})} \item{...}{Arguments passed on to other methods} } @@ -34,15 +34,37 @@ An object of class \code{computed_query}, which is identical to class status of the request. } \description{ -This function sends a request for information to a server. This is only -useful for processes that run a server-side process, as it separates the -submission of the request from its' retrieval. Within galah, this is used -exclusively for generating occurrence queries, where calling -\code{\link[=compute.data_request]{compute()}} and then passing -the resulting \code{query} object to \code{\link[=collect.data_request]{collect()}} -at a later time can be preferable to calling \code{\link[=atlas_occurrences]{atlas_occurrences()}}, which +Sends a request for information to a server. This is useful +for requests that run a server-side process, as it separates the +submission of the request from its retrieval. + +Within galah, \code{compute()} is generally hidden as it is one part of the overall +process to complete a \code{data_request}, +\code{metadata_request} or \code{file_request}. However, calling +\code{\link[=compute.data_request]{compute()}} at the +end of a \code{\link[=galah_call]{galah_call()}} sends a request to be completed server-side +(i.e., outside of R), and the result can be returned in R by +calling \code{\link[=collect.data_request]{collect()}} +at a later time. This can be preferable to calling \code{\link[=atlas_occurrences]{atlas_occurrences()}}, which prevents execution of new code until the server-side process is complete. } +\details{ +\code{galah} uses an object-based pipeline to convert piped requests into +valid queries, and to enact those queries with the specified organisation. +Typically, requests open with \code{\link[=galah_call]{galah_call()}} - though \code{\link[=request_metadata]{request_metadata()}} +and \code{\link[=request_files]{request_files()}} are also valid - and end with +\code{\link[=collect.data_request]{collect()}}. Under the hood, +the sequence of functions is as follows: + +\code{\link[=capture]{capture()}} → \code{\link[=compound]{compound()}} → +\code{\link[=collapse.data_request]{collapse()}} → +\code{\link[=compute.data_request]{compute()}} → +\code{\link[=collect.data_request]{collect()}} + +\code{\link[=compute.data_request]{compute()}} sends a query to a server, +which, once completed, can be retrieved using +\code{\link[=collect.data_request]{collect()}}. +} \seealso{ To open a piped query, see \code{\link[=galah_call]{galah_call()}}. For alternative operations on \verb{_request} objects, see \code{\link[=capture]{capture()}}, \code{\link[=compound]{compound()}}, diff --git a/man/galah_config.Rd b/man/galah_config.Rd index 8cdbcb50..c90272ef 100644 --- a/man/galah_config.Rd +++ b/man/galah_config.Rd @@ -18,8 +18,10 @@ if arguments are supplied. The \code{galah} package supports queries to a number of different data providers, and once selected, it is desirable that all later queries are sent to that organisation. Rather than supply this information separately in each -query, therefore, it is more parsimonious to cache that information centrally -and call it as needed, which is what this function supports. Beyond choosing +query, it is more parsimonious to cache it centrally +and call it as needed, which is what this function supports. + +Beyond choosing an organisation, there are several other use cases for caching. Many GBIF nodes require the user to supply a registered email address, password, and (in some cases) a reason for downloading data, all stored via @@ -35,11 +37,13 @@ Valid arguments to this function are: \item \code{atlas} string: Living Atlas to point to, Australia by default. Can be an organisation name, acronym, or region (see \code{\link[=show_all_atlases]{show_all_atlases()}} for admissible values) -\item \code{authenticate} logical: should \code{galah} authenticate your queries using -JWT tokens? Defaults to \code{FALSE}. +\item \code{authenticate} logical: Should \code{galah} use authenticate your queries using +JWT tokens? Defaults to \code{FALSE}. If \code{TRUE}, user credentials are +verified prior to sending a query. This can allow users with special +access to download additional information in \code{galah}. \item \code{caching} logical: should metadata query results be cached in \code{options()}? Defaults to \code{TRUE} for improved stability and speed. -\item \code{directory} string: the directory to use for the disk cache. +\item \code{directory} string: The directory to use for the disk cache. By default this is a temporary directory, which means that results will only be cached within an R session and cleared automatically when the user exits R. The user may wish to set this to a non-temporary directory for diff --git a/vignettes/quick_start_guide.Rmd b/vignettes/quick_start_guide.Rmd index ef7317eb..8f9ada29 100644 --- a/vignettes/quick_start_guide.Rmd +++ b/vignettes/quick_start_guide.Rmd @@ -1,7 +1,7 @@ --- title: "Quick start guide" author: "Martin Westgate & Dax Kellie" -date: '2026-01-08' +date: '2026-02-09' output: rmarkdown::html_vignette vignette: > @@ -51,8 +51,8 @@ galah_config(atlas = "GBIF", The full list of supported queries by organisation is as follows:

      -Organisations supported by galah -

      Organisations supported by galah

      +Fig 1: Organisations and APIs supported by galah +

      Fig 1: Organisations and APIs supported by galah

      # Getting data @@ -79,13 +79,13 @@ galah_call() |> # open a pipe ## # A tibble: 7 × 2 ## year count ## -## 1 2024 11770412 -## 2 2023 10974292 -## 3 2022 9422565 -## 4 2025 8989765 -## 5 2021 8686097 -## 6 2020 7308421 -## 7 2026 26191 +## 1 2024 11884223 +## 2 2023 11007008 +## 3 2022 9429562 +## 4 2025 9132194 +## 5 2021 8692767 +## 6 2020 7311538 +## 7 2026 256885 ``` Or to find the number of categories present in a dataset, for example how many @@ -107,7 +107,33 @@ galah_call() |> ## 1 17 ``` -Or to download the records themselves: +You can 'glimpse' a data download before you run it, to check all the data +you need is included: + + +``` r +galah_call() |> + identify("Eolophus roseicapilla") |> + filter(year == 2010) |> + glimpse() |> + collect() +``` + +``` +## Rows: 21,984 +## Columns: 8 +## $ taxonConceptID "https://biodiversity.org.au/afd/taxa/9b4ad548-8bb3-486a-ab0a-905506c463ea", "https://biodiversity.org.au/afd/taxa/9b4ad548-8bb3-486a-ab0a-905506c46… +## $ eventDate 1.281139e+12, 1.292890e+12, 1.270944e+12 +## $ scientificName "Eolophus roseicapilla", "Eolophus roseicapilla", "Eolophus roseicapilla" +## $ decimalLatitude -36.975, -31.600, -35.240 +## $ decimalLongitude 143.8083, 116.5200, 138.9100 +## $ basisOfRecord "HUMAN_OBSERVATION", "HUMAN_OBSERVATION", "HUMAN_OBSERVATION" +## $ dataResourceName "Victorian Biodiversity Atlas", "BirdLife Australia, Birdata", "BirdLife Australia, Birdata" +## $ occurrenceStatus "PRESENT", "PRESENT", "PRESENT" +``` + +And, once satisfied that your parameters are correct, download the records +themselves: @@ -116,7 +142,7 @@ Or to download the records themselves: galah_call() |> identify("Eolophus roseicapilla") |> filter(year == 2010) |> - select(eventDate, decimalLatitude, species) |> # choose columns to keep + select(eventDate, decimalLatitude, species) |> collect() ``` @@ -126,14 +152,14 @@ galah_call() |> ## ## 1 NA -38.2 Eolophus roseicapilla ## 2 NA -38.2 Eolophus roseicapilla -## 3 NA -37.0 Eolophus roseicapilla -## 4 NA -37.7 Eolophus roseicapilla +## 3 NA -36.1 Eolophus roseicapilla +## 4 NA -38.5 Eolophus roseicapilla ## 5 NA -35.6 Eolophus roseicapilla -## 6 NA -31.1 Eolophus roseicapilla +## 6 NA -38.3 Eolophus roseicapilla ## 7 NA -38.2 Eolophus roseicapilla -## 8 NA -36.8 Eolophus roseicapilla +## 8 NA -38.2 Eolophus roseicapilla ## 9 NA -38.3 Eolophus roseicapilla -## 10 NA -36.8 Eolophus roseicapilla +## 10 NA -38.2 Eolophus roseicapilla ## # ℹ 21,974 more rows ``` @@ -148,6 +174,7 @@ Supported `dplyr` verbs that modify queries are as follows: - `count.data_request()` - `distinct.data_request()` - `filter.data_request()` +- `glimpse.data_request()` - `group_by.data_request()` - `select.data_request()` - `slice_head.data_request()` @@ -159,19 +186,6 @@ Additional verbs are: - `identify.data_request()` - `unnest()` -Three other `dplyr` functions are supported to conclude a pipe: - -- `collapse.data_request()` converts the `request` object to a `query`. This allows users to inspect - their API calls before they are sent. Depending on the request, this function - may also call 'supplementary' APIs to collect required information, - such as Taxon Concept Identifiers or field names. -- `compute.data_request()` is intended to send the query in question to the requested API - for processing. In practice, only occurrence queries distinguish between - posting a query and retrieving the results, meaning that in all other cases, - `compute()` simply converts the `query` to a new class (`computed_query`). -- `collect.data_request()` retrieves the requested data into your workspace, returning a - `tibble`. - It is good practice to download your data in as few steps as possible, to minimize impacts on the server, and to ensure you can get a single DOI for your data. See the @@ -189,7 +203,7 @@ Finding this information requires looking for metadata: ``` r -request_metadata() |> +request_metadata(type = "fields") |> collect() ``` @@ -245,7 +259,7 @@ a full list. # Wrapper functions While `dplyr` syntax is very flexible, there are cases where it is easier -to simply say the sort of data you want, rather than create a full database +to simply say the sort of data you want, rather than create a database query to implement it. For this reason, several common use cases have their own wrapper functions. @@ -263,7 +277,7 @@ galah_call() |> ## # A tibble: 1 × 1 ## count ## -## 1 8989765 +## 1 9132194 ``` Or occurrences: @@ -280,14 +294,14 @@ galah_call() |> ``` ## # A tibble: 2,032 × 9 -## recordID scientificName taxonConceptID decimalLatitude decimalLongitude eventDate basisOfRecord occurrenceStatus dataResourceName -## -## 1 0026d29f-b6ab-4a1d-9c57-6ee12cfde3a0 Eolophus roseicapilla https://biodiversity.org.au/afd/taxa/9b4ad548-8bb3-486a-ab0a-905506c463ea -35.4 149. 2000-08-07 00:00:00 HUMAN_OBSERVATION PRESENT Garden Bird Surveys -## 2 0062d446-007b-4164-ac4f-ae84297e2578 Eolophus roseicapilla https://biodiversity.org.au/afd/taxa/9b4ad548-8bb3-486a-ab0a-905506c463ea -35.3 149. 2000-03-10 00:00:00 HUMAN_OBSERVATION PRESENT BirdLife Australia, Birdata -## 3 00a62ee0-1e08-4114-b0d8-9b7905472d53 Eolophus roseicapilla https://biodiversity.org.au/afd/taxa/9b4ad548-8bb3-486a-ab0a-905506c463ea -35.2 149. 2000-01-29 00:00:00 HUMAN_OBSERVATION PRESENT Garden Bird Surveys -## 4 00ab2f4d-326f-4b01-9a8a-1a10c1f77e3c Eolophus roseicapilla https://biodiversity.org.au/afd/taxa/9b4ad548-8bb3-486a-ab0a-905506c463ea -35.4 149. 2000-09-25 00:00:00 HUMAN_OBSERVATION PRESENT Garden Bird Surveys -## 5 00ae4631-ea59-44ec-b8f8-4377b6b3b3ef Eolophus roseicapilla https://biodiversity.org.au/afd/taxa/9b4ad548-8bb3-486a-ab0a-905506c463ea -35.3 149. 2000-02-12 00:00:00 HUMAN_OBSERVATION PRESENT BirdLife Australia, Birdata -## 6 00b6c8ec-e7b9-4d9f-9638-d962b1b4acfa Eolophus roseicapilla https://biodiversity.org.au/afd/taxa/9b4ad548-8bb3-486a-ab0a-905506c463ea -35.2 149. 2000-02-05 00:00:00 HUMAN_OBSERVATION PRESENT Garden Bird Surveys +## recordID scientificName taxonConceptID decimalLatitude decimalLongitude eventDate basisOfRecord occurrenceStatus dataResourceName +## +## 1 0026d29f-b6ab-4a1d-9c57-6ee12cfde3a0 Eolophus roseicapil… https://biodi… -35.4 149. 2000-08-07 00:00:00 HUMAN_OBSERV… PRESENT Garden Bird Sur… +## 2 0062d446-007b-4164-ac4f-ae84297e2578 Eolophus roseicapil… https://biodi… -35.3 149. 2000-03-10 00:00:00 HUMAN_OBSERV… PRESENT BirdLife Austra… +## 3 00a62ee0-1e08-4114-b0d8-9b7905472d53 Eolophus roseicapil… https://biodi… -35.2 149. 2000-01-29 00:00:00 HUMAN_OBSERV… PRESENT Garden Bird Sur… +## 4 00ab2f4d-326f-4b01-9a8a-1a10c1f77e3c Eolophus roseicapil… https://biodi… -35.4 149. 2000-09-25 00:00:00 HUMAN_OBSERV… PRESENT Garden Bird Sur… +## 5 00ae4631-ea59-44ec-b8f8-4377b6b3b3ef Eolophus roseicapil… https://biodi… -35.3 149. 2000-02-12 00:00:00 HUMAN_OBSERV… PRESENT BirdLife Austra… +## 6 00b6c8ec-e7b9-4d9f-9638-d962b1b4acfa Eolophus roseicapil… https://biodi… -35.2 149. 2000-02-05 00:00:00 HUMAN_OBSERV… PRESENT Garden Bird Sur… ## # ℹ 2,026 more rows ``` @@ -297,6 +311,32 @@ calls; see the [downloading images and sounds](downloading_images_and_sounds.htm vignette for details. Finally, metadata calls can be made more efficiently using the `show_all()` -and `show_values()` functions; see the -[look up information](lookup_up_information.html) vignette for details. +and `show_values()` functions. These take the same arguments as the `type` +argument in `request_metadata()`, but use non-standard evaluation, so they +don't require quotes. They are also evaluated immediately rather than lazily: + + +``` r +show_all(fields) +``` + +``` +## # A tibble: 639 × 3 +## id description type +## +## 1 abcdTypeStatus fields +## 2 acceptedNameUsage Accepted name fields +## 3 acceptedNameUsageID Accepted name fields +## 4 accessRights Access rights fields +## 5 annotationsDoi fields +## 6 annotationsUid Referenced by publication fields +## 7 assertionUserId Assertions by user fields +## 8 assertions Record issues fields +## 9 assertionsCount fields +## 10 associatedMedia Associated Media fields +## # ℹ 629 more rows +``` + +You can check the [look up information](lookup_up_information.html) vignette +for further details. diff --git a/vignettes/quick_start_guide.Rmd.orig b/vignettes/quick_start_guide.Rmd.orig index 3a1b9dff..4a11e3fe 100644 --- a/vignettes/quick_start_guide.Rmd.orig +++ b/vignettes/quick_start_guide.Rmd.orig @@ -47,7 +47,7 @@ galah_config(atlas = "GBIF", The full list of supported queries by organisation is as follows: -```{r, atlas-support, echo = FALSE, out.width = "100%", fig.cap="Organisations supported by galah"} +```{r, atlas-support, echo = FALSE, out.width = "100%", fig.cap="Fig 1: Organisations and APIs supported by galah"} knitr::include_graphics('../man/figures/atlases_plot.png') ``` @@ -81,7 +81,19 @@ galah_call() |> collect() ``` -Or to download the records themselves: +You can 'glimpse' a data download before you run it, to check all the data +you need is included: + +```{r, glimpse-example} +galah_call() |> + identify("Eolophus roseicapilla") |> + filter(year == 2010) |> + glimpse() |> + collect() +``` + +And, once satisfied that your parameters are correct, download the records +themselves: ```{r, cache-email, echo = FALSE} galah_config(email = "ala4r@ala.org.au", @@ -92,7 +104,7 @@ galah_config(email = "ala4r@ala.org.au", galah_call() |> identify("Eolophus roseicapilla") |> filter(year == 2010) |> - select(eventDate, decimalLatitude, species) |> # choose columns to keep + select(eventDate, decimalLatitude, species) |> collect() ``` @@ -107,6 +119,7 @@ Supported `dplyr` verbs that modify queries are as follows: - `count.data_request()` - `distinct.data_request()` - `filter.data_request()` +- `glimpse.data_request()` - `group_by.data_request()` - `select.data_request()` - `slice_head.data_request()` @@ -118,19 +131,6 @@ Additional verbs are: - `identify.data_request()` - `unnest()` -Three other `dplyr` functions are supported to conclude a pipe: - -- `collapse.data_request()` converts the `request` object to a `query`. This allows users to inspect - their API calls before they are sent. Depending on the request, this function - may also call 'supplementary' APIs to collect required information, - such as Taxon Concept Identifiers or field names. -- `compute.data_request()` is intended to send the query in question to the requested API - for processing. In practice, only occurrence queries distinguish between - posting a query and retrieving the results, meaning that in all other cases, - `compute()` simply converts the `query` to a new class (`computed_query`). -- `collect.data_request()` retrieves the requested data into your workspace, returning a - `tibble`. - It is good practice to download your data in as few steps as possible, to minimize impacts on the server, and to ensure you can get a single DOI for your data. See the @@ -147,7 +147,7 @@ Building queries using `filter()` requires that you know two things: Finding this information requires looking for metadata: ```{r, metadata-fields} -request_metadata() |> +request_metadata(type = "fields") |> collect() ``` @@ -168,7 +168,7 @@ a full list. # Wrapper functions While `dplyr` syntax is very flexible, there are cases where it is easier -to simply say the sort of data you want, rather than create a full database +to simply say the sort of data you want, rather than create a database query to implement it. For this reason, several common use cases have their own wrapper functions. @@ -198,6 +198,14 @@ calls; see the [downloading images and sounds](downloading_images_and_sounds.htm vignette for details. Finally, metadata calls can be made more efficiently using the `show_all()` -and `show_values()` functions; see the -[look up information](lookup_up_information.html) vignette for details. +and `show_values()` functions. These take the same arguments as the `type` +argument in `request_metadata()`, but use non-standard evaluation, so they +don't require quotes. They are also evaluated immediately rather than lazily: + +```{r, show-all-fields} +show_all(fields) +``` + +You can check the [look up information](lookup_up_information.html) vignette +for further details. From 62003917699087cc414ead532d191222892adca8 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Tue, 10 Feb 2026 16:20:02 +1100 Subject: [PATCH 89/94] Fix bugs identified by `test()` --- R/atlas_media.R | 2 +- R/check.R | 13 ++--- R/collect_occurrences.R | 12 ++-- R/dplyr-arrange.R | 5 +- R/handle_quosures_GBIF.R | 17 +++--- R/query_API.R | 11 ++-- R/utilities_internal.R | 18 +++--- man/galah_call.Rd | 1 + tests/testthat/_snaps/print.md | 14 ++--- tests/testthat/test-authentication.R | 4 +- tests/testthat/test-dplyr-glimpse.R | 29 +++++++++- tests/testthat/test-international-Austria.R | 25 +++++--- tests/testthat/test-international-Brazil.R | 20 +++++-- tests/testthat/test-international-Flanders.R | 57 ++++++++++++++----- tests/testthat/test-international-France.R | 5 +- .../test-international-GBIF-predicates.R | 12 +--- tests/testthat/test-international-GBIF.R | 22 +++++-- tests/testthat/test-international-Guatemala.R | 16 +++++- tests/testthat/test-international-Kew.R | 34 ++++++----- tests/testthat/test-international-Portugal.R | 10 +++- tests/testthat/test-international-Spain.R | 16 +++++- tests/testthat/test-international-Sweden.R | 26 ++++++--- tests/testthat/test-international-UK.R | 20 +++++-- 23 files changed, 261 insertions(+), 128 deletions(-) diff --git a/R/atlas_media.R b/R/atlas_media.R index 9380e494..87516696 100644 --- a/R/atlas_media.R +++ b/R/atlas_media.R @@ -70,7 +70,7 @@ atlas_media <- function(request = NULL, media_fq <- glue::glue("({glue::glue_collapse(media_fq, ' OR ')})") } url <- httr2::url_parse(query_collapse$url) - url$query$fq <- glue::glue("{url$query$fq} AND {media_fq}") + url$query$fq <- glue::glue("{url$query$fq}AND{media_fq}") query_collapse$url <- httr2::url_build(url) } diff --git a/R/check.R b/R/check.R index 1625bf1f..5205e157 100644 --- a/R/check.R +++ b/R/check.R @@ -269,7 +269,6 @@ check_field_identities <- function(df, } #' sub-function to `check_fields()` for GBIF -#' NOTE: This is probably obsolete once we start using predicates for counts #' @noRd #' @keywords Internal check_fields_gbif_counts <- function(.query){ @@ -754,12 +753,12 @@ check_reason <- function(.query, check_select <- function(.query, error_call = rlang::caller_env()){ if(any(names(.query$request) == "select")){ - if(is_gbif() & stringr::str_detect(.query$type, "^data")){ - cli::cli({ - cli::cli_text("Skipping `select()`.") - cli::cli_bullets(c(i = "This function is not supported by the GBIF occurrences downloads API v1.")) - }) - }else{ + if(!(is_gbif() & stringr::str_detect(.query$type, "^data"))){ + # cli::cli({ + # cli::cli_text("Skipping `select()`.") + # cli::cli_bullets(c(i = "This function is not supported by the GBIF occurrences downloads API v1.")) + # }) + # }else{ # 1. build df to `select` from valid_fields <- .query[["metadata/fields"]]$id valid_assertions <- .query[["metadata/assertions"]]$id diff --git a/R/collect_occurrences.R b/R/collect_occurrences.R index bd91d823..da1b5a8e 100644 --- a/R/collect_occurrences.R +++ b/R/collect_occurrences.R @@ -112,8 +112,8 @@ collect_occurrences_doi <- function(.query, collect_occurrences_glimpse <- function(.query){ result <- query_API(.query) - # pull required info from API - df_list <- result |> + # pull required info from API into a tibble + df <- result |> purrr::pluck("occurrences") |> # non-standard fields are nested within `otherProperties` # extract these @@ -121,11 +121,11 @@ collect_occurrences_glimpse <- function(.query){ if(any(names(a) == "otherProperties")){ c(a[names(a) != "otherProperties"], a[["otherProperties"]]) + }else{ + a } - }) - - # create a tibble - df <- dplyr::bind_rows(df_list) + }) |> + dplyr::bind_rows() attr(df, "total_n") <- result$totalRecords # assign new object for bespoke printing diff --git a/R/dplyr-arrange.R b/R/dplyr-arrange.R index 73be6878..48ee74de 100644 --- a/R/dplyr-arrange.R +++ b/R/dplyr-arrange.R @@ -52,7 +52,10 @@ arrange.data_request <- function(.data, ...){ parsed_dots <- purrr::map(dots, \(a){ switch(expr_type(a), "symbol" = {rlang::as_label(a)}, - "call" = {purrr::map(rlang::quo_get_expr(a), rlang::as_string)}, + "call" = { + # NOTE: this is *deliberately* not purrr::map(), + # which raises a warning + lapply(rlang::quo_get_expr(a), rlang::as_string)}, "literal" = {rlang::quo_get_expr(a)}, cli::cli_abort("Quosure type not recognised.", call = rlang::caller_env()))}) |> diff --git a/R/handle_quosures_GBIF.R b/R/handle_quosures_GBIF.R index b82ad2a0..75b06cac 100644 --- a/R/handle_quosures_GBIF.R +++ b/R/handle_quosures_GBIF.R @@ -12,8 +12,8 @@ #' @keywords internal parse_quosures_data_gbif <- function(dots){ if(length(dots) > 0){ - predicates <- purrr::map(dots, - switch_expr_type_pred) + predicates <- lapply(dots, # NOTE: `map()` raises an error here + switch_expr_type_pred) # sometimes, because we call `map()`, we end up with predicates # buried one layer down in the list. Correct this if(length(predicates) == 1L){ @@ -159,12 +159,13 @@ parse_logical_pred <- function(x){ } # wrap later predicates in supplied boolean - subpredicates <- purrr::map(rlang::quo_get_expr(x)[-1], - \(a){ - rlang::as_quosure(a, - env = rlang::quo_get_env(x)) |> - switch_expr_type_pred() - }) + # NOTE: deliberate use of `lapply()` and NOT `purrr::map()` + subpredicates <- lapply(rlang::quo_get_expr(x)[-1], + \(a){ + rlang::as_quosure(a, + env = rlang::quo_get_env(x)) |> + switch_expr_type_pred() + }) names(subpredicates) <- NULL list(type = logical_string, predicates = subpredicates) diff --git a/R/query_API.R b/R/query_API.R index 15f23b61..c15ac0e3 100644 --- a/R/query_API.R +++ b/R/query_API.R @@ -52,13 +52,14 @@ query_API <- function(.query, #' @noRd #' @keywords Internal set_progress_bar_behaviour <- function(criteria){ - verbose <- potions::pour("package", "verbose", .pkg = "galah") & - criteria + verbose <- all( + potions::pour("package", "verbose", .pkg = "galah") & + isTRUE(criteria)) if(verbose){ - progress_bar <- list(format = "Querying API | {pb_bar} {pb_percent}", - clear = TRUE) + list(name = "Querying API", + clear = TRUE) }else{ - progress_bar <- FALSE + FALSE } } diff --git a/R/utilities_internal.R b/R/utilities_internal.R index fd9f8ddb..969d0112 100644 --- a/R/utilities_internal.R +++ b/R/utilities_internal.R @@ -354,7 +354,6 @@ default_columns <- function() { if(atlas %in% c("Austria", "Brazil", "Guatemala", - "Kew", "Portugal")){ c("id", "taxon_name", @@ -377,6 +376,7 @@ default_columns <- function() { "dataResourceName") }else if(atlas %in% c("Australia", "Flanders", + "Kew", "Spain", "Sweden", "United Kingdom")){ @@ -401,7 +401,6 @@ image_fields <- function() { if(atlas %in% c("Austria", "Brazil", "Guatemala", - "Kew", "Portugal")){ "all_image_url" }else if(atlas %in% c("Australia", @@ -410,6 +409,8 @@ image_fields <- function() { "Sweden", "United Kingdom")){ c("multimedia", "images", "sounds", "videos") + }else if(atlas %in% c("Kew")){ + c("multimedia", "images") }else{ cli::cli_abort("Unknown `atlas`") } @@ -419,26 +420,23 @@ image_fields <- function() { #' @noRd #' @keywords Internal image_filters <- function(present_fields, - error_call = rlang::caller_env()){ + error_call = rlang::caller_env()){ atlas <- potions::pour("atlas", "region") switch(atlas, "Austria" = "(all_image_url:*)", "Australia" = glue::glue("({present_fields}:*)"), "Brazil" = "(all_image_url:*)", - "Flanders" = "(all_image_url:*)", + "Flanders" = "(images:*)", "Guatemala" = "(all_image_url:*)", - "Kew" = "(all_image_url:*)", + "Kew" = "(images:*)", "Portugal" = "(all_image_url:*)", - "Spain" = {filter_fields <- present_fields |> - stringr::str_remove("s$") |> - paste0("IDsCount") - glue::glue("{filter_fields}:[1 TO *]")}, + "Spain" = "(multimedia:*)", "Sweden" = {filter_fields <- present_fields |> stringr::str_remove("s$") |> paste0("IDsCount") glue::glue("{filter_fields}:[1 TO *]")}, - "United Kingdom" = "(all_image_url:*)", # !is.na(all_image_url), + "United Kingdom" = "(all_image_url:*)", cli::cli_abort("`atlas_media` is not supported for atlas = {atlas}", call = error_call) ) diff --git a/man/galah_call.Rd b/man/galah_call.Rd index dbf71850..8bece2c7 100644 --- a/man/galah_call.Rd +++ b/man/galah_call.Rd @@ -93,6 +93,7 @@ To amend a request object, use \code{\link[=apply_profile]{apply_profile()}}, \code{\link[=count.data_request]{count()}}, \code{\link[=distinct.data_request]{distinct()}}, \code{\link[=filter.data_request]{filter()}}, +\code{\link[=glimpse.data_request]{glimpse()}}, \code{\link[=group_by.data_request]{group_by()}}, \code{\link[=identify.data_request]{identify()}}, \code{\link[=select.data_request]{select}}, diff --git a/tests/testthat/_snaps/print.md b/tests/testthat/_snaps/print.md index 7e78d4dd..44c2dca4 100644 --- a/tests/testthat/_snaps/print.md +++ b/tests/testthat/_snaps/print.md @@ -73,8 +73,8 @@ compound(filter(galah_call(), basisOfRecord == "HUMAN_OBSERVATION")) Message Object of class query_set containing 4 queries: - * metadata/fields url: https://api.ala.org.au/occurrences/index/fields - * metadata/assertions url: https://api.ala.org.au/occurrences/assertions/codes + * metadata/fields data: galah:::retrieve_cache("fields") + * metadata/assertions data: galah:::retrieve_cache("assertions") * metadata/reasons url: https://api.ala.org.au/logger/service/logger/reasons * data/occurrences url: https://api.ala.org.au/occurrences/occurrences/offline/... @@ -87,7 +87,7 @@ `galah` package configuration Package - v verbose + x verbose v run_checks x send_email v caching @@ -95,10 +95,10 @@ User x authentication - username [Not Provided] - email - password [Not Provided] - download_reason_id 4 + username [Provided] + email ala4r@ala.org.au + password [Provided] + download_reason_id 10 Atlas Atlas of Living Australia (ALA), Australia diff --git a/tests/testthat/test-authentication.R b/tests/testthat/test-authentication.R index 9729d5ab..6e48298c 100644 --- a/tests/testthat/test-authentication.R +++ b/tests/testthat/test-authentication.R @@ -17,8 +17,8 @@ test_that("`request_metadata()` caches type `config` correctly", { x <- request_metadata(type = "config") |> collect() result <- request_metadata(type = "config") |> - capture() - expect_true(!is.null(result$data)) + capture() + expect_true(!is.null(result$data)) }) test_that("`authenticate()` works in-pipe for metadata", { diff --git a/tests/testthat/test-dplyr-glimpse.R b/tests/testthat/test-dplyr-glimpse.R index 69967486..a26ed8d1 100644 --- a/tests/testthat/test-dplyr-glimpse.R +++ b/tests/testthat/test-dplyr-glimpse.R @@ -1,3 +1,5 @@ +quiet_print <- purrr::quietly(print.occurrences_glimpse) + test_that("`glimpse()` returns the correct object class", { skip_if_offline(); skip_on_ci() x <- galah_call() |> @@ -5,6 +7,29 @@ test_that("`glimpse()` returns the correct object class", { glimpse() |> collect() expect_s3_class(x, c("occurrences_glimpse", "tbl_df", "tbl", "data.frame")) - expect_equal(nrow(x), 3) + expect_equal(nrow(x), 3) # number of rows in the tibble + x_print <- strsplit(quiet_print(x)$messages, "\n")[[1]] # print statement + expect_equal(length(x_print), 10) # expect_equal(ncol(x), length(default_columns())) # FIXME: issue with ID/recordID -}) \ No newline at end of file +}) + +test_that("`glimpse()` works with `select()`", { + skip_if_offline(); skip_on_ci() + x <- galah_call() |> + filter(year == 2025) |> + select(eventDate, decimalLatitude, year) |> + glimpse() |> + collect() + expect_s3_class(x, c("occurrences_glimpse", "tbl_df", "tbl", "data.frame")) + expect_equal(nrow(x), 3) + x_print <- strsplit(quiet_print(x)$messages, "\n")[[1]] + expect_equal(length(x_print), 5) + # ensure all three fields are present + stringr::str_detect(x_print, + "^\\$ (eventDate|decimalLatitude|year)") |> + which() |> + length() |> + expect_equal(3) +}) + +rm(quiet_print) \ No newline at end of file diff --git a/tests/testthat/test-international-Austria.R b/tests/testthat/test-international-Austria.R index eec9bb12..917cdf1c 100644 --- a/tests/testthat/test-international-Austria.R +++ b/tests/testthat/test-international-Austria.R @@ -116,20 +116,23 @@ test_that("search_all(taxa) works for Austria", { expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) }) -test_that("show_values works for fields for Austria", { +test_that("show_values works for Austria", { skip_if_offline(); skip_on_ci() + quiet_values <- function(...){ + x <- purrr::quietly(show_values) + x(...)$result + } + # fields x <- try({search_all(fields, "basis_of_record") |> - show_values()}, + quiet_values()}, silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") expect_gte(nrow(x), 1) expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) -}) -test_that("show_values works for lists for Austria", { - skip_if_offline(); skip_on_ci() + # lists x <- try({search_all(lists, "dr30") |> - show_values()}, + quiet_values()}, silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") expect_gte(nrow(x), 1) @@ -259,8 +262,12 @@ test_that("atlas_media() works for Austria", { expect_equal(colnames(x)[1:2], c("media_id", "media_type")) # download a subset + quiet_media <- function(...){ + x <- purrr::quietly(collect_media) + x(...)$result + } n_downloads <- 5 - collect_media(x[seq_len(n_downloads), ]) + quiet_media(x[seq_len(n_downloads), ]) expect_equal(length(list.files("temp", pattern = ".jpg$")), n_downloads) unlink("temp", recursive = TRUE) @@ -281,4 +288,6 @@ test_that("atlas_taxonomy works for Austria", { expect_gte(nrow(y), 5) }) -galah_config(atlas = "Australia") +quiet_config <- purrr::quietly(galah_config) +quiet_config(atlas = "Australia") +rm(quiet_config) \ No newline at end of file diff --git a/tests/testthat/test-international-Brazil.R b/tests/testthat/test-international-Brazil.R index 151d0869..4dd70b8e 100644 --- a/tests/testthat/test-international-Brazil.R +++ b/tests/testthat/test-international-Brazil.R @@ -87,18 +87,22 @@ test_that("search_all(taxa) works for Brazil", { test_that("`show_values()` works for Brazil", { skip_if_offline(); skip_on_ci() + quiet_values <- function(...){ + x <- purrr::quietly(show_values) + x(...)$result + } x <- search_fields("basis_of_record") |> - show_values() |> + quiet_values() |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") expect_gt(nrow(x), 1) y <- search_lists("drt1565630923841") |> - show_values() |> + quiet_values() |> try(silent = TRUE) skip_if(inherits(y, "try-error"), message = "API not available") expect_gt(nrow(y), 1) search_profiles("profile") |> - show_values() |> + quiet_values() |> expect_error() }) @@ -226,11 +230,17 @@ test_that("`atlas_media()` works for Brazil", { expect_equal(colnames(x)[1:2], c("media_id", "recordID")) # download a subset + quiet_media <- function(...){ + x <- purrr::quietly(collect_media) + x(...)$result + } n_downloads <- 5 - collect_media(x[seq_len(n_downloads), ]) + quiet_media(x[seq_len(n_downloads), ]) expect_equal(length(list.files("temp", pattern = ".jpg$")), n_downloads) unlink("temp", recursive = TRUE) }) -galah_config(atlas = "Australia") +quiet_config <- purrr::quietly(galah_config) +quiet_config(atlas = "Australia") +rm(quiet_config) \ No newline at end of file diff --git a/tests/testthat/test-international-Flanders.R b/tests/testthat/test-international-Flanders.R index cdba7536..96f0e003 100644 --- a/tests/testthat/test-international-Flanders.R +++ b/tests/testthat/test-international-Flanders.R @@ -151,8 +151,12 @@ test_that("search_all(identifiers) works for Flanders", { test_that("show_values works for fields for Flanders", { skip_if_offline(); skip_on_ci() + quiet_values <- function(...){ + x <- purrr::quietly(show_values) + x(...)$result + } x <- search_all(fields, "basisOfRecord") |> - show_values() |> + quiet_values() |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") expect_gte(nrow(x), 1) @@ -177,7 +181,7 @@ test_that("atlas_counts works with type = 'species' for Flanders", { expect_gt(x, 0) }) -test_that("atlas_counts works with galah_identify for Flanders", { +test_that("`count()` works with galah_identify for Flanders", { skip_if_offline(); skip_on_ci() result <- galah_call() |> identify("Mammalia") |> @@ -197,9 +201,25 @@ test_that("atlas_counts works with galah_identify for Flanders", { 0.1) # i.e. <1% margin of error }) -# FIXME test glimpse() +test_that("`glimpse()` works for Flanders", { + skip_if_offline(); skip_on_ci() + x <- galah_call() |> + filter(year == 2025) |> + glimpse() |> + collect() + expect_s3_class(x, c("occurrences_glimpse", "tbl_df", "tbl", "data.frame")) + expect_equal(nrow(x), 3) # number of rows in the tibble + quiet_print <- purrr::quietly(print.occurrences_glimpse) + x_print <- strsplit(quiet_print(x)$messages, "\n")[[1]] # print statement + expect_gt(length(x_print), 5) + stringr::str_detect(x_print, + "^\\$ (taxonConceptID|eventDate|decimalLatitude|scientificName)") |> + which() |> + length() |> + expect_equal(4) # note: recordID missing +}) -test_that("atlas_counts works with group_by for Flanders", { +test_that("`count()` works with `group_by()` for Flanders", { skip_if_offline(); skip_on_ci() result <- galah_call() |> filter(year >= 2000) |> @@ -212,7 +232,7 @@ test_that("atlas_counts works with group_by for Flanders", { expect_equal(names(result), c("basisOfRecord", "count")) }) -test_that("atlas_species works for Flanders", { +test_that("`atlas_species()` works for Flanders", { skip_if_offline(); skip_on_ci() galah_config( atlas = "Flanders", @@ -228,7 +248,7 @@ test_that("atlas_species works for Flanders", { expect_s3_class(spp, c("tbl_df", "tbl", "data.frame")) }) -test_that("atlas_occurrences() works for Flanders", { +test_that("`atlas_occurrences()` works for Flanders", { skip_if_offline(); skip_on_ci() galah_config( atlas = "Flanders", @@ -254,7 +274,7 @@ test_that("atlas_occurrences() works for Flanders", { expect_true(inherits(occ, c("tbl_df", "tbl", "data.frame"))) }) -test_that("atlas_media() works for Flanders", { +test_that("`atlas_media()` works for Flanders", { skip_if_offline(); skip_on_ci() galah_config( atlas = "Flanders", @@ -263,12 +283,13 @@ test_that("atlas_media() works for Flanders", { directory = "temp", send_email = FALSE) x <- request_data() |> - identify("Erinaceinae") |> # problem here: - # 1. query works but doesn't return an entry - # 2. identify() is not robust to searches that return no values - filter(year == 2023 - # imageIDsCount > 0 + identify("Vulpes") |> + filter(year == 2025, + basisOfRecord == "HUMAN_OBSERVATION", + # month == 6, + !is.na(images) ) |> + # group_by(year) |> # count() |> # collect() atlas_media() |> @@ -277,13 +298,19 @@ test_that("atlas_media() works for Flanders", { expect_s3_class(x, c("tbl_df", "tbl", "data.frame")) expect_gte(nrow(x), 1) expect_equal(colnames(x)[1:2], - c("media_id", "recordID")) + c("media_id", "media_type")) # download a subset + quiet_media <- function(...){ + x <- purrr::quietly(collect_media) + x(...)$result + } n_downloads <- 5 - collect_media(x[seq_len(n_downloads), ]) + quiet_media(x[seq_len(n_downloads), ]) expect_equal(length(list.files("temp", pattern = ".jpg$")), n_downloads) unlink("temp", recursive = TRUE) }) -galah_config(atlas = "Australia") +quiet_config <- purrr::quietly(galah_config) +quiet_config(atlas = "Australia") +rm(quiet_config) \ No newline at end of file diff --git a/tests/testthat/test-international-France.R b/tests/testthat/test-international-France.R index 26103632..bd6b7b00 100644 --- a/tests/testthat/test-international-France.R +++ b/tests/testthat/test-international-France.R @@ -209,5 +209,6 @@ test_that("atlas_occurrences works for France", { unlink("temp", recursive = TRUE) }) -rm(skip_message) -galah_config(atlas = "Australia") +quiet_config <- purrr::quietly(galah_config) +quiet_config(atlas = "Australia") +rm(quiet_config, skip_message) \ No newline at end of file diff --git a/tests/testthat/test-international-GBIF-predicates.R b/tests/testthat/test-international-GBIF-predicates.R index 5e782243..99cbfaee 100644 --- a/tests/testthat/test-international-GBIF-predicates.R +++ b/tests/testthat/test-international-GBIF-predicates.R @@ -1,6 +1,6 @@ quiet_config <- purrr::quietly(galah_config) -x <- quiet_config(atlas = "GBIF", +gbif_config <- quiet_config(atlas = "GBIF", username = "atlasoflivingaustralia", email = "ala4r@ala.org.au", password = "galah-gbif-test-login") @@ -30,8 +30,6 @@ test_that("`filter()` handles a single entry for GBIF", { expect_true(is.integer(x$count)) }) -# FIXME: `check_fields()` not tested for GBIF - try sending invalid fields to `filter()` - test_that("`filter()` handles multiple (`AND`) queries for GBIF", { skip_if_offline(); skip_on_ci() # get a count limited by two different categories @@ -105,10 +103,6 @@ test_that("`count()` works with `identify()` for GBIF", { expect_equal(ncol(x), 2) expect_equal(x$classKey, "359") }) -# FIXME: fields returned by show_all(fields) are not the same as those accepted by occurrences/search API -# This leads to real field names being passed to this API, but not affecting the result -# accepted fields are here: -# https://techdocs.gbif.org/en/openapi/v1/occurrence#/Searching%20occurrences/searchOccurrence test_that("`filter()` handles `OR` and `%in%` for GBIF", { skip_if_offline(); skip_on_ci() @@ -319,5 +313,5 @@ test_that("`count()` works with `galah_radius()` for GBIF", { # TODO: add assertions? -x <- quiet_config(atlas = "ALA") -rm(x, quiet_config) +gbif_config <- quiet_config(atlas = "ALA") +rm(gbif_config, quiet_config) diff --git a/tests/testthat/test-international-GBIF.R b/tests/testthat/test-international-GBIF.R index 19d24f6b..26364f13 100644 --- a/tests/testthat/test-international-GBIF.R +++ b/tests/testthat/test-international-GBIF.R @@ -43,8 +43,12 @@ test_that("show_values works for GBIF fields", { unnest() |> collect() # traditional syntax + quiet_values <- function(...){ + x <- purrr::quietly(show_values) + x(...)$result + } y <- search_fields("gbifRegion") |> - show_values() + quiet_values() # tests inherits(x, c("tbl_df", "tbl", "data.frame")) |> expect_true() @@ -248,9 +252,9 @@ test_that("`count()` works with `identify` for GBIF when `run_checks` = TRUE", { ## TODO: Add a more basic occurrences check test_that("`atlas_occurrences()` works for GBIF", { galah_config(atlas = "GBIF", - username = "atlasoflivingaustralia", - email = "ala4r@ala.org.au", - password = "galah-gbif-test-login") + username = "atlasoflivingaustralia", + email = "ala4r@ala.org.au", + password = "galah-gbif-test-login") x <- galah_call() |> filter(year == 1890, classKey == "359", @@ -358,6 +362,14 @@ test_that("`collapse()` et al. work for GBIF with `type = 'occurrences'`", { expect_true(inherits(z, c("tbl_df", "tbl", "data.frame"))) expect_equal(nrow(z), count$count) expect_true(!is.null(attributes(z)$doi)) + + # FIXME: need DOI search test + ## recent_doi <- attributes(z)$doi + # a <- galah_call() |> + # filter(doi == recent_doi) |> + # collect() }) -galah_config(atlas = "Australia") +quiet_config <- purrr::quietly(galah_config) +quiet_config(atlas = "Australia") +rm(quiet_config) \ No newline at end of file diff --git a/tests/testthat/test-international-Guatemala.R b/tests/testthat/test-international-Guatemala.R index c46b759c..b409400b 100644 --- a/tests/testthat/test-international-Guatemala.R +++ b/tests/testthat/test-international-Guatemala.R @@ -90,8 +90,12 @@ test_that("search_all(taxa) works for Guatemala", { test_that("show_values works for fields for Guatemala", { skip_if_offline(); skip_on_ci() + quiet_values <- function(...){ + x <- purrr::quietly(show_values) + x(...)$result + } x <- search_all(fields, "basis_of_record") |> - show_values() |> + quiet_values() |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") expect_gte(nrow(x), 1) @@ -222,11 +226,17 @@ test_that("atlas_media() works for Guatemala", { expect_equal(colnames(x)[1:2], c("media_id", "recordID")) # download a subset + quiet_media <- function(...){ + x <- purrr::quietly(collect_media) + x(...)$result + } n_downloads <- 5 - collect_media(x[seq_len(n_downloads), ]) + quiet_media(x[seq_len(n_downloads), ]) expect_equal(length(list.files("temp", pattern = ".jpg$")), n_downloads) unlink("temp", recursive = TRUE) }) -galah_config(atlas = "Australia") +quiet_config <- purrr::quietly(galah_config) +quiet_config(atlas = "Australia") +rm(quiet_config) \ No newline at end of file diff --git a/tests/testthat/test-international-Kew.R b/tests/testthat/test-international-Kew.R index df8467a3..f0149d21 100644 --- a/tests/testthat/test-international-Kew.R +++ b/tests/testthat/test-international-Kew.R @@ -79,15 +79,15 @@ test_that("show_all(profiles) unavailable for Kew", { expect_error(label = "No API is available for type `metadata/profiles`") }) -test_that("show_all(lists) works for Kew", { - # NOTE: When tested on 2025-07-04 this API works, but contains no data - skip_if_offline(); skip_on_ci() - x <- show_all(lists, limit = 10) |> - try(silent = TRUE) - skip_if(inherits(x, "try-error"), message = "API not available") - expect_lte(nrow(x), 10) - expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) -}) +## NOTE: When tested on 2025-07-04 this API works, but contains no data +# test_that("show_all(lists) works for Kew", { +# skip_if_offline(); skip_on_ci() +# x <- show_all(lists, limit = 10) |> +# try(silent = TRUE) +# skip_if(inherits(x, "try-error"), message = "API not available") +# expect_lte(nrow(x), 10) +# expect_true(inherits(x, c("tbl_df", "tbl", "data.frame"))) +# }) test_that("search_all(fields) works for Kew", { skip_if_offline(); skip_on_ci() @@ -218,7 +218,7 @@ test_that("`atlas_media()` works for Kew", { skip_if_offline(); skip_on_ci() galah_config( atlas = "Kew", - email = "test@ala.org.au", + email = "ala4r@ala.org.au", download_reason_id = 10, directory = "temp", send_email = FALSE) @@ -231,13 +231,19 @@ test_that("`atlas_media()` works for Kew", { expect_s3_class(x, c("tbl_df", "tbl", "data.frame")) expect_gte(nrow(x), 1) expect_equal(colnames(x)[1:2], - c("media_id", "recordID")) + c("media_id", "media_type")) # download a subset - n_downloads <- 5 - collect_media(x[seq_len(n_downloads), ]) + quiet_media <- function(...){ + x <- purrr::quietly(collect_media) + x(...)$result + } + n_downloads <- min(c(nrow(x), 5)) + quiet_media(x[seq_len(n_downloads), ]) expect_equal(length(list.files("temp", pattern = ".jpg$")), n_downloads) unlink("temp", recursive = TRUE) }) -galah_config(atlas = "Australia") +quiet_config <- purrr::quietly(galah_config) +quiet_config(atlas = "Australia") +rm(quiet_config) \ No newline at end of file diff --git a/tests/testthat/test-international-Portugal.R b/tests/testthat/test-international-Portugal.R index 2dc26c8b..670c9c77 100644 --- a/tests/testthat/test-international-Portugal.R +++ b/tests/testthat/test-international-Portugal.R @@ -88,8 +88,12 @@ test_that("search_all(taxa) works for Portugal", { test_that("show_values works for fields for Portugal", { skip_if_offline(); skip_on_ci() + quiet_values <- function(...){ + x <- purrr::quietly(show_values) + x(...)$result + } x <- search_all(fields, "basis_of_record") |> - show_values() |> + quiet_values() |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") expect_gte(nrow(x), 1) @@ -166,4 +170,6 @@ test_that("atlas_occurrences returns error for Portugal", { # Note: atlas_media() should also work, in theory, but again am unable to test -galah_config(atlas = "Australia") +quiet_config <- purrr::quietly(galah_config) +quiet_config(atlas = "Australia") +rm(quiet_config) \ No newline at end of file diff --git a/tests/testthat/test-international-Spain.R b/tests/testthat/test-international-Spain.R index 774b162b..f0cbd252 100644 --- a/tests/testthat/test-international-Spain.R +++ b/tests/testthat/test-international-Spain.R @@ -149,8 +149,12 @@ test_that("search_all(identifiers) works for Spain", { test_that("show_values works for fields for Spain", { skip_if_offline(); skip_on_ci() + quiet_values <- function(...){ + x <- purrr::quietly(show_values) + x(...)$result + } x <- search_all(fields, "basisOfRecord") |> - show_values() |> + quiet_values() |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") expect_gte(nrow(x), 1) @@ -291,11 +295,17 @@ test_that("atlas_media() works for Spain", { expect_equal(colnames(x)[1:2], c("media_id", "media_type")) # download a subset + quiet_media <- function(...){ + x <- purrr::quietly(collect_media) + x(...)$result + } n_downloads <- 5 - collect_media(x[seq_len(n_downloads), ]) + quiet_media(x[seq_len(n_downloads), ]) expect_equal(length(list.files("temp", pattern = ".jpg$")), n_downloads) unlink("temp", recursive = TRUE) }) -galah_config(atlas = "Australia") +quiet_config <- purrr::quietly(galah_config) +quiet_config(atlas = "Australia") +rm(quiet_config) \ No newline at end of file diff --git a/tests/testthat/test-international-Sweden.R b/tests/testthat/test-international-Sweden.R index cd757c98..817590bc 100644 --- a/tests/testthat/test-international-Sweden.R +++ b/tests/testthat/test-international-Sweden.R @@ -158,17 +158,20 @@ test_that("`search_taxa()` works for multiple ranks in Sweden", { expect_true(all(grepl("^[[:digit:]]+$", taxa$taxon_concept_id))) }) -test_that("show_values works fields in Sweden", { +test_that("s`how_values()` works fields in Sweden", { skip_if_offline(); skip_on_ci() + quiet_values <- function(...){ + x <- purrr::quietly(show_values) + x(...)$result + } + # fields x <- search_fields("basisOfRecord") |> - show_values() |> + quiet_values() |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") expect_gt(nrow(x), 1) -}) -test_that("show_values works for lists in Sweden", { - skip_if_offline(); skip_on_ci() + # lists x <- try({search_all(lists, "dr156") |> show_values()}, silent = TRUE) @@ -273,11 +276,12 @@ test_that("atlas_occurrences works for Sweden", { expect_equal(occ_collapse$type, "data/occurrences") # compute occ_compute <- compute(occ_collapse) + skip_if(inherits(occ_compute, "try-error"), message = "API not available") expect_s3_class(occ_compute, "computed_query") # collect occ <- collect(occ_compute) |> try(silent = TRUE) - skip_if(inherits(occ_compute, "try-error"), message = "API not available") + skip_if(inherits(occ, "try-error"), message = "API not available") expect_s3_class(occ, c("tbl_df", "tbl", "data.frame")) expect_equal(ncol(occ), length(default_columns())) }) @@ -340,10 +344,16 @@ test_that("collect_media() works for Sweden", { n_downloads) unlink("temp", recursive = TRUE) # try with collect_media() - collect_media(media_meta[seq_len(n_downloads), ]) + quiet_media <- function(...){ + x <- purrr::quietly(collect_media) + x(...)$result + } + quiet_media(x[seq_len(n_downloads), ]) expect_equal(length(list.files("temp", pattern = ".jpg$")), n_downloads) unlink("temp", recursive = TRUE) }) -galah_config(atlas = "Australia") +quiet_config <- purrr::quietly(galah_config) +quiet_config(atlas = "Australia") +rm(quiet_config) \ No newline at end of file diff --git a/tests/testthat/test-international-UK.R b/tests/testthat/test-international-UK.R index 2b2b802a..b1a4a25d 100644 --- a/tests/testthat/test-international-UK.R +++ b/tests/testthat/test-international-UK.R @@ -108,15 +108,19 @@ test_that("search_taxa doesn't break with typos", { test_that("show_values works for UK", { skip_if_offline(); skip_on_ci() + quiet_values <- function(...){ + x <- purrr::quietly(show_values) + x(...)$result + } + + # fields x <- search_fields("basisOfRecord") |> show_values() |> try(silent = TRUE) skip_if(inherits(x, "try-error"), message = "API not available") expect_gt(nrow(x), 1) -}) -test_that("show_list_values works for United Kingdom", { - skip_if_offline(); skip_on_ci() + # lists x <- search_lists("dr1445") |> show_values() |> try(silent = TRUE) @@ -258,11 +262,17 @@ test_that("atlas_media() works for UK", { expect_equal(colnames(x)[1:2], c("media_id", "media_type")) # download a subset + quiet_media <- function(...){ + x <- purrr::quietly(collect_media) + x(...)$result + } n_downloads <- 5 - collect_media(x[seq_len(n_downloads), ]) + quiet_media(x[seq_len(n_downloads), ]) expect_equal(length(list.files("temp", pattern = ".jpg$")), n_downloads) unlink("temp", recursive = TRUE) }) -galah_config(atlas = "Australia") +quiet_config <- purrr::quietly(galah_config) +quiet_config(atlas = "Australia") +rm(quiet_config) From b6bdaae2710a45d702f553d082fe28036f704107 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Wed, 11 Feb 2026 09:58:27 +1100 Subject: [PATCH 90/94] pdate `galah_config()` so that `download_reason_id` can be set without an internet connection --- R/galah_config.R | 21 ++++++++++++--------- tests/testthat/_snaps/print.md | 4 ++-- tests/testthat/test-print.R | 8 +++++++- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/R/galah_config.R b/R/galah_config.R index 49598e86..9f2ab221 100644 --- a/R/galah_config.R +++ b/R/galah_config.R @@ -259,27 +259,30 @@ enforce_download_reason <- function(value, error_call = rlang::caller_env()){ # first ensure API is available. Currently missing for Brazil, for example. - reasons_api_available <- url_lookup("metadata/reasons") |> + reasons_df <- show_all_reasons() |> try(silent = TRUE) - if(inherits(reasons_api_available, "try-error")){ - return(1) + if(inherits(reasons_df, "try-error")){ + if(is.numeric(value)){ + as.integer(value) + }else{ + 1 + } }else{ - if (is.numeric(value) & !(value %in% show_all_reasons()$id)) { + if (is.numeric(value) & !(value %in% reasons_df$id)) { c("Invalid download reason ID.", i = "Use `show_all(reasons)` to see all valid reasons.", x = "{value} does not match an existing reason ID.") |> cli::cli_abort(call = error_call) - } else if(is.character(value) & !(value %in% show_all_reasons()$name)) { + } else if(is.character(value) & !(value %in% reasons_df$name)) { bullets <- c( "Invalid download reason name.", i = "Use `show_all(reasons)` to see all valid reasons.", x = "\"{value}\" does not match an existing reason name.") |> cli::cli_abort(call = error_call) } - if (is.character(value) & (value %in% show_all_reasons()$name)) { - valid_reasons <- show_all_reasons() - value_id <- valid_reasons |> - dplyr::filter(valid_reasons$name == value) |> + if (is.character(value) & (value %in% reasons_df$name)) { + value_id <- reasons_df |> + dplyr::filter(reasons_df$name == value) |> dplyr::select("id") |> dplyr::pull("id") cli::cli_bullets(c("v" = "Matched \"{value}\" to valid download reason ID {value_id}.")) diff --git a/tests/testthat/_snaps/print.md b/tests/testthat/_snaps/print.md index 44c2dca4..dcd00c82 100644 --- a/tests/testthat/_snaps/print.md +++ b/tests/testthat/_snaps/print.md @@ -73,8 +73,8 @@ compound(filter(galah_call(), basisOfRecord == "HUMAN_OBSERVATION")) Message Object of class query_set containing 4 queries: - * metadata/fields data: galah:::retrieve_cache("fields") - * metadata/assertions data: galah:::retrieve_cache("assertions") + * metadata/fields url: https://api.ala.org.au/occurrences/index/fields + * metadata/assertions url: https://api.ala.org.au/occurrences/assertions/codes * metadata/reasons url: https://api.ala.org.au/logger/service/logger/reasons * data/occurrences url: https://api.ala.org.au/occurrences/occurrences/offline/... diff --git a/tests/testthat/test-print.R b/tests/testthat/test-print.R index 33602bd8..19f0693a 100644 --- a/tests/testthat/test-print.R +++ b/tests/testthat/test-print.R @@ -49,6 +49,8 @@ test_that("object of class `computed_query` formats correctly", { }) test_that("object of class `query_set` formats correctly", { + # prevent earlier caching (or lack thereof) affecting the result + reset_cache() galah_call() |> filter(basisOfRecord == "HUMAN_OBSERVATION") |> compound() |> @@ -56,7 +58,11 @@ test_that("object of class `query_set` formats correctly", { }) test_that("`galah_config()` formats correctly", { - galah_config(directory = "something") + galah_config(atlas = "ALA", + directory = "something", + verbose = FALSE, + caching = TRUE, + download_reason_id = 10) galah_config() |> expect_snapshot() galah_config(directory = tempdir(check = TRUE)) From d45ad3cc3d45837c5991572f3f41691170c7efb3 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Wed, 11 Feb 2026 09:59:14 +1100 Subject: [PATCH 91/94] support passing DOI to `filter()` for GBIF (#272) --- R/capture.R | 18 +++++++++---- R/capture_occurrences.R | 32 +++++++++++++++--------- R/collapse_checks.R | 3 ++- R/collapse_query_set.R | 3 +-- R/collect_occurrences.R | 8 +++--- R/compute_occurrences.R | 19 ++++++++++++++ R/dplyr-compute.R | 1 + tests/testthat/test-atlas_occurrences.R | 1 + tests/testthat/test-international-GBIF.R | 13 ++++++---- 9 files changed, 69 insertions(+), 29 deletions(-) diff --git a/R/capture.R b/R/capture.R index f70a0f90..d2f07a74 100644 --- a/R/capture.R +++ b/R/capture.R @@ -161,14 +161,22 @@ as_prequery <- function(x){ #' @keywords Internal check_doi <- function(x){ if(x$type == "occurrences"){ - # handle sending dois via `filter()` - # important this happens first, as it affects `type`, which affects later code - variables <- purrr::pluck(x, "filter", "variable") # NOTE: breaks for GBIF - if(!is.null(variables)){ - if(length(variables) == 1 & variables[1] == "doi"){ + if(is_gbif()){ + variables <- unlist(x$filter) + if(any(variables == "DOI")){ x$type <- "occurrences-doi" } + }else{ + # handle sending dois via `filter()` + # important this happens first, as it affects `type`, which affects later code + variables <- purrr::pluck(x, "filter", "variable") + if(!is.null(variables)){ + if(length(variables) == 1 & variables[1] == "doi"){ + x$type <- "occurrences-doi" + } + } } + } x } diff --git a/R/capture_occurrences.R b/R/capture_occurrences.R index fbc410f6..8e37bd99 100644 --- a/R/capture_occurrences.R +++ b/R/capture_occurrences.R @@ -83,16 +83,20 @@ capture_occurrences_la <- function(.query, #' @keywords Internal capture_occurrences_doi <- function(.query, error_call = rlang::caller_env()){ + # check for a doi filter if(is.null(.query$filter)){ cli::cli_abort("A DOI must be specified using `filter(doi == \"my-doi-here\")`.", call = error_call) } - if(is.null(.query$filter$variable) && .query$filter$variable != "doi"){ - cli::cli_abort("No DOI has been supplied.", - call = error_call) + # check that doi is supplied 'correctly' + if(inherits(.query$filter, "predicates_filter")){ + doi_missing_check <- .query$filter$key != "DOI" + }else{ + doi_missing_check <- is.null(.query$filter$variable) && .query$filter$variable != "doi" } + # check atlas is ok atlas <- potions::pour("atlas", "acronym") if(!(atlas %in% c("ALA", "GBIF"))){ c( @@ -101,25 +105,29 @@ capture_occurrences_doi <- function(.query, cli::cli_abort(call = error_call) } + # get doi doi <- .query$filter$value[[1]] # remove "https://" if present - if (grepl("^http://doi.org/", doi)) { - doi <- sub("^https://doi.org/", "", doi) + if (stringr::str_detect(doi, "^(http|https)://doi.org/")) { + doi <- stringr::str_remove(doi, "^(http|https)://doi.org/") } # extract useful part of DOI - doi_str <- stringr::str_split(doi, "ala.")[[1]][2] - if(is.na(doi_str)){ - c( - "DOI has not been generated by the ALA.", - i = "DOIs created by the ALA have a prefix of 10.26197/ala.") |> - cli::cli_abort(call = error_call) + + if(atlas == "ALA"){ + doi <- stringr::str_split(doi, "ala.")[[1]][2] + if(is.na(doi)){ + c("DOI has not been generated by the ALA.", + i = "DOIs created by the ALA have a prefix of 10.26197/ala.") |> + cli::cli_abort(call = error_call) + } } + # build an object list(type = "data/occurrences-doi", url = url_lookup("data/occurrences-doi", - doi_string = doi_str), + doi_string = doi), headers = build_headers(), download = TRUE) |> as_query() diff --git a/R/collapse_checks.R b/R/collapse_checks.R index 89452074..56c6f2d3 100644 --- a/R/collapse_checks.R +++ b/R/collapse_checks.R @@ -43,8 +43,9 @@ collapse_build_checks <- function(.query){ #' @keywords Internal collapse_run_checks <- function(.query, error_call = rlang::caller_env()){ + # "data/" functions require pre-processing of metadata, - if(stringr::str_detect(.query$type, "^data/")){ + if(stringr::str_detect(.query$type, "^data/") & .query$type != "data/occurrences-doi"){ # taxon concept ID must always be evaluated .query <- check_identifiers(.query, error_call) # login should only be evaluated for species and occurrence diff --git a/R/collapse_query_set.R b/R/collapse_query_set.R index 0562fcf9..f3cfd811 100644 --- a/R/collapse_query_set.R +++ b/R/collapse_query_set.R @@ -39,7 +39,6 @@ collapse_query_set <- function(x, add_request(x$request), # some "metadata/" functions require pagination under some circumstances "metadata/lists" = collapse_lists(x), # always paginates - x # remaining "metadata/" functions are passed as-is - # fixme to make a new object type + x # remaining "metadata/" functions and "data/occurrences-doi" are passed as-is ) } \ No newline at end of file diff --git a/R/collect_occurrences.R b/R/collect_occurrences.R index da1b5a8e..ff39423a 100644 --- a/R/collect_occurrences.R +++ b/R/collect_occurrences.R @@ -50,9 +50,7 @@ collect_occurrences_default <- function(.query, wait, file, call){ # get data if(potions::pour("package", "verbose", .pkg = "galah") & download_response$status == "complete") { - scrolly_dots_message("Downloading") - # cli::cli_par() } # sometimes lookup info critical, but not others - unclear when/why! if(any(names(download_response) == "download_url")){ @@ -102,6 +100,10 @@ collect_occurrences_doi <- function(.query, if(is.null(result)){ download_failed_message(call = call) }else{ + # first see if DOI is returned in the API call + if(!is.null(.query$doi)){ + attr(result, "doi") <- glue::glue("https://doi.org/{.query$doi}") + } result } } @@ -147,8 +149,6 @@ download_failed_message <- function(call){ cli::cli_abort(call = call) } - - #' Theatrics #' @noRd #' @keywords Internal diff --git a/R/compute_occurrences.R b/R/compute_occurrences.R index f38baef0..a590409a 100644 --- a/R/compute_occurrences.R +++ b/R/compute_occurrences.R @@ -68,3 +68,22 @@ extract_fields <- function(.query){ strsplit(split = ",") |> purrr::pluck(!!!list(1)) } + +#' Internal function to retrieve a GBIF DOI +#' @noRd +#' @keywords Internal +compute_occurrences_doi <- function(.query){ + if(stringr::str_detect(.query$url, "api.gbif.org")){ + .query$download <- NULL + result <- query_API(.query) + c(list(type = "data/occurrences-doi", + url = result$downloadLink, + download = TRUE), + result[!(names(result) %in% c("request", "downloadLink"))]) |> + add_request(.query) |> + structure(class = "computed_query") + }else{ + # living atlases just need passing onward + as_computed_query(.query) + } +} \ No newline at end of file diff --git a/R/dplyr-compute.R b/R/dplyr-compute.R index 3a9b285e..650b09e9 100644 --- a/R/dplyr-compute.R +++ b/R/dplyr-compute.R @@ -76,6 +76,7 @@ compute.prequery <- compute.data_request compute.query <- function(x, ...){ switch(x$type, "data/occurrences" = compute_occurrences(x), + "data/occurrences-doi" = compute_occurrences_doi(x), "data/species" = { if(is_gbif()){ compute_occurrences(x) diff --git a/tests/testthat/test-atlas_occurrences.R b/tests/testthat/test-atlas_occurrences.R index 1dbbea29..9639653b 100644 --- a/tests/testthat/test-atlas_occurrences.R +++ b/tests/testthat/test-atlas_occurrences.R @@ -175,6 +175,7 @@ test_that("`atlas_occurrences()`() and friends accept a file name", { quiet_collect(file = "test_doi2") expect_equal(occ3, occ4) expect_true(any(list.files(directory) == "test_doi2.zip")) + expect_false(is.null(attributes(occ3)$doi)) # clean up unlink("TEMP", recursive = TRUE) cache_dir <- tempfile() diff --git a/tests/testthat/test-international-GBIF.R b/tests/testthat/test-international-GBIF.R index 26364f13..53e95b9e 100644 --- a/tests/testthat/test-international-GBIF.R +++ b/tests/testthat/test-international-GBIF.R @@ -251,6 +251,7 @@ test_that("`count()` works with `identify` for GBIF when `run_checks` = TRUE", { ## TODO: Add a more basic occurrences check test_that("`atlas_occurrences()` works for GBIF", { + skip_if_offline(); skip_on_ci() galah_config(atlas = "GBIF", username = "atlasoflivingaustralia", email = "ala4r@ala.org.au", @@ -334,7 +335,7 @@ test_that("atlas_media fails for GBIF", { }) }) -test_that("`collapse()` et al. work for GBIF with `type = 'occurrences'`", { +test_that("`collect()` works for GBIF with `type = 'occurrences' or 'occurrences-doi'` ", { skip_if_offline(); skip_on_ci() # collapse base_query <- request_data() |> @@ -364,10 +365,12 @@ test_that("`collapse()` et al. work for GBIF with `type = 'occurrences'`", { expect_true(!is.null(attributes(z)$doi)) # FIXME: need DOI search test - ## recent_doi <- attributes(z)$doi - # a <- galah_call() |> - # filter(doi == recent_doi) |> - # collect() + recent_doi <- attributes(z)$doi + a <- galah_call() |> + filter(doi == recent_doi) |> + collect() + expect_equal(a, z, ignore_attr = TRUE) + expect_equal(attributes(a)$doi, attributes(z)$doi) }) quiet_config <- purrr::quietly(galah_config) From b30df28c7f6a0115167f37ba98112ce5fcf89183 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Wed, 11 Feb 2026 11:49:48 +1100 Subject: [PATCH 92/94] Support `glimpse()` for GBIF --- R/capture.R | 5 ++- R/capture_occurrences.R | 8 +++-- R/capture_occurrences_count.R | 10 +++--- R/collapse_occurrences_count_gbif.R | 4 +-- R/collapse_query_set.R | 6 ++++ R/collect_occurrences.R | 33 ++++++++++++++++++++ R/dplyr-glimpse.R | 5 +-- tests/testthat/test-dplyr-glimpse.R | 4 +-- tests/testthat/test-international-Flanders.R | 2 +- tests/testthat/test-international-GBIF.R | 19 ++++++++++- 10 files changed, 77 insertions(+), 19 deletions(-) diff --git a/R/capture.R b/R/capture.R index d2f07a74..4ea1fcf1 100644 --- a/R/capture.R +++ b/R/capture.R @@ -305,11 +305,14 @@ check_glimpse <- function(x){ if(!is.null(x$glimpse)){ if(x$type == "occurrences"){ x$type <- "occurrences-glimpse" + x }else{ cli::cli_inform("`glimpse()` is only supported for `type =\"occurrences\"") + x } + }else{ + x } - x } #' Internal function to check `slice` and `arrange` for counts diff --git a/R/capture_occurrences.R b/R/capture_occurrences.R index 8e37bd99..333d1eb6 100644 --- a/R/capture_occurrences.R +++ b/R/capture_occurrences.R @@ -138,8 +138,10 @@ capture_occurrences_doi <- function(.query, #' @keywords Internal capture_occurrences_glimpse <- function(.query){ if(is_gbif()){ - # browser() # not coded yet - .query + result <- capture_occurrences_count(.query) + result$body$limit <- 3 + result$type <- "data/occurrences-glimpse" + as_prequery(result) }else{ result <- capture_occurrences_la(.query) url <- httr2::url_parse(result$url) @@ -155,6 +157,6 @@ capture_occurrences_glimpse <- function(.query){ # rebuild and ship result$url <- httr2::url_build(url) result$type <- "data/occurrences-glimpse" - result + as_prequery(result) } } \ No newline at end of file diff --git a/R/capture_occurrences_count.R b/R/capture_occurrences_count.R index 01d92e6f..c2762323 100644 --- a/R/capture_occurrences_count.R +++ b/R/capture_occurrences_count.R @@ -79,11 +79,11 @@ parse_slice_arrange <- function(df){ #' @keywords Internal #' @noRd capture_occurrences_count_gbif <- function(identify = NULL, - filter = NULL, - geolocate = NULL, - group_by = NULL, - slice = NULL # probably broken - ){ + filter = NULL, + geolocate = NULL, + group_by = NULL, + slice = NULL # probably broken + ){ # compile supplied arguments into a list # honestly this is a little messy, but the alternative is to call # [build_predicates()], which is messier as taxonomic info hasn't yet been diff --git a/R/collapse_occurrences_count_gbif.R b/R/collapse_occurrences_count_gbif.R index e0561fdf..e66d9a7f 100644 --- a/R/collapse_occurrences_count_gbif.R +++ b/R/collapse_occurrences_count_gbif.R @@ -4,9 +4,9 @@ #' @param x A list with slots relevant to building predicates #' @noRd #' @keywords Internal -collapse_occurrences_count_gbif <- function(x){ +collapse_occurrences_count_gbif <- function(x, limit = 0){ x$body <- list(predicate = build_predicates(x$body), - limit = 0) |> + limit = limit) |> remove_nulls_from_list() |> jsonlite::toJSON(auto_unbox = TRUE, pretty = TRUE) diff --git a/R/collapse_query_set.R b/R/collapse_query_set.R index f3cfd811..22d3dc1d 100644 --- a/R/collapse_query_set.R +++ b/R/collapse_query_set.R @@ -31,6 +31,12 @@ collapse_query_set <- function(x, } } }, + "data/occurrences-glimpse" = { + if(is_gbif()){ + collapse_occurrences_count_gbif(x, limit = 3) + }else{ + x + }}, "data/species" = collapse_occurrences(x), # optimised for GBIF "data/species-count" = collapse_species_count(x), # "-unnest" functions require some checks diff --git a/R/collect_occurrences.R b/R/collect_occurrences.R index ff39423a..0d6fab68 100644 --- a/R/collect_occurrences.R +++ b/R/collect_occurrences.R @@ -112,6 +112,39 @@ collect_occurrences_doi <- function(.query, #' @noRd #' @keywords Internal collect_occurrences_glimpse <- function(.query){ + if(is_gbif()){ + collect_occurrences_glimpse_gbif(.query) + }else{ + collect_occurrences_glimpse_la(.query) + } +} + +#' collect type `data/occurrences-glimpse` for GBIF +#' @noRd +#' @keywords Internal +collect_occurrences_glimpse_gbif <- function(.query){ + result <- query_API(.query) + + # convert to tibble + df <- result |> + purrr::pluck("results") |> + purrr::map(tidy_list_columns) |> + dplyr::bind_rows() + attr(df, "total_n") <- result$count + + # assign new object for bespoke printing + if(tibble::is_tibble(df)){ + structure(df, + class = c("occurrences_glimpse", "tbl_df", "tbl", "data.frame")) + }else{ + df # not sure what use case this is, but probably NULL + } +} + +#' collect type `data/occurrences-glimpse` for living atlases +#' @noRd +#' @keywords Internal +collect_occurrences_glimpse_la <- function(.query){ result <- query_API(.query) # pull required info from API into a tibble diff --git a/R/dplyr-glimpse.R b/R/dplyr-glimpse.R index 7a5cd8a8..c1feb913 100644 --- a/R/dplyr-glimpse.R +++ b/R/dplyr-glimpse.R @@ -24,8 +24,5 @@ print.occurrences_glimpse <- function(x, ...){ n_text <- attr(x, 'total_n') |> formatC(big.mark = ",") y[[1]] <- glue::glue("Rows: {n_text}") - cli::cli({ - purrr::map(y, cli::cli_text) |> - invisible() - }) + cli::cat_line(y) } \ No newline at end of file diff --git a/tests/testthat/test-dplyr-glimpse.R b/tests/testthat/test-dplyr-glimpse.R index a26ed8d1..225f1475 100644 --- a/tests/testthat/test-dplyr-glimpse.R +++ b/tests/testthat/test-dplyr-glimpse.R @@ -8,7 +8,7 @@ test_that("`glimpse()` returns the correct object class", { collect() expect_s3_class(x, c("occurrences_glimpse", "tbl_df", "tbl", "data.frame")) expect_equal(nrow(x), 3) # number of rows in the tibble - x_print <- strsplit(quiet_print(x)$messages, "\n")[[1]] # print statement + x_print <- strsplit(quiet_print(x)$output, "\n")[[1]] # print statement expect_equal(length(x_print), 10) # expect_equal(ncol(x), length(default_columns())) # FIXME: issue with ID/recordID }) @@ -22,7 +22,7 @@ test_that("`glimpse()` works with `select()`", { collect() expect_s3_class(x, c("occurrences_glimpse", "tbl_df", "tbl", "data.frame")) expect_equal(nrow(x), 3) - x_print <- strsplit(quiet_print(x)$messages, "\n")[[1]] + x_print <- strsplit(quiet_print(x)$output, "\n")[[1]] expect_equal(length(x_print), 5) # ensure all three fields are present stringr::str_detect(x_print, diff --git a/tests/testthat/test-international-Flanders.R b/tests/testthat/test-international-Flanders.R index 96f0e003..49acfe23 100644 --- a/tests/testthat/test-international-Flanders.R +++ b/tests/testthat/test-international-Flanders.R @@ -210,7 +210,7 @@ test_that("`glimpse()` works for Flanders", { expect_s3_class(x, c("occurrences_glimpse", "tbl_df", "tbl", "data.frame")) expect_equal(nrow(x), 3) # number of rows in the tibble quiet_print <- purrr::quietly(print.occurrences_glimpse) - x_print <- strsplit(quiet_print(x)$messages, "\n")[[1]] # print statement + x_print <- strsplit(quiet_print(x)$output, "\n")[[1]] # print statement expect_gt(length(x_print), 5) stringr::str_detect(x_print, "^\\$ (taxonConceptID|eventDate|decimalLatitude|scientificName)") |> diff --git a/tests/testthat/test-international-GBIF.R b/tests/testthat/test-international-GBIF.R index 53e95b9e..f751d684 100644 --- a/tests/testthat/test-international-GBIF.R +++ b/tests/testthat/test-international-GBIF.R @@ -249,7 +249,24 @@ test_that("`count()` works with `identify` for GBIF when `run_checks` = TRUE", { expect_equal(nrow(z), 1) }) -## TODO: Add a more basic occurrences check +test_that("`glimpse()` works for GBIF", { + skip_if_offline(); skip_on_ci() + x <- galah_call() |> + filter(year == 2025) |> + glimpse() |> + collect() + expect_s3_class(x, c("occurrences_glimpse", "tbl_df", "tbl", "data.frame")) + expect_equal(nrow(x), 3) # number of rows in the tibble + quiet_print <- purrr::quietly(print.occurrences_glimpse) + x_print <- strsplit(quiet_print(x)$output, "\n")[[1]] # print statement + expect_gt(length(x_print), 5) + stringr::str_detect(x_print, + "^\\$ (taxonConceptID|eventDate|decimalLatitude)") |> + which() |> + length() |> + expect_equal(3) +}) + test_that("`atlas_occurrences()` works for GBIF", { skip_if_offline(); skip_on_ci() galah_config(atlas = "GBIF", From f3e5735258a0bb04ad924803f3795e3bd6e9447c Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Wed, 11 Feb 2026 13:31:33 +1100 Subject: [PATCH 93/94] Update `print()` tests, add examples for `glimpse()`, `authenticate()` --- R/authenticate.R | 14 ++++++++++++++ R/dplyr-glimpse.R | 8 ++++++++ man/authenticate.Rd | 16 ++++++++++++++++ man/glimpse.data_request.Rd | 10 ++++++++++ tests/testthat/_snaps/print.md | 3 +-- tests/testthat/test-print.R | 1 + 6 files changed, 50 insertions(+), 2 deletions(-) diff --git a/R/authenticate.R b/R/authenticate.R index 94d155af..208d59d2 100644 --- a/R/authenticate.R +++ b/R/authenticate.R @@ -11,6 +11,20 @@ #' to `FALSE` #' @returns An object of the same class as supplied, but with an added #' `authenticate` slot. +#' @examples \dontrun{ +#' # use `galah_config()` to set for all occurrence queries +#' galah_config(authenticate = TRUE) +#' +#' x <- galah_call() |> +#' identify("Wollemia nobilis") |> +#' collect() +#' +#' # use in-pipe for more control +#' x <- galah_call() |> +#' identify("Wollemia nobilis") |> +#' authenticate() |> +#' collect() +#' } #' @export authenticate <- function(.data, cache_disk = FALSE){ diff --git a/R/dplyr-glimpse.R b/R/dplyr-glimpse.R index c1feb913..f71d987b 100644 --- a/R/dplyr-glimpse.R +++ b/R/dplyr-glimpse.R @@ -12,6 +12,14 @@ #' sent to the server, then returning a novel object class with it's own #' [print()] method. #' @name glimpse.data_request +#' @examples \dontrun{ +#' galah_call() |> +#' filter(year >= 2019, +#' basisOfRecord == "HumanObservation") |> +#' select(year, basisOfRecord, species) |> +#' glimpse() |> +#' collect() +#' } #' @export glimpse.data_request <- function(x, ...){ update_request_object(x, glimpse = TRUE) diff --git a/man/authenticate.Rd b/man/authenticate.Rd index 0cd36fcd..e46788e6 100644 --- a/man/authenticate.Rd +++ b/man/authenticate.Rd @@ -24,3 +24,19 @@ of \code{\link[=galah_config]{galah_config()}} is set to \code{TRUE}, but only f to the Atlas of Living Australia. \ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}}. } +\examples{ +\dontrun{ +# use `galah_config()` to set for all occurrence queries +galah_config(authenticate = TRUE) + +x <- galah_call() |> + identify("Wollemia nobilis") |> + collect() + +# use in-pipe for more control +x <- galah_call() |> + identify("Wollemia nobilis") |> + authenticate() |> + collect() +} +} diff --git a/man/glimpse.data_request.Rd b/man/glimpse.data_request.Rd index d83bb614..8590077b 100644 --- a/man/glimpse.data_request.Rd +++ b/man/glimpse.data_request.Rd @@ -26,3 +26,13 @@ This implementation of \code{\link[=glimpse]{glimpse()}} actually involves chang sent to the server, then returning a novel object class with it's own \code{\link[=print]{print()}} method. } +\examples{ +\dontrun{ +galah_call() |> + filter(year >= 2019, + basisOfRecord == "HumanObservation") |> + select(year, basisOfRecord, species) |> + glimpse() |> + collect() +} +} diff --git a/tests/testthat/_snaps/print.md b/tests/testthat/_snaps/print.md index dcd00c82..43284d92 100644 --- a/tests/testthat/_snaps/print.md +++ b/tests/testthat/_snaps/print.md @@ -72,10 +72,9 @@ Code compound(filter(galah_call(), basisOfRecord == "HUMAN_OBSERVATION")) Message - Object of class query_set containing 4 queries: + Object of class query_set containing 3 queries: * metadata/fields url: https://api.ala.org.au/occurrences/index/fields * metadata/assertions url: https://api.ala.org.au/occurrences/assertions/codes - * metadata/reasons url: https://api.ala.org.au/logger/service/logger/reasons * data/occurrences url: https://api.ala.org.au/occurrences/occurrences/offline/... diff --git a/tests/testthat/test-print.R b/tests/testthat/test-print.R index 19f0693a..f24c0632 100644 --- a/tests/testthat/test-print.R +++ b/tests/testthat/test-print.R @@ -62,6 +62,7 @@ test_that("`galah_config()` formats correctly", { directory = "something", verbose = FALSE, caching = TRUE, + run_checks = TRUE, download_reason_id = 10) galah_config() |> expect_snapshot() From 97e2a4531920227860026636ee130dab2ac57085 Mon Sep 17 00:00:00 2001 From: Martin Westgate Date: Wed, 11 Feb 2026 13:59:02 +1100 Subject: [PATCH 94/94] update vignettes, news, pkgdown site --- NEWS.md | 16 +- _pkgdown.yml | 6 +- vignettes/accessing_sensitive_data.Rmd | 167 ++++++-------- vignettes/accessing_sensitive_data.Rmd.orig | 43 ++-- vignettes/downloading_images_and_sounds.Rmd | 15 -- .../downloading_images_and_sounds.Rmd.orig | 18 -- vignettes/object-oriented-programming.Rmd | 215 ++++++++++++++++++ .../object-oriented-programming.Rmd.orig | 139 +++++++++++ vignettes/quick_start_guide.Rmd | 61 ++--- 9 files changed, 491 insertions(+), 189 deletions(-) delete mode 100644 vignettes/downloading_images_and_sounds.Rmd delete mode 100644 vignettes/downloading_images_and_sounds.Rmd.orig create mode 100644 vignettes/object-oriented-programming.Rmd create mode 100644 vignettes/object-oriented-programming.Rmd.orig diff --git a/NEWS.md b/NEWS.md index bc4a1a48..dba0a332 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,15 +1,16 @@ # galah 2.2.0 ### Improved organisational support -* `filter()` now builds predicate queries natively when atlas is set to `GBIF`. `filter()` now uses an object-oriented workflow. -* DOIs now supported for `GBIF`. -* Kew gardens and Flanders living atlases added (#256). -* Authentication supported for ALA users (#189). +* Flemish living atlas and Kew Gardens Data Portal added (#256, #271). +* Authentication supported for ALA users, with associated vignette (#189). +* All GBIF data queries now use predicates. `filter()` builds predicate queries natively when atlas is set to `GBIF` using an object-oriented workflow (#272). +* Re-downloading a file via its' DOI now supported for `GBIF` (#272). +* `search_identifiers()` and `tibble`-based searching via `search_taxa()` now supported for `GBIF` -### New & amended functions +### New functions * `dplyr::distinct()` can be used to find grouped data and summaries, generalising `atlas_species()` (#284). +* `dplyr::glimpse()` can be used to see what the resulting query will look like. * New functions `capture()` and `compound()` as prequels to `collapse()` (#278). -* `galah_call()` is now synonmous with `request_data()` rather than wrapping all `request_` functions; `method` argument is removed. ## Changes to metadata functions * All metadata requests now accept `select()`. @@ -17,10 +18,11 @@ * All `show_all()` and `search_all()` functions gain an `all_fields` argument. * Metadata now supports list-columns where the API returns nested data. * Metadata functions now return columns names in `snake_case` rather than `camelCase`. -* All metadata functions support caching, and are affected by re-introduced `caching` argument in `galah_config()` (set to `TRUE` by default). +* All metadata functions support caching, and are affected by re-introduced `caching` argument in `galah_config()` (set to `TRUE` by default #282). * Media metadata now uses a different API to return more relevant information. ### Minor improvements and bug fixes +* `galah_call()` is now synonmous with `request_data()` rather than wrapping all `request_` functions; `method` argument is removed. * Move to `testthat` 3rd edition for improved test functionality. * Move to `{cli}` for `print()` calls, not `cat()`. * Reduce usage of `@importFrom` in favour of `pkg::fun()` syntax, as per R style guide. diff --git a/_pkgdown.yml b/_pkgdown.yml index 025d9236..d214d9fc 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -28,10 +28,12 @@ navbar: - text: Further information - text: Looking up information href: articles/look_up_information.html + - text: Accessing sensitive data + href: articles/accessing_sensitive_data.html - text: Reproduciblility href: articles/download-data-reproducibly.html - - text: Downloading images and sounds - href: articles/downloading_images_and_sounds.html + - text: Object oriented programming + href: articles/object-oriented-programming.html - text: --- - text: Filtering - text: Taxonomic filtering diff --git a/vignettes/accessing_sensitive_data.Rmd b/vignettes/accessing_sensitive_data.Rmd index 45c4ff46..fe12040d 100644 --- a/vignettes/accessing_sensitive_data.Rmd +++ b/vignettes/accessing_sensitive_data.Rmd @@ -1,7 +1,7 @@ --- title: "Accessing sensitive data" author: "Martin Westgate" -date: "2025-10-29" +date: "2026-02-11" output: rmarkdown::html_vignette vignette: > @@ -39,44 +39,38 @@ Framework for the Sharing of Restricted Access Species Data in Australia, more simply known as the 'RASD framework' (https://www.rasd.org.au). If your access to sensitive data is approved by the provider(s) in question, -from version 2.2.0 you can use 'galah' to access that sensitive data. +you can use 'galah' to access that sensitive data. -# Switching on authentication +# Using authentication -First, you will need to add your email address and password, as usual for -downloads via `galah`. Because you might want to share your script at some -point, we do not recommend that you simply type these into your script. Instead, -if you save your information in json format, you can import it directly to -R without ever showing the text in your script. For example, if you save this -text into a file: +You have two choices for how to call authentication in your queries. The +simplest way is to 'switch on' authentication using `galah_config()`: ``` r -{"email":"my.email@email.com", "password":"the-most-secure-password-ever"} +galah_config(authenticate = TRUE) ``` -Then, if we import this from JSON to a list, we can pass it directly to -`galah_config()` without typing secret information into our script. +This is straightforward, but you'll have to remember to switch +`authentication` to `FALSE` again once you're done. A safer choice is to +use the `authenticate()` function in-pipe: ``` r -jsonlite::fromJSON("my_secret_information.txt") |> - galah_config() +df <- galah_call() |> + identify("Mammalia") |> + filter(year == 2025) |> + authenticate() |> + collect() ``` -The second step is to switch on authentication, also via `galah_config()`. -This downloads a client ID and set of URLs from the ALA that enable -authentication, and also triggers later queries to run an authentication process. +It doesn't matter where in the pipe you place `authenticate()`, as it will only +trigger your browser to open once you run `collect()`. Whichever method +you use, you'll notice a page pop up in your default browser, encouraging +you to sign in. Once you have done so, you should be redirected back to your +chosen IDE to continue working. -``` r -galah_config(authenticate = TRUE) -``` - -That's it! What happens next is that your first query will trigger your browser -to open. Once you have successfully signed in to the ALA, later queries will -have the full set of permissions available to you via `galah`. - # Deciding what sensitive data to access In the the ALA, sensitive data are stored in bespoke fields, which have an @@ -84,35 +78,23 @@ existing field name prefixed with `sensitive_`. They cannot be requested directly. Instead, if you request a field that has a sensitive counterpart, both the public and sensitive version of that field will be returned. - -``` -## -## Attaching package: 'gt' -``` - -``` -## The following object is masked from 'package:testthat': -## -## matches -``` - -
      - @@ -585,7 +567,6 @@ the public and sensitive version of that field will be returned. sensitive_verbatimLongitude -
      diff --git a/vignettes/accessing_sensitive_data.Rmd.orig b/vignettes/accessing_sensitive_data.Rmd.orig index e1934e4d..2986d024 100644 --- a/vignettes/accessing_sensitive_data.Rmd.orig +++ b/vignettes/accessing_sensitive_data.Rmd.orig @@ -1,7 +1,7 @@ --- title: "Accessing sensitive data" author: "Martin Westgate" -date: "2025-10-29" +date: "2026-02-11" output: rmarkdown::html_vignette vignette: > @@ -41,40 +41,35 @@ Framework for the Sharing of Restricted Access Species Data in Australia, more simply known as the 'RASD framework' (https://www.rasd.org.au). If your access to sensitive data is approved by the provider(s) in question, -from version 2.2.0 you can use 'galah' to access that sensitive data. +you can use 'galah' to access that sensitive data. -# Keeping emails and passwords secure +# Using authentication -First, you will need to add your email address and password, as usual for -downloads via `galah`. Because you might want to share your script at some -point, we do not recommend that you simply type these into your script. Instead, -if you save your information in json format, you can import it directly to -R without ever showing the text in your script. For example, if you save this -text into a file: +You have two choices for how to call authentication in your queries. The +simplest way is to 'switch on' authentication using `galah_config()`: ```{r, eval = FALSE} -{"email":"my.email@email.com", "password":"the-most-secure-password-ever"} +galah_config(authenticate = TRUE) ``` -Then, if we import this from JSON to a list, we can pass it directly to -`galah_config()` without typing secret information into our script. +This is straightforward, but you'll have to remember to switch +`authentication` to `FALSE` again once you're done. A safer choice is to +use the `authenticate()` function in-pipe: ```{r, eval = FALSE} -jsonlite::fromJSON("my_secret_information.txt") |> - galah_config() +df <- galah_call() |> + identify("Mammalia") |> + filter(year == 2025) |> + authenticate() |> + collect() ``` -The second step is to switch on authentication, also via `galah_config()`. -This downloads a client ID and set of URLs from the ALA that enable -authentication, and also triggers later queries to run an authentication process. - -```{r, eval = FALSE} -galah_config(authenticate = TRUE) -``` +It doesn't matter where in the pipe you place `authenticate()`, as it will only +trigger your browser to open once you run `collect()`. Whichever method +you use, you'll notice a page pop up in your default browser, encouraging +you to sign in. Once you have done so, you should be redirected back to your +chosen IDE to continue working. -That's it! What happens next is that your first query will trigger your browser -to open. Once you have successfully signed in to the ALA, later queries will -have the full set of permissions available to you via `galah`. # Deciding what sensitive data to access diff --git a/vignettes/downloading_images_and_sounds.Rmd b/vignettes/downloading_images_and_sounds.Rmd deleted file mode 100644 index 7f1d11f3..00000000 --- a/vignettes/downloading_images_and_sounds.Rmd +++ /dev/null @@ -1,15 +0,0 @@ ---- -title: "Downloading images and sounds" -author: "Martin Westgate" -date: '2025-10-20' -output: - rmarkdown::html_vignette -vignette: > - %\VignetteIndexEntry{Downloading images and sounds} - %\VignetteEngine{knitr::rmarkdown} - %\VignetteEncoding{UTF-8} ---- - - - -This is a placeholder only diff --git a/vignettes/downloading_images_and_sounds.Rmd.orig b/vignettes/downloading_images_and_sounds.Rmd.orig deleted file mode 100644 index 3c448275..00000000 --- a/vignettes/downloading_images_and_sounds.Rmd.orig +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: "Downloading images and sounds" -author: "Martin Westgate" -date: '2025-10-20' -output: - rmarkdown::html_vignette -vignette: > - %\VignetteIndexEntry{Downloading images and sounds} - %\VignetteEngine{knitr::rmarkdown} - %\VignetteEncoding{UTF-8} ---- - -```{r, include = FALSE} -# set up galah session -galah_config(email = "ala4r@ala.org.au", verbose = FALSE) -``` - -This is a placeholder only \ No newline at end of file diff --git a/vignettes/object-oriented-programming.Rmd b/vignettes/object-oriented-programming.Rmd new file mode 100644 index 00000000..ee8b959f --- /dev/null +++ b/vignettes/object-oriented-programming.Rmd @@ -0,0 +1,215 @@ +--- +title: "Object-Oriented Programming" +author: "Martin Westgate & Dax Kellie" +date: '2026-02-11' +output: + rmarkdown::html_vignette +vignette: > + %\VignetteIndexEntry{Object-Oriented Programming} + %\VignetteEngine{knitr::rmarkdown} + %\VignetteEncoding{UTF-8} +--- + + +`galah` has some alot of functions that display object-oriented behaviour, +which are used for two purposes: + + - building piped queries via `request` objects + - handling the parsing of those objects into `query` objects + +Below we'll go through each in turn. + + +# `request` objects + +The default method for building queries in `galah` is to first use `galah_call()` +to create a query object called a "`data_request`". When a piped object is of +class `data_request`, galah triggers functions to use specific methods for +this object class, e.g. + + +``` r +galah_call() |> + filter(genus == "Crinia", year == 2020) |> + group_by(species) |> + count() |> + collect() +``` + +``` +## # A tibble: 16 × 2 +## species count +## +## 1 Crinia signifera 42477 +## 2 Crinia parinsignifera 8363 +## 3 Crinia glauerti 3111 +## 4 Crinia georgiana 1509 +## 5 Crinia remota 717 +## 6 Crinia sloanei 682 +## 7 Crinia insignifera 530 +## 8 Crinia tinnula 316 +## 9 Crinia deserticola 254 +## 10 Crinia pseudinsignifera 222 +## 11 Crinia tasmaniensis 182 +## 12 Crinia bilingua 75 +## 13 Crinia subinsignifera 46 +## 14 Crinia riparia 10 +## 15 Crinia flindersensis 3 +## 16 Crinia nimba 1 +``` + +Thanks to object-oriented programming, galah "masks" `filter()` and `group_by()` +functions to use methods defined for `data_request` objects instead. The full +list of masked functions is: + +- `arrange()` (`{dplyr}`) +- `count()` (`{dplyr}`) +- `glimpse()` (`{dplyr}`) +- `identify()` (`{graphics}`) +- `select()` (`{dplyr}`) +- `group_by()` (`{dplyr}`) +- `slice_head()` (`{dplyr}`) +- `st_crop()` (`{sf}`) + +Note that these functions are all evaluated lazily; they amend the underlying +object, but do not amend the nature of the data until the call is evaluated. + +# `query` objects + +A `request` object stores all the information needed to generate a query, +but does not build or enact that query. To achieve this, galah has a second +object-oriented workflow, consisting of the following stages + +- `capture()` identifies the url needed to execute the request. For complex + requests that require multiple API calls to evaluate, it returns a `prequery` + object. For simpler requests it returns a `query`. +- `compund()` identifies the full set of queries necessary to properly evaluate + the specified request, returning them as a `query_set`. +- `collapse()` converts a `query_set` to a `query`. This is the point in the + pipeline where the final url is generated. +- `compute()` is intended to send the query in question to the requested API + for processing. This is particularly important for occurrences, where + it can be useful to submit a query and retrieve it at a later time. If the + `compute()` stage is not required, however, `compute()` simply converts + the `query` to a new class (`computed_query`). +- `collect()` retrieves the requested data into your workspace, returning a + `tibble`. + +We can use these in sequence, or just leap ahead to the stage we want: + + +``` r +x <- request_data() |> + filter(genus == "Crinia", year == 2020) |> + group_by(species) |> + arrange(species) |> + count() + +capture(x) +``` + +``` +## Object of class prequery with type data/occurrences-count-groupby +``` + +``` +## • url: https://api.ala.org.au/occurrences/occurrences/facets?fq=%28genus%3A%2... +``` + +``` r +compound(x) +``` + +``` +## Object of class query_set containing 3 queries: +``` + +``` +## • metadata/fields data: galah:::retrieve_cache("fields") +``` + +``` +## • metadata/assertions data: galah:::retrieve_cache("assertions") +``` + +``` +## • data/occurrences-count-groupby url: https://api.ala.org.au/occurrences/occurr... +``` + +``` r +collapse(x) +``` + +``` +## Object of class query with type data/occurrences-count-groupby +``` + +``` +## • url: https://api.ala.org.au/occurrences/occurrences/facets?fq=%28genus%3A%2... +``` + +``` r +collect(x) |> head() +``` + +``` +## # A tibble: 6 × 2 +## species count +## +## 1 Crinia bilingua 75 +## 2 Crinia deserticola 254 +## 3 Crinia flindersensis 3 +## 4 Crinia georgiana 1509 +## 5 Crinia glauerti 3111 +## 6 Crinia insignifera 530 +``` + +The benefit of this workflow is that it is highly modular. This is critical +for debugging workflows that might have gone wrong for one reason or another, but it +is also useful for handling large data requests in galah. Users can send their query +using `compute()`, and download data once the query has finished — downloading +with `collect()` later — rather than waiting for the request to finish within R. + + +``` r +# Create and send query to be calculated server-side +request <- request_data() |> + identify("perameles") |> + filter(year > 1900) |> + compute() + +# Download data +request |> + collect() +``` + +# metadata requests + +For the above workflow to be achivable, it is neccessary for every API call +in `galah` to be written as a `request` object. This is because `compound()` +must collect a range of different requests to evaluate a single query. +To this end, `galah` supports metadata requests, in addition to the data +requests described above. + + + +``` r +request_metadata(type = "fields") |> + collect() +``` + +Or to show values for states and territories: + + +``` r +request_metadata() |> + filter(field == "cl22") |> + unnest() |> + collect() +``` + +While `request_metadata()` is more modular than `show_all()`, there is +little benefit to using it for most applications. However, in some cases, +larger databases like GBIF return huge `data.frame`s of metadata when called +via `show_all()`. Using `request_metdata()` allows users to specify a +`slice_head()` line within their pipe to get around this issue. diff --git a/vignettes/object-oriented-programming.Rmd.orig b/vignettes/object-oriented-programming.Rmd.orig new file mode 100644 index 00000000..104bfbc6 --- /dev/null +++ b/vignettes/object-oriented-programming.Rmd.orig @@ -0,0 +1,139 @@ +--- +title: "Object-Oriented Programming" +author: "Martin Westgate & Dax Kellie" +date: '`r Sys.Date()`' +output: + rmarkdown::html_vignette +vignette: > + %\VignetteIndexEntry{Object-Oriented Programming} + %\VignetteEngine{knitr::rmarkdown} + %\VignetteEncoding{UTF-8} +--- +```{r include = FALSE} +galah_config(email = "ala4r@ala.org.au", + atlas = "Australia", + verbose = FALSE) +``` + +`galah` has some alot of functions that display object-oriented behaviour, +which are used for two purposes: + + - building piped queries via `request` objects + - handling the parsing of those objects into `query` objects + +Below we'll go through each in turn. + + +# `request` objects + +The default method for building queries in `galah` is to first use `galah_call()` +to create a query object called a "`data_request`". When a piped object is of +class `data_request`, galah triggers functions to use specific methods for +this object class, e.g. + +```{r} +galah_call() |> + filter(genus == "Crinia", year == 2020) |> + group_by(species) |> + count() |> + collect() +``` + +Thanks to object-oriented programming, galah "masks" `filter()` and `group_by()` +functions to use methods defined for `data_request` objects instead. The full +list of masked functions is: + +- `arrange()` (`{dplyr}`) +- `count()` (`{dplyr}`) +- `glimpse()` (`{dplyr}`) +- `identify()` (`{graphics}`) +- `select()` (`{dplyr}`) +- `group_by()` (`{dplyr}`) +- `slice_head()` (`{dplyr}`) +- `st_crop()` (`{sf}`) + +Note that these functions are all evaluated lazily; they amend the underlying +object, but do not amend the nature of the data until the call is evaluated. + +# `query` objects + +A `request` object stores all the information needed to generate a query, +but does not build or enact that query. To achieve this, galah has a second +object-oriented workflow, consisting of the following stages + +- `capture()` identifies the url needed to execute the request. For complex + requests that require multiple API calls to evaluate, it returns a `prequery` + object. For simpler requests it returns a `query`. +- `compund()` identifies the full set of queries necessary to properly evaluate + the specified request, returning them as a `query_set`. +- `collapse()` converts a `query_set` to a `query`. This is the point in the + pipeline where the final url is generated. +- `compute()` is intended to send the query in question to the requested API + for processing. This is particularly important for occurrences, where + it can be useful to submit a query and retrieve it at a later time. If the + `compute()` stage is not required, however, `compute()` simply converts + the `query` to a new class (`computed_query`). +- `collect()` retrieves the requested data into your workspace, returning a + `tibble`. + +We can use these in sequence, or just leap ahead to the stage we want: + +```{r} +x <- request_data() |> + filter(genus == "Crinia", year == 2020) |> + group_by(species) |> + arrange(species) |> + count() + +capture(x) +compound(x) +collapse(x) +collect(x) |> head() +``` + +The benefit of this workflow is that it is highly modular. This is critical +for debugging workflows that might have gone wrong for one reason or another, but it +is also useful for handling large data requests in galah. Users can send their query +using `compute()`, and download data once the query has finished — downloading +with `collect()` later — rather than waiting for the request to finish within R. + +```{r, eval = FALSE} +# Create and send query to be calculated server-side +request <- request_data() |> + identify("perameles") |> + filter(year > 1900) |> + compute() + +# Download data +request |> + collect() +``` + +# metadata requests + +For the above workflow to be achivable, it is neccessary for every API call +in `galah` to be written as a `request` object. This is because `compound()` +must collect a range of different requests to evaluate a single query. +To this end, `galah` supports metadata requests, in addition to the data +requests described above. + + +```{r, eval = FALSE} +request_metadata(type = "fields") |> + collect() +``` + +Or to show values for states and territories: + +```{r, eval = FALSE} +request_metadata() |> + filter(field == "cl22") |> + unnest() |> + collect() +``` + +While `request_metadata()` is more modular than `show_all()`, there is +little benefit to using it for most applications. However, in some cases, +larger databases like GBIF return huge `data.frame`s of metadata when called +via `show_all()`. Using `request_metdata()` allows users to specify a +`slice_head()` line within their pipe to get around this issue. \ No newline at end of file diff --git a/vignettes/quick_start_guide.Rmd b/vignettes/quick_start_guide.Rmd index 8f9ada29..9cac39f4 100644 --- a/vignettes/quick_start_guide.Rmd +++ b/vignettes/quick_start_guide.Rmd @@ -1,7 +1,7 @@ --- title: "Quick start guide" author: "Martin Westgate & Dax Kellie" -date: '2026-02-09' +date: '2026-02-11' output: rmarkdown::html_vignette vignette: > @@ -79,13 +79,13 @@ galah_call() |> # open a pipe ## # A tibble: 7 × 2 ## year count ## -## 1 2024 11884223 -## 2 2023 11007008 -## 3 2022 9429562 -## 4 2025 9132194 -## 5 2021 8692767 -## 6 2020 7311538 -## 7 2026 256885 +## 1 2024 11889930 +## 2 2023 11007491 +## 3 2022 9430065 +## 4 2025 9142677 +## 5 2021 8695248 +## 6 2020 7311836 +## 7 2026 309836 ``` Or to find the number of categories present in a dataset, for example how many @@ -122,14 +122,14 @@ galah_call() |> ``` ## Rows: 21,984 ## Columns: 8 -## $ taxonConceptID "https://biodiversity.org.au/afd/taxa/9b4ad548-8bb3-486a-ab0a-905506c463ea", "https://biodiversity.org.au/afd/taxa/9b4ad548-8bb3-486a-ab0a-905506c46… -## $ eventDate 1.281139e+12, 1.292890e+12, 1.270944e+12 -## $ scientificName "Eolophus roseicapilla", "Eolophus roseicapilla", "Eolophus roseicapilla" -## $ decimalLatitude -36.975, -31.600, -35.240 -## $ decimalLongitude 143.8083, 116.5200, 138.9100 -## $ basisOfRecord "HUMAN_OBSERVATION", "HUMAN_OBSERVATION", "HUMAN_OBSERVATION" -## $ dataResourceName "Victorian Biodiversity Atlas", "BirdLife Australia, Birdata", "BirdLife Australia, Birdata" -## $ occurrenceStatus "PRESENT", "PRESENT", "PRESENT" +## $ taxonConceptID "https://biodiversity.org.au/afd/taxa/9b4ad548-8bb3-486a-ab0a-905506c463ea", "https://biodiversity.org.au… +## $ eventDate 1.272672e+12, 1.289002e+12, 1.291014e+12 +## $ scientificName "Eolophus roseicapilla", "Eolophus roseicapilla", "Eolophus roseicapilla" +## $ decimalLatitude -25.98833, -37.83032, -35.41707 +## $ decimalLongitude 152.0442, 144.9812, 138.6868 +## $ basisOfRecord "HUMAN_OBSERVATION", "HUMAN_OBSERVATION", "HUMAN_OBSERVATION" +## $ dataResourceName "BirdLife Australia, Birdata", "eBird Australia", "eBird Australia" +## $ occurrenceStatus "PRESENT", "ABSENT", "ABSENT" ``` And, once satisfied that your parameters are correct, download the records @@ -150,15 +150,15 @@ galah_call() |> ## # A tibble: 21,984 × 3 ## eventDate decimalLatitude species ## -## 1 NA -38.2 Eolophus roseicapilla +## 1 NA -36.5 Eolophus roseicapilla ## 2 NA -38.2 Eolophus roseicapilla -## 3 NA -36.1 Eolophus roseicapilla -## 4 NA -38.5 Eolophus roseicapilla +## 3 NA -37.0 Eolophus roseicapilla +## 4 NA -37.7 Eolophus roseicapilla ## 5 NA -35.6 Eolophus roseicapilla -## 6 NA -38.3 Eolophus roseicapilla +## 6 NA -31.1 Eolophus roseicapilla ## 7 NA -38.2 Eolophus roseicapilla ## 8 NA -38.2 Eolophus roseicapilla -## 9 NA -38.3 Eolophus roseicapilla +## 9 NA -38.2 Eolophus roseicapilla ## 10 NA -38.2 Eolophus roseicapilla ## # ℹ 21,974 more rows ``` @@ -277,7 +277,7 @@ galah_call() |> ## # A tibble: 1 × 1 ## count ## -## 1 9132194 +## 1 9142677 ``` Or occurrences: @@ -294,15 +294,16 @@ galah_call() |> ``` ## # A tibble: 2,032 × 9 -## recordID scientificName taxonConceptID decimalLatitude decimalLongitude eventDate basisOfRecord occurrenceStatus dataResourceName -## -## 1 0026d29f-b6ab-4a1d-9c57-6ee12cfde3a0 Eolophus roseicapil… https://biodi… -35.4 149. 2000-08-07 00:00:00 HUMAN_OBSERV… PRESENT Garden Bird Sur… -## 2 0062d446-007b-4164-ac4f-ae84297e2578 Eolophus roseicapil… https://biodi… -35.3 149. 2000-03-10 00:00:00 HUMAN_OBSERV… PRESENT BirdLife Austra… -## 3 00a62ee0-1e08-4114-b0d8-9b7905472d53 Eolophus roseicapil… https://biodi… -35.2 149. 2000-01-29 00:00:00 HUMAN_OBSERV… PRESENT Garden Bird Sur… -## 4 00ab2f4d-326f-4b01-9a8a-1a10c1f77e3c Eolophus roseicapil… https://biodi… -35.4 149. 2000-09-25 00:00:00 HUMAN_OBSERV… PRESENT Garden Bird Sur… -## 5 00ae4631-ea59-44ec-b8f8-4377b6b3b3ef Eolophus roseicapil… https://biodi… -35.3 149. 2000-02-12 00:00:00 HUMAN_OBSERV… PRESENT BirdLife Austra… -## 6 00b6c8ec-e7b9-4d9f-9638-d962b1b4acfa Eolophus roseicapil… https://biodi… -35.2 149. 2000-02-05 00:00:00 HUMAN_OBSERV… PRESENT Garden Bird Sur… +## recordID scientificName taxonConceptID decimalLatitude decimalLongitude eventDate basisOfRecord occurrenceStatus +## +## 1 0026d29f-b6ab-4… Eolophus rose… https://biodi… -35.4 149. 2000-08-07 00:00:00 HUMAN_OBSERV… PRESENT +## 2 0062d446-007b-4… Eolophus rose… https://biodi… -35.3 149. 2000-03-10 00:00:00 HUMAN_OBSERV… PRESENT +## 3 00a62ee0-1e08-4… Eolophus rose… https://biodi… -35.2 149. 2000-01-29 00:00:00 HUMAN_OBSERV… PRESENT +## 4 00ab2f4d-326f-4… Eolophus rose… https://biodi… -35.4 149. 2000-09-25 00:00:00 HUMAN_OBSERV… PRESENT +## 5 00ae4631-ea59-4… Eolophus rose… https://biodi… -35.3 149. 2000-02-12 00:00:00 HUMAN_OBSERV… PRESENT +## 6 00b6c8ec-e7b9-4… Eolophus rose… https://biodi… -35.2 149. 2000-02-05 00:00:00 HUMAN_OBSERV… PRESENT ## # ℹ 2,026 more rows +## # ℹ 1 more variable: dataResourceName ``` `atlas_species()` replaces the need for `distinct()` call, while `atlas_media()`