Skip to content
Open
2 changes: 2 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* Implemented an `AnnDataView` class, which provides a lazy view of an `AnnData` object without copying data (PR #1096)
* Implemented S3 methods for `AbstractAnnData` objects: `dim`, `nrow`, `ncol`, `dimnames`, `rownames`, `colnames`, and `[` (PR #1096)
* Add `ReticulateAnnData` class for seamless Python integration via **{reticulate}** (PR #322)
* Added S4 `as()` coercions linking AnnData implementations with `SingleCellExperiment` and `Seurat` objects (PR #358)

## Major changes

Expand All @@ -29,6 +30,7 @@
- Add checks for type arguments to `generate_dataset()` (PR #354)
- Generalise the layers created by `generate_dataset()` when `format = "Seurat"`
(PR #354)
- Fix inconsistency in `output_class` argument values across conversion functions (PR #358)

## Bug fixes

Expand Down
1 change: 0 additions & 1 deletion R/AnnDataView.R
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,6 @@ AnnDataView <- R6::R6Class(
#' @param context_name Name for error messages ("observations" or "variables")
#'
#' @return Integer vector of indices, or NULL if subset is NULL
#' @keywords internal
#' @noRd
convert_to_indices <- function(
subset,
Expand Down
231 changes: 231 additions & 0 deletions R/as-coercions.R
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not for this PR but I think we should be more consistent about if/when we use the . prefix for internal functions

Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
#' Coercion helpers for `as()`
#'
#' These helper registrations wire up S4-style `as()` conversions so that
#' AnnData implementations (including [`InMemoryAnnData`], [`HDF5AnnData`], and
#' [`ReticulateAnnData`]) as well as [`SingleCellExperiment`] and
#' [`SeuratObject::Seurat`] objects can be coerced
#' between one another without the caller needing to know the underlying helper
#' functions. Because `as()` cannot accept additional arguments, conversions
#' that require them (such as writing HDF5-backed AnnData objects) raise an
#' informative error pointing to the richer interface.
#'
#' @noRd
NULL

# Class compatibility registrations -----------------------------------------

.register_oldclass <- function(class, super = character()) {
if (!methods::isClass(class)) {
methods::setOldClass(c(class, super))
}
}

.register_oldclass("AbstractAnnData", "R6")
.register_oldclass("InMemoryAnnData", c("AbstractAnnData", "R6"))
.register_oldclass("HDF5AnnData", c("AbstractAnnData", "R6"))
.register_oldclass("ReticulateAnnData", c("AbstractAnnData", "R6"))
.register_oldclass("AnnDataView", c("AbstractAnnData", "R6"))

.as_abort_extra_args <- function(from, to, helper) {
cli::cli_abort(
c(
"Can't coerce {.cls {from}} to {.cls {to}} with {.fun as} as extra arguments are required",
"i" = helper
),
call = rlang::caller_env()
)
}

.warn_as_limited <- function(recommendation) {
cli::cli_warn(
c(
"Using {.fun as} to coerce object limits control over data mapping",
"i" = recommendation
),
call = rlang::caller_env()
)
}

# Handler constructors -------------------------------------------------------

.make_convert_handler <- function(converter, warn = NULL, pre = NULL) {
force(warn)
force(pre)
Comment on lines +46 to +47
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you need this (just curious)?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It causes these arguments to be evaluated eagerly instead of lazily

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I figured that, I just wasn't sure why that was needed 😸


function(from) {
if (!is.null(pre)) {
pre(from)
}
if (!is.null(warn)) {
.warn_as_limited(warn)
}
converter(from)
}
}

.make_abort_handler <- function(from_class, to_class, helper) {
force(from_class)
force(to_class)
force(helper)

function(from) {
.as_abort_extra_args(from_class, to_class, helper)
}
}

.register_set_as_rules <- function(rules) {
for (rule in rules) {
methods::setAs(rule$from, rule$to, rule$handler)
}
}

.format_control_recommendation <- function(call_expr) {
sprintf(
"Prefer {.code %s} for fine-grained control over data mapping",
call_expr
)
}

# AnnData <-> AnnData coercion rules ----------------------------------------
warn_ann_inmemory <- .format_control_recommendation(
"adata$as_InMemoryAnnData(...)"
)
warn_ann_reticulate <- .format_control_recommendation(
"adata$as_ReticulateAnnData(...)"
)

anndata_rules <- list(
list(
from = "AbstractAnnData",
to = "InMemoryAnnData",
handler = .make_convert_handler(
converter = as_InMemoryAnnData,
warn = warn_ann_inmemory
)
),
list(
from = "AbstractAnnData",
to = "ReticulateAnnData",
handler = .make_convert_handler(
converter = as_ReticulateAnnData,
warn = warn_ann_reticulate
)
),
list(
from = "AbstractAnnData",
to = "HDF5AnnData",
handler = .make_abort_handler(
from_class = "AbstractAnnData",
to_class = "HDF5AnnData",
helper = "Use {.code adata$as_HDF5AnnData(file = <path>)} to provide the output file"
)
)
)

.register_set_as_rules(anndata_rules)

# SingleCellExperiment coercion rules ---------------------------------------

if (rlang::is_installed("SingleCellExperiment")) {
warn_customise <- .format_control_recommendation("as_AnnData(...)")
warn_sce <- .format_control_recommendation(
"adata$as_SingleCellExperiment(...)"
)

single_cell_rules <- list(
list(
from = "SingleCellExperiment",
to = "InMemoryAnnData",
handler = .make_convert_handler(
converter = function(from) {
as_AnnData(from, output_class = "InMemoryAnnData")
},
warn = warn_customise
)
),
list(
from = "SingleCellExperiment",
to = "ReticulateAnnData",
handler = .make_convert_handler(
converter = function(from) {
as_AnnData(from, output_class = "ReticulateAnnData")
},
warn = warn_customise
)
),
list(
from = "SingleCellExperiment",
to = "HDF5AnnData",
handler = .make_abort_handler(
from_class = "SingleCellExperiment",
to_class = "HDF5AnnData",
helper = paste(
"Use {.code as_AnnData(from, output_class = \"HDF5AnnData\",",
"filename = <path>)} to provide the output file"
)
)
),
list(
from = "AbstractAnnData",
to = "SingleCellExperiment",
handler = .make_convert_handler(
converter = as_SingleCellExperiment,
warn = warn_sce
)
)
)

.register_set_as_rules(single_cell_rules)
}

# Seurat coercion rules ------------------------------------------------------

if (rlang::is_installed("SeuratObject")) {
warn_customise <- .format_control_recommendation("as_AnnData(...)")
warn_seurat <- .format_control_recommendation("adata$as_Seurat(...)")

seurat_rules <- list(
list(
from = "Seurat",
to = "InMemoryAnnData",
handler = .make_convert_handler(
converter = function(from) {
as_AnnData(from, output_class = "InMemoryAnnData")
},
warn = warn_customise
)
),
list(
from = "Seurat",
to = "ReticulateAnnData",
handler = .make_convert_handler(
converter = function(from) {
as_AnnData(from, output_class = "ReticulateAnnData")
},
warn = warn_customise
)
),
list(
from = "Seurat",
to = "HDF5AnnData",
handler = .make_abort_handler(
from_class = "Seurat",
to_class = "HDF5AnnData",
helper = paste(
"Use {.code as_AnnData(from, output_class = \"HDF5AnnData\",",
"filename = <path>)} to provide the output file"
)
)
),
list(
from = "AbstractAnnData",
to = "Seurat",
handler = .make_convert_handler(
converter = as_Seurat,
warn = warn_seurat
)
)
)

.register_set_as_rules(seurat_rules)
}
6 changes: 3 additions & 3 deletions R/as_AnnData.R
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ as_AnnData <- function(
varp_mapping = TRUE,
uns_mapping = TRUE,
assay_name = NULL,
output_class = c("InMemory", "HDF5AnnData", "ReticulateAnnData"),
output_class = c("InMemoryAnnData", "HDF5AnnData", "ReticulateAnnData"),
...
) {
UseMethod("as_AnnData", x)
Expand All @@ -182,7 +182,7 @@ as_AnnData.SingleCellExperiment <- function(
varp_mapping = TRUE,
uns_mapping = TRUE,
assay_name = TRUE,
output_class = c("InMemory", "HDF5AnnData", "ReticulateAnnData"),
output_class = c("InMemoryAnnData", "HDF5AnnData", "ReticulateAnnData"),
...
) {
from_SingleCellExperiment(
Expand Down Expand Up @@ -215,7 +215,7 @@ as_AnnData.Seurat <- function(
varp_mapping = TRUE,
uns_mapping = TRUE,
assay_name = NULL,
output_class = c("InMemory", "HDF5AnnData", "ReticulateAnnData"),
output_class = c("InMemoryAnnData", "HDF5AnnData", "ReticulateAnnData"),
...
) {
from_Seurat(
Expand Down
2 changes: 1 addition & 1 deletion R/from_Seurat.R
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ from_Seurat <- function(
obsp_mapping = TRUE,
varp_mapping = TRUE,
uns_mapping = TRUE,
output_class = c("InMemory", "HDF5AnnData", "ReticulateAnnData"),
output_class = c("InMemoryAnnData", "HDF5AnnData", "ReticulateAnnData"),
...
) {
check_requires("Converting Seurat to AnnData", c("SeuratObject", "Seurat"))
Expand Down
2 changes: 1 addition & 1 deletion R/from_SingleCellExperiment.R
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ from_SingleCellExperiment <- function(
obsp_mapping = TRUE,
varp_mapping = TRUE,
uns_mapping = TRUE,
output_class = c("InMemory", "HDF5AnnData", "ReticulateAnnData"),
output_class = c("InMemoryAnnData", "HDF5AnnData", "ReticulateAnnData"),
...
) {
check_requires(
Expand Down
6 changes: 3 additions & 3 deletions man/as_AnnData.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions man/generate_dataset.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading