diff --git a/.github/workflows/test-coverage.yaml b/.github/workflows/test-coverage.yaml index 0b00e9f..09cb781 100644 --- a/.github/workflows/test-coverage.yaml +++ b/.github/workflows/test-coverage.yaml @@ -37,7 +37,8 @@ jobs: shell: Rscript {0} run: | reticulate::install_miniconda() - reticulate::py_install(c("numpy", "pandas")) + # pyarrow lets the use_arrow = TRUE conversion path be exercised + reticulate::py_install(c("numpy", "pandas", "pyarrow")) pak::local_install() cat(sprintf("RETICULATE_PYTHON=%s\n", reticulate::py_config()$python), file = Sys.getenv("GITHUB_ENV"), append = TRUE) diff --git a/tests/testthat/test-convert.R b/tests/testthat/test-convert.R index 85efbc6..12e090b 100644 --- a/tests/testthat/test-convert.R +++ b/tests/testthat/test-convert.R @@ -119,6 +119,63 @@ test_that("pandas2df recovers 64-bit ids and object columns", { expect_identical(out$label, c("a", "b")) }) +test_that("pandas2df flattens object columns and normalises datetimes", { + skip_if_no_module("pandas") + # An object column of python ints (flattened to an atomic vector), a genuine + # list-valued object column (a multi-select, left as a list), an all-None + # object column (-> NA), and a datetime column (-> UTC POSIXct). + df <- reticulate::py_eval( + paste0("__import__('pandas').DataFrame({", + "'n': __import__('pandas').Series([1, 2, 3], dtype=object), ", + "'tags': __import__('pandas').Series([['AB'], ['CD'], []], dtype=object), ", + "'blank': __import__('pandas').Series([None, None, None], dtype=object), ", + "'when': __import__('pandas').to_datetime(", + "['2020-01-01', '2020-01-02', '2020-01-03'])})"), + convert = FALSE) + out <- pandas2df(df) + # object ints flattened to a plain atomic vector, not a list of length-1s + expect_false(is.list(out$n)) + expect_identical(out$n, c(1L, 2L, 3L)) + # a genuine list-valued (multi-select) column is left intact as a list + expect_true(is.list(out$tags)) + expect_identical(out$tags[[1]], "AB") + expect_length(out$tags[[3]], 0L) + # an all-None object column collapses to NA + expect_true(all(is.na(out$blank))) + # datetimes come back as UTC POSIXct + expect_s3_class(out$when, "POSIXct") + expect_identical(attr(out$when, "tzone"), "UTC") + expect_identical(as.character(as.Date(out$when)), + c("2020-01-01", "2020-01-02", "2020-01-03")) +}) + +test_that("pandas2df use_arrow path round-trips via a feather file", { + skip_if_no_module("pandas") + skip_if_no_module("pyarrow") # pandas.to_feather needs pyarrow + skip_if_not_installed("arrow") # R arrow::read_feather + df <- reticulate::py_eval( + "__import__('pandas').DataFrame({'label': ['a', 'b', 'c'], 'n': [1, 2, 3]})", + convert = FALSE) + out <- pandas2df(df, use_arrow = TRUE) + # the arrow path always returns a tibble + expect_s3_class(out, "tbl_df") + expect_identical(out$label, c("a", "b", "c")) + expect_equal(as.integer(out$n), 1:3) +}) + +test_that("pandas2df use_arrow handles an empty frame", { + skip_if_no_module("pandas") + skip_if_not_installed("arrow") + skip_if_not_installed("tibble") + # the empty-frame branch returns via py_to_r + tibble, without touching arrow + df <- reticulate::py_eval( + "__import__('pandas').DataFrame({'label': [], 'n': []})", convert = FALSE) + out <- pandas2df(df, use_arrow = TRUE) + expect_s3_class(out, "tbl_df") + expect_identical(nrow(out), 0L) + expect_identical(names(out), c("label", "n")) +}) + test_that("pandas2df fast-path preserves column order and nullable ids", { skip_if_no_module("pandas") ids <- c("720575940621039145", "720575940626877799") diff --git a/tests/testthat/test-env.R b/tests/testthat/test-env.R new file mode 100644 index 0000000..a2a14f1 --- /dev/null +++ b/tests/testthat/test-env.R @@ -0,0 +1,45 @@ +# Pure-R helpers in env.R. The provisioning machinery itself (install_miniconda, +# conda_install, ...) has real side effects and is not unit-tested here; these +# cover the option/env plumbing and the non-destructive "print the command" +# branches of simple_python_base(). + +test_that("np_condaenv honours the option and defaults to r-reticulate", { + withr::local_options(nat.python.condaenv = NULL) + expect_identical(np_condaenv(), "r-reticulate") + withr::local_options(nat.python.condaenv = "my-env") + expect_identical(np_condaenv(), "my-env") +}) + +test_that("ownpythonrequested reflects RETICULATE_PYTHON", { + withr::local_envvar(RETICULATE_PYTHON = "") + expect_false(ownpythonrequested()) + withr::local_envvar(RETICULATE_PYTHON = "/opt/python/bin/python") + expect_true(ownpythonrequested()) +}) + +test_that("checkownpython aborts for a non-standard Python", { + withr::local_envvar(RETICULATE_PYTHON = "") + # miniconda = FALSE means the user asked for their own Python + expect_error(checkownpython(miniconda = FALSE), "on your own") + withr::local_envvar(RETICULATE_PYTHON = "/opt/python/bin/python") + expect_error(checkownpython(miniconda = TRUE), "on your own") + # standard managed setup: no abort + withr::local_envvar(RETICULATE_PYTHON = "") + expect_silent(checkownpython(miniconda = TRUE)) +}) + +test_that("check_reticulate is a no-op when check_python = FALSE", { + expect_true(check_reticulate(check_python = FALSE)) + expect_invisible(check_reticulate(check_python = FALSE)) +}) + +test_that("simple_python_base blast only prints and deletes nothing", { + withr::local_envvar(RETICULATE_PYTHON = "") + # the blast branch just prints the (destructive) unlink command; it must + # never touch the filesystem itself, and returns invisibly + existed <- dir.exists(reticulate::miniconda_path()) + expect_invisible(res <- simple_python_base("blast", miniconda = TRUE)) + expect_null(res) + # the miniconda directory is left exactly as it was + expect_identical(dir.exists(reticulate::miniconda_path()), existed) +}) diff --git a/tests/testthat/test-int64.R b/tests/testthat/test-int64.R index 00327c8..78b17bf 100644 --- a/tests/testthat/test-int64.R +++ b/tests/testthat/test-int64.R @@ -24,3 +24,53 @@ test_that("rids2raw honours endianness", { expect_false(identical(rids2raw(1:3, endian = "little"), rids2raw(1:3, endian = "big"))) }) + +test_that("pyids2bit64 round-trips a numpy int64 array", { + skip_if_no_module("numpy") + np <- reticulate::import("numpy", convert = FALSE) + ids <- c("123", "720575940621039145", "9223372036854775807") + arr <- np$array(ids, dtype = "int64") + # default: character out + expect_identical(pyids2bit64(arr), ids) + # as_character = FALSE: an exact integer64 vector + i64 <- pyids2bit64(arr, as_character = FALSE) + expect_s3_class(i64, "integer64") + expect_identical(as.character(i64), ids) +}) + +test_that("pyids2bit64 accepts uint64 within signed range and rejects overflow", { + skip_if_no_module("numpy") + np <- reticulate::import("numpy", convert = FALSE) + ok <- np$array(c("123", "9223372036854775807"), dtype = "uint64") + expect_identical(pyids2bit64(ok), c("123", "9223372036854775807")) + # a uint64 value beyond the signed range cannot be represented as int64 + ov <- np$array(c("18446744073709551615"), dtype = "uint64") + expect_error(pyids2bit64(ov), "int64 overflow") + # unsupported dtype is rejected + f <- np$array(c("1.5"), dtype = "float64") + expect_error(pyids2bit64(f), "dtype=int64 or uint64") +}) + +test_that("pyids2bit64 handles an empty array", { + skip_if_no_module("numpy") + np <- reticulate::import("numpy", convert = FALSE) + empty <- np$array(list(), dtype = "int64") + expect_identical(pyids2bit64(empty), character()) + expect_identical(pyids2bit64(empty, as_character = FALSE), bit64::integer64()) +}) + +test_that("rids2pyint round-trips R ids back through pyids2bit64", { + skip_if_no_module("numpy") + ids <- c("123", "720575940621039145", "9223372036854775807") + # in-memory string path (default for short vectors) + arr <- rids2pyint(ids, numpyarray = TRUE) + expect_identical(pyids2bit64(arr), ids) + # a Python list of ints is the non-numpyarray default + lst <- rids2pyint(ids) + expect_s3_class(lst, "python.builtin.list") + # the file-marshalling path (usefile = TRUE) yields the same ids + arr2 <- rids2pyint(ids, numpyarray = TRUE, usefile = TRUE) + expect_identical(pyids2bit64(arr2), ids) + # a numpy array passed in is returned as-is + expect_identical(pyids2bit64(rids2pyint(arr, numpyarray = TRUE)), ids) +}) diff --git a/tests/testthat/test-time.R b/tests/testthat/test-time.R new file mode 100644 index 0000000..827b832 --- /dev/null +++ b/tests/testthat/test-time.R @@ -0,0 +1,20 @@ +# ts2pydatetime: the early-return for an already-converted datetime is pure R; +# the conversion path needs the Python datetime module. + +test_that("ts2pydatetime returns an existing python datetime unchanged", { + # a value already carrying the datetime.datetime class is passed straight + # back, no Python needed + fake <- structure(list(), class = "datetime.datetime") + expect_identical(ts2pydatetime(fake), fake) +}) + +test_that("ts2pydatetime converts an R time to a UTC python datetime", { + skip_if_no_module("datetime") + t <- as.POSIXct("2020-01-02 03:04:05", tz = "UTC") + py <- ts2pydatetime(t) + expect_s3_class(py, "datetime.datetime") + # tzinfo is made explicit (UTC), and the instant round-trips exactly + expect_true(reticulate::py_to_r(py$tzinfo == reticulate::import("datetime")$timezone$utc)) + back <- as.numeric(reticulate::py_to_r(py$timestamp())) + expect_equal(back, as.numeric(t)) +}) diff --git a/tests/testthat/test-utils.R b/tests/testthat/test-utils.R new file mode 100644 index 0000000..a4f2726 --- /dev/null +++ b/tests/testthat/test-utils.R @@ -0,0 +1,31 @@ +# Internal utility helpers in utils.R. check_suggested is pure R; py_np needs +# numpy and is skipped when Python is unavailable. + +test_that("check_suggested passes for an installed package", { + # reticulate is a hard dependency, so it is always present + expect_true(check_suggested("reticulate")) + expect_invisible(check_suggested("reticulate")) +}) + +test_that("check_suggested errors for a missing package with instructions", { + nope <- "a.package.that.does.not.exist" + expect_error(check_suggested(nope), "is required") + expect_error(check_suggested(nope), "install.packages") + # the purpose string is woven into the message when supplied + expect_error(check_suggested(nope, "for the widget path"), + "for the widget path") +}) + +test_that("py_np imports and caches the numpy module", { + skip_if_no_module("numpy") + # clear any cached handle so we exercise the import branch + rm(list = ls(.nat_python_cache), envir = .nat_python_cache) + np1 <- py_np() + expect_s3_class(np1, "python.builtin.module") + # second call is served from the session cache -- same object handle + np2 <- py_np() + expect_identical(np1, np2) + # convert = TRUE is cached under a separate key + np3 <- py_np(convert = TRUE) + expect_s3_class(np3, "python.builtin.module") +})