From 7cf91ed64147c66575ad397a7899f9dfff7eba78 Mon Sep 17 00:00:00 2001 From: Philipp A Date: Fri, 14 Jul 2023 13:40:11 +0200 Subject: [PATCH] Ruff (#112) --- .pre-commit-config.yaml | 8 +-- .vscode/settings.json | 7 ++- docs/conf.py | 13 ++-- docs/ext/r_links.py | 62 ++++++++++++------- pyproject.toml | 34 ++++++++-- src/anndata2ri/__init__.py | 39 +++++++----- src/anndata2ri/{conv.py => _conv.py} | 26 +++++--- .../{conv_name.py => _conv_name.py} | 3 + src/anndata2ri/{py2r.py => _py2r.py} | 38 +++++++----- src/anndata2ri/{r2py.py => _r2py.py} | 61 ++++++++++-------- src/anndata2ri/_rpy2_ext.py | 18 ++++++ src/anndata2ri/rpy2_ext.py | 18 ------ src/anndata2ri/scipy2ri/__init__.py | 40 +++++++----- src/anndata2ri/scipy2ri/{conv.py => _conv.py} | 15 +++-- src/anndata2ri/scipy2ri/{py2r.py => _py2r.py} | 47 ++++++++------ src/anndata2ri/scipy2ri/{r2py.py => _r2py.py} | 27 +++++--- .../scipy2ri/{support.py => _support.py} | 23 ++++--- src/anndata2ri/test_utils.py | 58 ++++++++++------- tests/test_py2rpy.py | 41 ++++++++---- tests/test_rpy2py.py | 34 +++++++--- tests/test_scipy_py2rpy.py | 18 +++++- tests/test_scipy_rpy2py.py | 37 +++++++---- 22 files changed, 428 insertions(+), 239 deletions(-) rename src/anndata2ri/{conv.py => _conv.py} (75%) rename src/anndata2ri/{conv_name.py => _conv_name.py} (95%) rename src/anndata2ri/{py2r.py => _py2r.py} (75%) rename src/anndata2ri/{r2py.py => _r2py.py} (69%) create mode 100644 src/anndata2ri/_rpy2_ext.py delete mode 100644 src/anndata2ri/rpy2_ext.py rename src/anndata2ri/scipy2ri/{conv.py => _conv.py} (70%) rename src/anndata2ri/scipy2ri/{py2r.py => _py2r.py} (79%) rename src/anndata2ri/scipy2ri/{r2py.py => _r2py.py} (67%) rename src/anndata2ri/scipy2ri/{support.py => _support.py} (64%) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e24a421..ef42bee 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,14 +2,14 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: - - id: double-quote-string-fixer - id: trailing-whitespace - repo: https://github.com/psf/black rev: 23.3.0 hooks: - id: black language_version: python3 -- repo: https://github.com/pycqa/isort - rev: 5.12.0 +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.0.278 hooks: - - id: isort + - id: ruff + args: [--fix, --exit-non-zero-on-fix] diff --git a/.vscode/settings.json b/.vscode/settings.json index 4d1d792..660bf28 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,9 +2,10 @@ "[python]": { "editor.formatOnSave": true, "editor.defaultFormatter": "ms-python.black-formatter", - //"editor.codeActionsOnSave": { - // "source.fixAll.ruff": true, - //}, + "editor.codeActionsOnSave": { + "source.fixAll.ruff": true, + "source.organizeImports.ruff": true, + }, }, "python.testing.pytestArgs": [], "python.testing.unittestEnabled": false, diff --git a/docs/conf.py b/docs/conf.py index 0266bcd..657e66b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,6 +1,8 @@ +"""Sphinx configuration.""" + import sys from abc import ABC -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path from unittest.mock import MagicMock, patch @@ -11,8 +13,8 @@ from importlib_metadata import metadata -def mock_rpy2(): - # Can’t use autodoc_mock_imports as we import anndata2ri +def mock_rpy2() -> None: + """Can’t use autodoc_mock_imports as we import anndata2ri.""" patch('rpy2.situation.get_r_home', lambda: None).start() sys.modules['rpy2.rinterface_lib'] = MagicMock() submods = ['embedded', 'conversion', 'memorymanagement', 'sexp', 'bufferprotocol', 'callbacks', '_rinterface_capi'] @@ -27,7 +29,7 @@ def mock_rpy2(): import rpy2.rinterface_lib.sexp rpy2.rinterface_lib = sys.modules['rpy2.rinterface_lib'] - rpy2.rinterface._MissingArgType = object + rpy2.rinterface._MissingArgType = object # noqa: SLF001 rpy2.rinterface.initr_simple = lambda *_, **__: None assert rpy2.rinterface_lib.sexp is sexp @@ -48,7 +50,7 @@ def mock_rpy2(): project = 'anndata2ri' meta = metadata(project) author = meta['author-email'].split('"')[1] -copyright = f'{datetime.now():%Y}, {author}.' +copyright = f'{datetime.now(tz=timezone.utc):%Y}, {author}.' # noqa: A001 version = meta['version'] release = version @@ -56,7 +58,6 @@ def mock_rpy2(): templates_path = ['_templates'] source_suffix = '.rst' master_doc = 'index' -# default_role = '?' exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] pygments_style = 'sphinx' diff --git a/docs/ext/r_links.py b/docs/ext/r_links.py index d69c97e..adff46e 100644 --- a/docs/ext/r_links.py +++ b/docs/ext/r_links.py @@ -1,22 +1,34 @@ +"""Sphinx extension for links to R documentation.""" + +from __future__ import annotations + import logging -from typing import Tuple +from typing import TYPE_CHECKING, ClassVar from docutils import nodes -from sphinx.application import Sphinx -from sphinx.environment import BuildEnvironment from sphinx.roles import XRefRole +if TYPE_CHECKING: + from sphinx.application import Sphinx + from sphinx.environment import BuildEnvironment + + class RManRefRole(XRefRole): - nodeclass = nodes.reference + """R reference role.""" + + nodeclass: ClassVar[nodes.Node] = nodes.reference + topic_cache: ClassVar[dict[str, dict[str, str]]] = {} + """pkg → alias → url""" - topic_cache = {} + cls: bool - def __init__(self, *a, cls: bool = False, **kw): + def __init__(self, *a, cls: bool = False, **kw) -> None: # noqa: ANN002, ANN003 + """Set self.cls.""" super().__init__(*a, **kw) self.cls = cls - def _get_man(self, pkg: str, alias: str): + def _get_man(self, pkg: str, alias: str) -> str: from urllib.error import HTTPError pkg_cache = type(self).topic_cache.setdefault(pkg) @@ -25,14 +37,14 @@ def _get_man(self, pkg: str, alias: str): try: pkg_cache = self._fetch_cache(repo, pkg) break - except HTTPError: + except HTTPError: # noqa: PERF203 pass else: return None type(self).topic_cache[pkg] = pkg_cache return pkg_cache.get(alias) - def _fetch_cache(self, repo: str, pkg: str): + def _fetch_cache(self, repo: str, pkg: str) -> dict[str, str]: from urllib.parse import urljoin from urllib.request import urlopen @@ -41,13 +53,18 @@ def _fetch_cache(self, repo: str, pkg: str): if repo.startswith('R'): url = f'https://stat.ethz.ch/R-manual/{repo}/library/{pkg}/html/00Index.html' tr_xpath = '//tr' - get = lambda tr: (tr[0][0].text, tr[0][0].attrib['href']) + + def get(tr: html.HtmlElement) -> tuple[str, str]: + return tr[0][0].text, tr[0][0].attrib['href'] + else: url = f'https://rdrr.io/{repo}/{pkg}/api/' tr_xpath = "//div[@id='body-content']//tr[./td]" - get = lambda tr: (tr[0].text, tr[1][0].attrib['href']) - with urlopen(url) as con: + def get(tr: html.HtmlElement) -> tuple[str, str]: + return tr[0].text, tr[1][0].attrib['href'] + + with urlopen(url) as con: # noqa: S310 txt = con.read().decode(con.headers.get_content_charset()) doc = html.fromstring(txt) cache = {} @@ -57,8 +74,14 @@ def _fetch_cache(self, repo: str, pkg: str): return cache def process_link( - self, env: BuildEnvironment, refnode: nodes.reference, has_explicit_title: bool, title: str, target: str - ) -> Tuple[str, str]: + self, + env: BuildEnvironment, # noqa: ARG002 + refnode: nodes.reference, + has_explicit_title: bool, # noqa: ARG002, FBT001 + title: str, + target: str, + ) -> tuple[str, str]: + """Derive link title and URL from target.""" qualified = not target.startswith('~') if not qualified: target = target[1:] @@ -70,16 +93,11 @@ def process_link( url = self._get_man(package, topic) refnode['refuri'] = url if not url: - logging.warning(f'R topic {target} not found.') + logging.warning('R topic %s not found.', target) return title, url - # def result_nodes(self, document: nodes.document, env: BuildEnvironment, node: nodes.reference, is_ref: bool): - # target = node.get('reftarget') - # if target: - # node.attributes['refuri'] = target - # return [node], [] - -def setup(app: Sphinx): +def setup(app: Sphinx) -> None: + """Set Sphinx extension up.""" app.add_role('rman', RManRefRole()) app.add_role('rcls', RManRefRole(cls=True)) diff --git a/pyproject.toml b/pyproject.toml index 2f7ba16..95dcbb0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,11 +84,35 @@ filterwarnings = [ line-length = 120 skip-string-normalization = true -[tool.isort] -profile = 'black' -line_length = 120 -lines_after_imports = 2 -length_sort_straight = true +[tool.ruff] +line-length = 120 +select = ['ALL'] +ignore = [ + 'ANN101', # self type doesn’t need to be annotated + 'C408', # dict() calls are nice + 'COM812', # trailing commas handled by black + 'D203', # prefer 0 to 1 blank line before class members + 'D213', # prefer docstring summary on first line + 'FIX002', # “TODO” comments + 'PLR0913', # having many (kw)args is fine + 'S101', # asserts are fine +] +allowed-confusables = ['’', '×'] +[tool.ruff.per-file-ignores] +'src/**/*.py' = ['PT'] # No Pytest checks +'docs/**/*.py' = ['INP001'] # No __init__.py in docs +'tests/**/*.py' = [ + 'INP001', # No __init__.py in tests + 'D100', # test modules don’t need docstrings + 'D103', # tests don’t need docstrings + 'PD901', # “df” is a fine var name in tests + 'PLR2004', # magic numbers are fine in tests +] +[tool.ruff.isort] +known-first-party = ['anndata2ri'] +lines-after-imports = 2 +[tool.ruff.flake8-quotes] +inline-quotes = 'single' [build-system] requires = ['hatchling', 'hatch-vcs'] diff --git a/src/anndata2ri/__init__.py b/src/anndata2ri/__init__.py index 83fb97a..3addb41 100644 --- a/src/anndata2ri/__init__.py +++ b/src/anndata2ri/__init__.py @@ -1,6 +1,4 @@ -r""" -Converter between Python’s AnnData and R’s SingleCellExperiment. - +r"""Converter between Python’s AnnData and R’s SingleCellExperiment. ========================================================== = ======================================================== :rcls:`~SingleCellExperiment::SingleCellExperiment` :class:`~anndata.AnnData` @@ -14,17 +12,23 @@ :rman:`~SingleCellExperiment::reducedDim`\ ``(d, 'DM')`` ⇄ ``d.``\ :attr:`~anndata.AnnData.obsm`\ ``['X_diffmap']`` ========================================================== = ======================================================== """ -__all__ = ['activate', 'deactivate', 'py2rpy', 'rpy2py', 'converter'] +from __future__ import annotations from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING + +from . import _py2r, _r2py # noqa: F401 +from ._conv import activate, converter, deactivate -from rpy2.rinterface import Sexp -from . import py2r, r2py -from .conv import activate, converter, deactivate +if TYPE_CHECKING: + from anndata import AnnData + from pandas import DataFrame + from rpy2.rinterface import Sexp +__all__ = ['__version__', 'activate', 'deactivate', 'py2rpy', 'rpy2py', 'converter'] + HERE = Path(__file__).parent __author__ = 'Philipp Angerer' @@ -35,22 +39,25 @@ except (ImportError, LookupError): try: from ._version import __version__ - except ImportError: - raise ImportError('Cannot infer version. Make sure to `pip install` the project or install `setuptools-scm`.') + except ImportError as e: + msg = 'Cannot infer version. Make sure to `pip install` the project or install `setuptools-scm`.' + raise ImportError(msg) from e -def py2rpy(obj: Any) -> Sexp: - """ - Convert Python objects to R interface objects. Supports: +def py2rpy(obj: AnnData) -> Sexp: + """Convert Python objects to R interface objects. + + Supports: - :class:`~anndata.AnnData` → :rcls:`~SingleCellExperiment::SingleCellExperiment` """ return converter.py2rpy(obj) -def rpy2py(obj: Any) -> Sexp: - """ - Convert R interface objects to Python objects. Supports: +def rpy2py(obj: Sexp) -> AnnData | DataFrame: + """Convert R interface objects to Python objects. + + Supports: - :rcls:`~SingleCellExperiment::SingleCellExperiment` → :class:`~anndata.AnnData` - :rcls:`S4Vectors::DataFrame` → :class:`pandas.DataFrame` diff --git a/src/anndata2ri/conv.py b/src/anndata2ri/_conv.py similarity index 75% rename from src/anndata2ri/conv.py rename to src/anndata2ri/_conv.py index a4c2a00..b1b52fa 100644 --- a/src/anndata2ri/conv.py +++ b/src/anndata2ri/_conv.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import TYPE_CHECKING + import numpy as np import pandas as pd from rpy2.robjects import conversion, numpy2ri, pandas2ri @@ -8,13 +10,20 @@ from . import scipy2ri +if TYPE_CHECKING: + from collections.abc import Callable + + from rpy2.rinterface import Sexp + from scipy.sparse import spmatrix + + original_converter: conversion.Converter | None = None converter = conversion.Converter('original anndata conversion') _mat_converter = numpy2ri.converter + scipy2ri.converter -def mat_py2rpy(obj: np.ndarray) -> np.ndarray: +def mat_py2rpy(obj: np.ndarray | spmatrix | pd.DataFrame) -> Sexp: if isinstance(obj, pd.DataFrame): numeric_cols = obj.dtypes <= np.number if not numeric_cols.all(): @@ -25,7 +34,7 @@ def mat_py2rpy(obj: np.ndarray) -> np.ndarray: return _mat_converter.py2rpy(obj) -mat_rpy2py = _mat_converter.rpy2py +mat_rpy2py: Callable[[Sexp], np.ndarray | spmatrix | Sexp] = _mat_converter.rpy2py def full_converter() -> conversion.Converter: @@ -40,15 +49,16 @@ def full_converter() -> conversion.Converter: return new_converter -def activate(): - r""" - Activate conversion for :class:`~anndata.AnnData` objects +def activate() -> None: + r"""Activate conversion for supported objects. + + This includes :class:`~anndata.AnnData` objects as well as :ref:`numpy:arrays` and :class:`pandas.DataFrame`\ s via ``rpy2.robjects.numpy2ri`` and ``rpy2.robjects.pandas2ri``. Does nothing if this is the active converter. """ - global original_converter + global original_converter # noqa: PLW0603 if original_converter is not None: return @@ -58,9 +68,9 @@ def activate(): conversion.set_conversion(new_converter) -def deactivate(): +def deactivate() -> None: """Deactivate the conversion described above if it is active.""" - global original_converter + global original_converter # noqa: PLW0603 if original_converter is None: return diff --git a/src/anndata2ri/conv_name.py b/src/anndata2ri/_conv_name.py similarity index 95% rename from src/anndata2ri/conv_name.py rename to src/anndata2ri/_conv_name.py index 5fd9f1c..df9ed10 100644 --- a/src/anndata2ri/conv_name.py +++ b/src/anndata2ri/_conv_name.py @@ -1,3 +1,6 @@ +from __future__ import annotations + + dimension_reductions = { 'pca', 'dca', diff --git a/src/anndata2ri/py2r.py b/src/anndata2ri/_py2r.py similarity index 75% rename from src/anndata2ri/py2r.py rename to src/anndata2ri/_py2r.py index e655407..9136ef8 100644 --- a/src/anndata2ri/py2r.py +++ b/src/anndata2ri/_py2r.py @@ -1,21 +1,27 @@ +from __future__ import annotations + from collections.abc import Mapping +from typing import TYPE_CHECKING from warnings import warn import numpy as np -import pandas as pd from anndata import AnnData from rpy2.robjects import conversion, default_converter, pandas2ri from rpy2.robjects.conversion import localconverter -from rpy2.robjects.methods import RS4 from rpy2.robjects.vectors import ListVector -from . import conv_name -from .conv import converter, full_converter, mat_py2rpy -from .rpy2_ext import importr +from . import _conv_name +from ._conv import converter, full_converter, mat_py2rpy +from ._rpy2_ext import importr + + +if TYPE_CHECKING: + import pandas as pd + from rpy2.robjects.methods import RS4 class NotConvertedWarning(Warning): - pass + """A warning for elements that have not been converted.""" dict_converter = conversion.Converter('Converter handling dicts') @@ -26,15 +32,20 @@ class NotConvertedWarning(Warning): dict_converter.py2rpy.register(np.str_, lambda x: conversion.py2rpy(str(x))) +# TODO(flying-sheep): #111 set stacklevel +# https://github.com/theislab/anndata2ri/issues/111 +STACK_LEVEL = 2 + + @dict_converter.py2rpy.register(Mapping) def py2rpy_dict(obj: Mapping) -> ListVector: - """Try converting everything. For nested dicts, this needs itself to be registered""" + """Try converting everything. For nested dicts, this needs itself to be registered.""" converted = {} - for k, v in obj.items(): - try: + try: + for k, v in obj.items(): converted[str(k)] = conversion.py2rpy(v) - except NotImplementedError as e: - warn(str(e), NotConvertedWarning) + except NotImplementedError as e: + warn(str(e), NotConvertedWarning, stacklevel=STACK_LEVEL) # This tries to convert everything again. This works because py2rpy(Sexp) is the identity function return ListVector(converted) @@ -42,7 +53,7 @@ def py2rpy_dict(obj: Mapping) -> ListVector: def check_no_dupes(idx: pd.Index, name: str) -> bool: dupes = idx.duplicated().any() if dupes: - warn(f'Duplicated {name}: {idx[idx.duplicated(False)].sort_values()}') + warn(f'Duplicated {name}: {idx[idx.duplicated(keep=False)].sort_values()}', stacklevel=STACK_LEVEL + 1) return not dupes @@ -51,7 +62,6 @@ def py2rpy_anndata(obj: AnnData) -> RS4: with localconverter(default_converter): s4v = importr('S4Vectors') sce = importr('SingleCellExperiment') - # TODO: sparse x = {} if obj.X is None else dict(X=mat_py2rpy(obj.X.T)) layers = {k: mat_py2rpy(v.T) for k, v in obj.layers.items()} assays = ListVector({**x, **layers}) @@ -70,7 +80,7 @@ def py2rpy_anndata(obj: AnnData) -> RS4: with localconverter(full_converter() + dict_converter): metadata = ListVector(obj.uns.items()) - rd_args = {conv_name.scanpy2sce(k): mat_py2rpy(obj.obsm[k]) for k in obj.obsm.keys()} + rd_args = {_conv_name.scanpy2sce(k): mat_py2rpy(obj.obsm[k]) for k in obj.obsm} reduced_dims = s4v.SimpleList(**rd_args) return sce.SingleCellExperiment( diff --git a/src/anndata2ri/r2py.py b/src/anndata2ri/_r2py.py similarity index 69% rename from src/anndata2ri/r2py.py rename to src/anndata2ri/_r2py.py index c5c339d..8e1e1d0 100644 --- a/src/anndata2ri/r2py.py +++ b/src/anndata2ri/_r2py.py @@ -1,7 +1,6 @@ from __future__ import annotations -from collections.abc import Mapping -from typing import Optional, Union +from typing import TYPE_CHECKING, Any import pandas as pd from anndata import AnnData @@ -10,37 +9,49 @@ from rpy2.robjects.conversion import localconverter from rpy2.robjects.robject import RSlots -from . import conv_name -from .conv import converter, full_converter, mat_rpy2py -from .rpy2_ext import importr +from . import _conv_name +from ._conv import converter, full_converter, mat_rpy2py +from ._rpy2_ext import importr from .scipy2ri import supported_r_matrix_classes -from .scipy2ri.r2py import rmat_to_spmat +from .scipy2ri._r2py import rmat_to_spmat + + +if TYPE_CHECKING: + from collections.abc import Mapping + + import numpy as np + from scipy.sparse import spmatrix + + +R_INT_BYTES = 4 @converter.rpy2py.register(SexpS4) -def rpy2py_s4(obj: SexpS4) -> Optional[Union[pd.DataFrame, AnnData]]: - """ +def rpy2py_s4(obj: SexpS4) -> pd.DataFrame | AnnData | None: + """Convert known S4 class instance to Python object. + See here for the slots: https://bioconductor.org/packages/release/bioc/vignettes/SingleCellExperiment/inst/doc/intro.html """ r_classes = set(obj.rclass) if {'DataFrame', 'DFrame'} & r_classes: return rpy2py_data_frame(obj) - elif 'SingleCellExperiment' in r_classes: + if 'SingleCellExperiment' in r_classes: return rpy2py_single_cell_experiment(obj) - elif supported_r_matrix_classes() & r_classes: + if supported_r_matrix_classes() & r_classes: return rmat_to_spmat(obj) - else: # Don’t use the registered one, it would lead to recursion. - return default_converter.rpy2py(obj) + # Don’t use the registered one, it would lead to recursion. + return default_converter.rpy2py(obj) -def rpy2py_vector(v): - """ - Converts vectors. Also handles NA in int vectors: https://github.com/rpy2/rpy2/issues/376 +def rpy2py_vector(v: Sexp) -> Any: # noqa: ANN401 + """Convert R vector to Python vectors. + + Also handles NA in int vectors: https://github.com/rpy2/rpy2/issues/376 """ if not isinstance(v, Sexp): return v if isinstance(v, IntSexpVector): - assert v._R_SIZEOF_ELT == 4, 'R integer size changed away from 32 bit' + assert v._R_SIZEOF_ELT == R_INT_BYTES, 'R integer size changed away from 32 bit' # noqa: SLF001 r = pd.array(v, dtype=pd.Int32Dtype()) v_is_na = numpy2ri.rpy2py(baseenv['is.na'](v)).astype(bool) if 'factor' in v.rclass: @@ -56,9 +67,7 @@ def rpy2py_vector(v): def rpy2py_data_frame(obj: SexpS4) -> pd.DataFrame: - """ - S4 DataFrame class, not data.frame - """ + """S4 DataFrame class, not data.frame.""" slots = RSlots(obj) with localconverter(default_converter): columns = {k: rpy2py_vector(v) for k, v in slots['listData'].items()} @@ -75,13 +84,16 @@ def rpy2py_single_cell_experiment(obj: SexpS4) -> AnnData: se = importr('SummarizedExperiment') sce = importr('SingleCellExperiment') - def convert_mats(attr: str, mats: Mapping[str, Sexp], *, transpose: bool = False): + def convert_mats( + attr: str, mats: Mapping[str, Sexp], *, transpose: bool = False + ) -> list[np.ndarray | spmatrix]: rv = [] for n, mat in mats.items(): conv = mat_rpy2py(mat) if isinstance(conv, RS4): cls_names = mat_rpy2py(conv.slots['class']).tolist() - raise TypeError(f'Cannot convert {attr} “{n}” of type(s) {cls_names} to Python') + msg = f'Cannot convert {attr} “{n}” of type(s) {cls_names} to Python' + raise TypeError(msg) rv.append(conv.T if transpose else conv) return rv @@ -92,7 +104,7 @@ def convert_mats(attr: str, mats: Mapping[str, Sexp], *, transpose: bool = False assays = convert_mats('assay', {n: se.assay(obj, n) for n in assay_names}, transpose=True) # There’s SingleCellExperiment with no assays exprs, layers = assays[0], dict(zip(assay_names[1:], assays[1:])) - assert len(exprs.shape) == 2, exprs.shape + assert len(exprs.shape) == 2, exprs.shape # noqa: PLR2004 else: exprs, layers = None, {} @@ -100,7 +112,7 @@ def convert_mats(attr: str, mats: Mapping[str, Sexp], *, transpose: bool = False if not isinstance(rdim_names, NULLType): rdim_names = [str(t) for t in rdim_names] reduced_dims = convert_mats('reducedDim', {t: sce.reducedDim(obj, t) for t in rdim_names}) - obsm = {conv_name.sce2scanpy(n): d for n, d in zip(rdim_names, reduced_dims)} + obsm = {_conv_name.sce2scanpy(n): d for n, d in zip(rdim_names, reduced_dims)} else: obsm = None @@ -114,5 +126,4 @@ def convert_mats(attr: str, mats: Mapping[str, Sexp], *, transpose: bool = False with localconverter(full_converter()): uns = dict(metadata.items()) - # TODO: Once the AnnData bug is fixed, remove the “or None” - return AnnData(exprs, obs, var, uns, obsm or None, layers=layers) + return AnnData(exprs, obs, var, uns, obsm, layers=layers) diff --git a/src/anndata2ri/_rpy2_ext.py b/src/anndata2ri/_rpy2_ext.py new file mode 100644 index 0000000..9dfcae1 --- /dev/null +++ b/src/anndata2ri/_rpy2_ext.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from functools import lru_cache + +from rpy2.robjects import Environment, packages + + +@lru_cache +def importr(name: str) -> packages.Package: + return packages.importr(name) + + +@lru_cache +def data(package: str, name: str | None = None) -> packages.PackageData | Environment: + if name is None: + return packages.data(importr(package)) + # Use cached version of PackageData collection and just fetch + return data(package).fetch(name) diff --git a/src/anndata2ri/rpy2_ext.py b/src/anndata2ri/rpy2_ext.py deleted file mode 100644 index ed41955..0000000 --- a/src/anndata2ri/rpy2_ext.py +++ /dev/null @@ -1,18 +0,0 @@ -from functools import lru_cache -from typing import Optional, Union - -from rpy2.robjects import Environment, packages - - -@lru_cache() -def importr(name: str) -> packages.Package: - return packages.importr(name) - - -@lru_cache() -def data(package: str, name: Optional[str] = None) -> Union[packages.PackageData, Environment]: - if name is None: - return packages.data(importr(package)) - else: - # Use cached version of PackageData collection and just fetch - return data(package).fetch(name) diff --git a/src/anndata2ri/scipy2ri/__init__.py b/src/anndata2ri/scipy2ri/__init__.py index 0b74469..895eb31 100644 --- a/src/anndata2ri/scipy2ri/__init__.py +++ b/src/anndata2ri/scipy2ri/__init__.py @@ -1,5 +1,4 @@ -r""" -Convert scipy.sparse matrices between Python and R. +r"""Convert scipy.sparse matrices between Python and R. For a detailed comparison between the two languages’ sparse matrix environment, see `issue #8`_. @@ -21,6 +20,20 @@ :rcls:`~Matrix::ldiMatrix` :class:`~scipy.sparse.dia_matrix`\ ``(dtype=bool)`` ===================================================== ====================================================== """ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from . import _py2r, _r2py # noqa: F401 +from ._conv import activate, converter, deactivate +from ._support import supported_r_matrix_classes, supported_r_matrix_storage, supported_r_matrix_types + + +if TYPE_CHECKING: + from rpy2.rinterface import Sexp + from scipy import sparse + + __all__ = [ 'activate', 'deactivate', @@ -33,15 +46,6 @@ ] -from typing import Any - -from rpy2.rinterface import Sexp - -from . import py2r, r2py -from .conv import activate, converter, deactivate -from .support import supported_r_matrix_classes, supported_r_matrix_storage, supported_r_matrix_types - - supported_r_matrix_types = supported_r_matrix_types """The Matrix data types supported by this module; Double, Logical, and patterN.""" @@ -49,9 +53,10 @@ """The Matrix storage types supported by this module; Column-sparse, Row-Sparse, Triplets, and DIagonal.""" -def py2rpy(obj: Any) -> Sexp: - """ - Convert scipy sparse matrices objects to R sparse matrices. Supports: +def py2rpy(obj: sparse.spmatrix) -> Sexp: + """Convert scipy sparse matrices objects to R sparse matrices. + + Supports: :class:`~scipy.sparse.csc_matrix` (dtype in {float32, float64, bool}) → :rcls:`~Matrix::dgCMatrix` or :rcls:`~Matrix::lgCMatrix` @@ -65,9 +70,10 @@ def py2rpy(obj: Any) -> Sexp: return converter.py2rpy(obj) -def rpy2py(obj: Any) -> Sexp: - """ - Convert R sparse matrices to scipy sparse matrices. Supports: +def rpy2py(obj: Sexp) -> sparse.spmatrix: + """Convert R sparse matrices to scipy sparse matrices. + + Supports: :rcls:`~Matrix::dgCMatrix`, :rcls:`~Matrix::lgCMatrix`, or :rcls:`~Matrix::ngCMatrix` → :class:`~scipy.sparse.csc_matrix` (dtype float64 or bool) diff --git a/src/anndata2ri/scipy2ri/conv.py b/src/anndata2ri/scipy2ri/_conv.py similarity index 70% rename from src/anndata2ri/scipy2ri/conv.py rename to src/anndata2ri/scipy2ri/_conv.py index b17bf09..536cb17 100644 --- a/src/anndata2ri/scipy2ri/conv.py +++ b/src/anndata2ri/scipy2ri/_conv.py @@ -1,20 +1,19 @@ -from typing import Optional +from __future__ import annotations from rpy2.robjects import conversion, numpy2ri from rpy2.robjects.conversion import overlay_converter -original_converter: Optional[conversion.Converter] = None +original_converter: conversion.Converter | None = None converter = conversion.Converter('original scipy conversion') -def activate(): - """ - Activate conversion between sparse matrices from Scipy and R’s Matrix package. +def activate() -> None: + """Activate conversion between sparse matrices from Scipy and R’s Matrix package. Does nothing if this is the active conversion. """ - global original_converter + global original_converter # noqa: PLW0603 if original_converter is not None: return @@ -30,9 +29,9 @@ def activate(): conversion.set_conversion(new_converter) -def deactivate(): +def deactivate() -> None: """Deactivate the conversion described above if it is active.""" - global original_converter + global original_converter # noqa: PLW0603 if original_converter is None: return diff --git a/src/anndata2ri/scipy2ri/py2r.py b/src/anndata2ri/scipy2ri/_py2r.py similarity index 79% rename from src/anndata2ri/scipy2ri/py2r.py rename to src/anndata2ri/scipy2ri/_py2r.py index 9986b0e..692a3d6 100644 --- a/src/anndata2ri/scipy2ri/py2r.py +++ b/src/anndata2ri/scipy2ri/_py2r.py @@ -1,37 +1,47 @@ +from __future__ import annotations + from functools import wraps -from typing import Callable, Optional +from typing import TYPE_CHECKING import numpy as np -from rpy2.rinterface import Sexp from rpy2.robjects import default_converter, numpy2ri from rpy2.robjects.conversion import localconverter from rpy2.robjects.packages import Package, SignatureTranslatedAnonymousPackage from scipy import sparse -from ..rpy2_ext import importr -from .conv import converter +from anndata2ri._rpy2_ext import importr + +from ._conv import converter + +if TYPE_CHECKING: + from collections.abc import Callable -matrix: Optional[SignatureTranslatedAnonymousPackage] = None -base: Optional[Package] = None + from rpy2.rinterface import Sexp + + +matrix: SignatureTranslatedAnonymousPackage | None = None +base: Package | None = None def get_type_conv(dtype: np.dtype) -> Callable[[np.ndarray], Sexp]: - global base + global base # noqa: PLW0603 if base is None: base = importr('base') if np.issubdtype(dtype, np.floating): return base.as_double - elif np.issubdtype(dtype, np.bool_): + if np.issubdtype(dtype, np.bool_): return base.as_logical - else: - raise ValueError(f'Unknown dtype {dtype!r} cannot be converted to ?gRMatrix.') + msg = f'Unknown dtype {dtype!r} cannot be converted to ?gRMatrix.' + raise ValueError(msg) + +def py2r_context(f: Callable[[sparse.spmatrix], Sexp]) -> Callable[[sparse.spmatrix], Sexp]: + """R globalenv context with some helper functions.""" -def py2r_context(f): @wraps(f) - def wrapper(obj): - global as_logical, as_integer, as_double, matrix + def wrapper(obj: sparse.spmatrix) -> Sexp: + global matrix # noqa: PLW0603 if matrix is None: importr('Matrix') # make class available matrix = SignatureTranslatedAnonymousPackage( @@ -92,7 +102,7 @@ def wrapper(obj): @converter.py2rpy.register(sparse.csc_matrix) @py2r_context -def csc_to_rmat(csc: sparse.csc_matrix): +def csc_to_rmat(csc: sparse.csc_matrix) -> Sexp: csc.sort_indices() conv_data = get_type_conv(csc.dtype) with localconverter(default_converter + numpy2ri.converter): @@ -101,7 +111,7 @@ def csc_to_rmat(csc: sparse.csc_matrix): @converter.py2rpy.register(sparse.csr_matrix) @py2r_context -def csr_to_rmat(csr: sparse.csr_matrix): +def csr_to_rmat(csr: sparse.csr_matrix) -> Sexp: csr.sort_indices() conv_data = get_type_conv(csr.dtype) with localconverter(default_converter + numpy2ri.converter): @@ -116,7 +126,7 @@ def csr_to_rmat(csr: sparse.csr_matrix): @converter.py2rpy.register(sparse.coo_matrix) @py2r_context -def coo_to_rmat(coo: sparse.coo_matrix): +def coo_to_rmat(coo: sparse.coo_matrix) -> Sexp: conv_data = get_type_conv(coo.dtype) with localconverter(default_converter + numpy2ri.converter): return matrix.from_coo( @@ -130,13 +140,14 @@ def coo_to_rmat(coo: sparse.coo_matrix): @converter.py2rpy.register(sparse.dia_matrix) @py2r_context -def dia_to_rmat(dia: sparse.dia_matrix): +def dia_to_rmat(dia: sparse.dia_matrix) -> Sexp: conv_data = get_type_conv(dia.dtype) if len(dia.offsets) > 1: - raise ValueError( + msg = ( 'Cannot convert a dia_matrix with more than 1 diagonal to a *diMatrix. ' f'R diagonal matrices only support 1 diagonal, but this has {len(dia.offsets)}.' ) + raise ValueError(msg) with localconverter(default_converter + numpy2ri.converter): return matrix.from_dia( n=dia.shape[0], diff --git a/src/anndata2ri/scipy2ri/r2py.py b/src/anndata2ri/scipy2ri/_r2py.py similarity index 67% rename from src/anndata2ri/scipy2ri/r2py.py rename to src/anndata2ri/scipy2ri/_r2py.py index 8b7c351..60f6692 100644 --- a/src/anndata2ri/scipy2ri/r2py.py +++ b/src/anndata2ri/scipy2ri/_r2py.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from warnings import warn import numpy as np @@ -7,12 +9,13 @@ from rpy2.robjects.robject import RSlots from scipy import sparse -from .conv import converter -from .support import supported_r_matrix_classes +from ._conv import converter +from ._support import supported_r_matrix_classes @converter.rpy2py.register(SexpS4) -def rmat_to_spmat(rmat: SexpS4): +def rmat_to_spmat(rmat: SexpS4) -> sparse.spmatrix: + """Convert R sparse matrices to scipy sparse matrices.""" slots = RSlots(rmat) with localconverter(default_converter + numpy2ri.converter): shape = baseenv['dim'](rmat) @@ -20,7 +23,9 @@ def rmat_to_spmat(rmat: SexpS4): r_classes = set(rmat.rclass) if not supported_r_matrix_classes() & r_classes: if any(c.endswith('Matrix') for c in r_classes): - warn(f'Encountered Matrix class that is not supported: {r_classes}') + # TODO(flying-sheep): #111 set stacklevel + # https://github.com/theislab/anndata2ri/issues/111 + warn(f'Encountered Matrix class that is not supported: {r_classes}', stacklevel=2) return rmat for storage, mat_cls, idx, nnz in [ ('C', sparse.csc_matrix, lambda: [slots['i'], slots['p']], lambda c: len(c[0])), @@ -31,11 +36,13 @@ def rmat_to_spmat(rmat: SexpS4): if not supported_r_matrix_classes(storage=storage) & r_classes: continue coord_spec = idx() - if supported_r_matrix_classes(types='n') & r_classes: + data = ( + np.repeat(a=True, repeats=nnz(coord_spec)) # we have pattern matrix without data (but always i and j!) - data = np.repeat(True, nnz(coord_spec)) - else: - data = slots['x'] + if supported_r_matrix_classes(types='n') & r_classes + else slots['x'] + ) return mat_cls((data, *coord_spec), shape=shape) - else: - assert False, 'Should have hit one of the branches' + + msg = 'Should have hit one of the branches' + raise AssertionError(msg) diff --git a/src/anndata2ri/scipy2ri/support.py b/src/anndata2ri/scipy2ri/_support.py similarity index 64% rename from src/anndata2ri/scipy2ri/support.py rename to src/anndata2ri/scipy2ri/_support.py index c518e28..827b06e 100644 --- a/src/anndata2ri/scipy2ri/support.py +++ b/src/anndata2ri/scipy2ri/_support.py @@ -1,5 +1,11 @@ +from __future__ import annotations + from functools import lru_cache -from typing import FrozenSet, Iterable, Union +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from collections.abc import Iterable # these are documented in __init__.py because of sphinx limitations @@ -9,11 +15,10 @@ @lru_cache(maxsize=None) def supported_r_matrix_classes( - types: Union[Iterable[str], str] = supported_r_matrix_types, - storage: Union[Iterable[str], str] = supported_r_matrix_storage, -) -> FrozenSet[str]: - """ - Get supported classes, possibly limiting data types or storage types. + types: Iterable[str] | str = supported_r_matrix_types, + storage: Iterable[str] | str = supported_r_matrix_storage, +) -> frozenset[str]: + """Get supported classes, possibly limiting data types or storage types. :param types: Data type character(s) from :data:`supported_r_matrix_types` :param storage: Storage mode(s) from :data:`supported_r_matrix_storage` @@ -24,10 +29,12 @@ def supported_r_matrix_classes( bad_types = types - supported_r_matrix_types if bad_types: - raise ValueError(f'Type(s) {bad_types} not supported.') + msg = f'Type(s) {bad_types} not supported.' + raise ValueError(msg) bad_storage = storage - supported_r_matrix_storage if bad_storage: - raise ValueError(f'Storage type(s) {bad_storage} not supported.') + msg = f'Storage type(s) {bad_storage} not supported.' + raise ValueError(msg) classes = {f'{t}g{s}Matrix' for t in types for s in storage - {'di'}} if 'di' in storage: diff --git a/src/anndata2ri/test_utils.py b/src/anndata2ri/test_utils.py index bae9018..4299454 100644 --- a/src/anndata2ri/test_utils.py +++ b/src/anndata2ri/test_utils.py @@ -1,39 +1,53 @@ +"""Pytest test fixtures and other utilities/enumerations of functionality.""" + +from __future__ import annotations + from abc import ABC, abstractmethod from types import ModuleType -from typing import Any, Callable, List +from typing import TYPE_CHECKING, Any import pytest -from rpy2.rinterface import Sexp from rpy2.robjects import default_converter, globalenv from rpy2.robjects.conversion import Converter, localconverter +if TYPE_CHECKING: + from collections.abc import Callable + + from rpy2.rinterface import Sexp + + Py2R = Callable[['ConversionModule', Any], Any] + R2Py = Callable[['ConversionModule', Callable[[], Sexp]], Any] + + class ConversionModule(ModuleType, ABC): + """Type for conversion modules.""" + @property @abstractmethod def converter(self) -> Converter: - pass + """Conversion modules have a “converter”.""" @abstractmethod def activate(self) -> None: - pass + """Conversion modules can be a “activate”d.""" @abstractmethod def deactivate(self) -> None: - pass + """Conversion modules can be a “deactivate”d.""" -def conversion_py2rpy_manual(conv_mod: ConversionModule, dataset: Any) -> Sexp: +def _conversion_py2rpy_manual(conv_mod: ConversionModule, dataset: Any) -> Sexp: # noqa: ANN401 return conv_mod.converter.py2rpy(dataset) -def conversion_py2rpy_local(conv_mod: ConversionModule, dataset: Any) -> Sexp: +def _conversion_py2rpy_local(conv_mod: ConversionModule, dataset: Any) -> Sexp: # noqa: ANN401 with localconverter(conv_mod.converter): globalenv['temp'] = dataset return globalenv['temp'] -def conversion_py2rpy_activate(conv_mod: ConversionModule, dataset: Any) -> Sexp: +def _conversion_py2rpy_activate(conv_mod: ConversionModule, dataset: Any) -> Sexp: # noqa: ANN401 try: conv_mod.activate() globalenv['temp'] = dataset @@ -42,30 +56,31 @@ def conversion_py2rpy_activate(conv_mod: ConversionModule, dataset: Any) -> Sexp return globalenv['temp'] -conversions_py2rpy: List[Callable[[ConversionModule, Any], Sexp]] = [ - pytest.param(conversion_py2rpy_manual, id='manual'), - pytest.param(conversion_py2rpy_local, id='local'), - pytest.param(conversion_py2rpy_activate, id='activate'), +conversions_py2rpy: list[Py2R] = [ + pytest.param(_conversion_py2rpy_manual, id='manual'), + pytest.param(_conversion_py2rpy_local, id='local'), + pytest.param(_conversion_py2rpy_activate, id='activate'), ] @pytest.fixture(params=conversions_py2rpy) -def py2r(request) -> Callable[[ConversionModule, Any], Sexp]: +def py2r(request: pytest.FixtureRequest) -> Py2R: + """Ways to convert a Python object to an R object.""" return request.param -def conversion_rpy2py_manual(conv_mod: ConversionModule, dataset: Callable[[], Sexp]) -> Any: +def _conversion_rpy2py_manual(conv_mod: ConversionModule, dataset: Callable[[], Sexp]) -> Any: # noqa: ANN401 return conv_mod.converter.rpy2py(dataset()) -def conversion_rpy2py_local(conv_mod: ConversionModule, dataset: Callable[[], Sexp]) -> Any: +def _conversion_rpy2py_local(conv_mod: ConversionModule, dataset: Callable[[], Sexp]) -> Any: # noqa: ANN401 # Needs default_converter to e.g. call `as` on a SummarizedExperiment: # Calling a R function returning a S4 object requires py2rpy[RS4], py2rpy[str], … with localconverter(default_converter + conv_mod.converter): return dataset() -def conversion_rpy2py_activate(conv_mod: ConversionModule, dataset: Callable[[], Sexp]) -> Any: +def _conversion_rpy2py_activate(conv_mod: ConversionModule, dataset: Callable[[], Sexp]) -> Any: # noqa: ANN401 try: conv_mod.activate() return dataset() @@ -73,13 +88,14 @@ def conversion_rpy2py_activate(conv_mod: ConversionModule, dataset: Callable[[], conv_mod.deactivate() -conversions_rpy2py: List[Callable[[ConversionModule, Callable[[], Sexp]], Any]] = [ - pytest.param(conversion_rpy2py_manual, id='manual'), - pytest.param(conversion_rpy2py_local, id='local'), - pytest.param(conversion_rpy2py_activate, id='activate'), +conversions_rpy2py: list[R2Py] = [ + pytest.param(_conversion_rpy2py_manual, id='manual'), + pytest.param(_conversion_rpy2py_local, id='local'), + pytest.param(_conversion_rpy2py_activate, id='activate'), ] @pytest.fixture(params=conversions_rpy2py) -def r2py(request) -> Callable[[ConversionModule, Callable[[], Sexp]], Any]: +def r2py(request: pytest.FixtureRequest) -> R2Py: + """Ways to convert an R object to a Python object.""" return request.param diff --git a/tests/test_py2rpy.py b/tests/test_py2rpy.py index ccb61c8..c7266c2 100644 --- a/tests/test_py2rpy.py +++ b/tests/test_py2rpy.py @@ -1,3 +1,6 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING from warnings import catch_warnings, simplefilter import numpy as np @@ -9,10 +12,18 @@ from rpy2.robjects.conversion import localconverter import anndata2ri -from anndata2ri.rpy2_ext import importr +from anndata2ri._rpy2_ext import importr + + +if TYPE_CHECKING: + from collections.abc import Callable + + from rpy2.rinterface import Sexp + + from anndata2ri.test_utils import Py2R -def mk_ad_simple(): +def mk_ad_simple() -> AnnData: return AnnData( np.array([[1, 2, 3], [0.3, 0.2, 0.1]]), dict(cluster=[1, 2]), @@ -20,11 +31,11 @@ def mk_ad_simple(): ) -def check_empty(ex): +def check_empty(_: Sexp) -> None: pass -def check_pca(ex): +def check_pca(ex: Sexp) -> None: sce = importr('SingleCellExperiment') assert [str(n) for n in sce.reducedDimNames(ex)] == ['PCA'] pca = sce.reducedDim(ex, 'PCA') @@ -35,12 +46,16 @@ def check_pca(ex): pytest.param(check_empty, (0, 0), AnnData, id='empty'), pytest.param(check_pca, (2, 3), mk_ad_simple, id='simple'), pytest.param(check_empty, (640, 11), sc.datasets.krumsiek11, id='krumsiek'), - # pytest.param(check_empty, (2730, 3451), sc.datasets.paul15, id="paul"), ] -@pytest.mark.parametrize('check,shape,dataset', datasets) -def test_py2rpy(py2r, check, shape, dataset): +@pytest.mark.parametrize(('check', 'shape', 'dataset'), datasets) +def test_py2rpy( + py2r: Py2R, + check: Callable[[Sexp], None], + shape: tuple[int, ...], + dataset: Callable[[], AnnData], +) -> None: if dataset is sc.datasets.krumsiek11: with pytest.warns(UserWarning, match=r'Duplicated obs_names'): ex = py2r(anndata2ri, dataset()) @@ -50,8 +65,8 @@ def test_py2rpy(py2r, check, shape, dataset): check(ex) -def test_py2rpy2_numpy_pbmc68k(): - """This has some weird metadata""" +def test_py2rpy2_numpy_pbmc68k() -> None: + """Not tested above as the pbmc68k dataset has some weird metadata.""" from scanpy.datasets import pbmc68k_reduced try: @@ -65,8 +80,8 @@ def test_py2rpy2_numpy_pbmc68k(): @pytest.mark.parametrize('attr', ['X', 'layers', 'obsm']) -def test_dfs(attr): - """X, layers, obsm can contain dataframes""" +def test_dfs(attr: str) -> None: + """X, layers, obsm can contain dataframes.""" adata = mk_ad_simple() if attr == 'X': adata.X = DataFrame(adata.X, index=adata.obs_names) @@ -75,13 +90,13 @@ def test_dfs(attr): elif attr == 'obsm': adata.obsm['X_pca'] = DataFrame(adata.obsm['X_pca'], index=adata.obs_names) else: - assert False, attr + pytest.fail('Forgot to add a case') with localconverter(anndata2ri.converter): globalenv['adata_obsm_pd'] = adata -def test_df_error(): +def test_df_error() -> None: adata = mk_ad_simple() adata.obsm['stuff'] = DataFrame(dict(a=[1, 2], b=list('ab'), c=[1.0, 2.0]), index=adata.obs_names) with pytest.raises(ValueError, match=r"DataFrame contains non-numeric columns \['b'\]"): diff --git a/tests/test_rpy2py.py b/tests/test_rpy2py.py index 05d2a3c..8ac7e57 100644 --- a/tests/test_rpy2py.py +++ b/tests/test_rpy2py.py @@ -1,4 +1,7 @@ +from __future__ import annotations + from pathlib import Path +from typing import TYPE_CHECKING import pandas as pd import pytest @@ -6,7 +9,15 @@ from rpy2.robjects import conversion, r import anndata2ri -from anndata2ri.rpy2_ext import importr +from anndata2ri._rpy2_ext import importr + + +if TYPE_CHECKING: + from collections.abc import Callable + + from rpy2.rinterface import Sexp + + from anndata2ri.test_utils import R2Py as_ = getattr(importr('methods'), 'as') @@ -20,14 +31,14 @@ Path(eh.getExperimentHubOption('CACHE')[0]).mkdir(parents=True, exist_ok=True) -def check_allen(adata): +def check_allen(adata: AnnData) -> None: assert adata.uns.keys() == {'SuppInfo', 'which_qc'} assert set(adata.obs.keys()) > {'NREADS', 'NALIGNED', 'Animal.ID', 'passes_qc_checks_s'} assert adata.obs['Secondary.Type'][:4].tolist() == ['L4 Ctxn3', '', 'L5a Batf3', None], 'NAs not conserved?' assert adata.obs['Animal.ID'][:4].tolist() == [133632, 133632, 151560, pd.NA], 'NAs not conserved?' -def check_example(adata): +def check_example(adata: AnnData) -> None: assert set(adata.obsm.keys()) == {'X_pca', 'X_tsne'} assert adata.obsm['X_pca'].shape == (100, 5) @@ -52,20 +63,25 @@ def check_example(adata): lambda: as_(seq.ReprocessedAllenData(assays='tophat_counts'), 'SingleCellExperiment'), id='allen', ), - pytest.param(lambda x: None, (0, 0), sce.SingleCellExperiment, id='empty'), + pytest.param(lambda _: None, (0, 0), sce.SingleCellExperiment, id='empty'), pytest.param(check_example, (100, 200), lambda: r(code_example), id='example'), ] -@pytest.mark.parametrize('check,shape,dataset', expression_sets) -def test_convert_manual(r2py, check, shape, dataset): +@pytest.mark.parametrize(('check', 'shape', 'dataset'), expression_sets) +def test_convert_manual( + r2py: R2Py, + check: Callable[[AnnData], None], + shape: tuple[int, ...], + dataset: Callable[[], Sexp], +) -> None: ad = r2py(anndata2ri, dataset) assert isinstance(ad, AnnData) assert ad.shape == shape check(ad) -def test_convert_empty_df_with_rows(r2py): +def test_convert_empty_df_with_rows(r2py: R2Py) -> None: df = r('S4Vectors::DataFrame(a=1:10)[, -1]') assert df.slots['nrows'][0] == 10 @@ -73,7 +89,7 @@ def test_convert_empty_df_with_rows(r2py): assert isinstance(df_py, pd.DataFrame) -def test_convert_factor(r2py): +def test_convert_factor(r2py: R2Py) -> None: code = """ SingleCellExperiment::SingleCellExperiment( assays = list(counts = matrix(rpois(6*4, 5), ncol=4)), @@ -82,4 +98,4 @@ def test_convert_factor(r2py): """ ad = r2py(anndata2ri, lambda: r(code)) assert isinstance(ad.obs['a_factor'].values, pd.Categorical) - assert ad.obs['a_factor'].values.tolist() == pd.Categorical.from_codes([0, 0, -1, 1], ['A', 'B']).tolist() + assert ad.obs['a_factor'].to_numpy().tolist() == pd.Categorical.from_codes([0, 0, -1, 1], ['A', 'B']).tolist() diff --git a/tests/test_scipy_py2rpy.py b/tests/test_scipy_py2rpy.py index d6ab249..03bf145 100644 --- a/tests/test_scipy_py2rpy.py +++ b/tests/test_scipy_py2rpy.py @@ -1,3 +1,7 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal + import numpy as np import pytest from rpy2.robjects import baseenv, numpy2ri @@ -6,6 +10,10 @@ from anndata2ri import scipy2ri +if TYPE_CHECKING: + from anndata2ri.test_utils import Py2R + + mats = [ pytest.param((0, 0), sparse.csr_matrix((0, 0)), 'gR', id='csr-empty'), pytest.param((2, 3), sparse.csr_matrix([[2.0, 0.0, 1.0], [0.0, 0.1, 0.0]]), 'gR', id='csr'), @@ -18,8 +26,14 @@ @pytest.mark.parametrize('typ', ['l', 'd']) -@pytest.mark.parametrize('shape,dataset,cls', mats) -def test_py2rpy(py2r, typ, shape, dataset, cls): +@pytest.mark.parametrize(('shape', 'dataset', 'cls'), mats) +def test_py2rpy( + py2r: Py2R, + typ: Literal['l', 'd'], + shape: tuple[int, ...], + dataset: sparse.spmatrix, + cls: str, +) -> None: if typ == 'l': dataset = dataset.astype(bool) sm = py2r(scipy2ri, dataset) diff --git a/tests/test_scipy_rpy2py.py b/tests/test_scipy_rpy2py.py index 07e712e..1c83f33 100644 --- a/tests/test_scipy_rpy2py.py +++ b/tests/test_scipy_rpy2py.py @@ -1,14 +1,23 @@ +from __future__ import annotations + from functools import partial -from typing import Callable, Tuple, Type +from typing import TYPE_CHECKING import numpy as np import pytest -from rpy2.rinterface import Sexp from rpy2.robjects import baseenv, numpy2ri, r from scipy import sparse from anndata2ri import scipy2ri -from anndata2ri.rpy2_ext import importr +from anndata2ri._rpy2_ext import importr + + +if TYPE_CHECKING: + from collections.abc import Callable + + from rpy2.rinterface import Sexp + + from anndata2ri.test_utils import R2Py matrix = importr('Matrix') @@ -29,13 +38,13 @@ csc_b1 = [[1, 0, 0], [1, 1, 1]] lgr = partial(r, "new('lgRMatrix', j = 1:0, p = c(0L, 0L, 1L, 2L), x = c(T, F), Dim = c(3L, 3L))") csr_b1 = [[0, 0, 0], [0, 1, 0], [0, 0, 0]] -# lgt = ... -# coo_b1 = ... +# TODO(flying-sheep): lgt & coo_b1 matrices +# https://github.com/theislab/anndata2ri/issues/110 ngc = partial(r, "as(Matrix::Matrix(c(T, T, F, T, F, T), 2, sparse = TRUE), 'nMatrix')") csc_b2 = [[1, 0, 0], [1, 1, 1]] -# ngr = ... -# csr_b2 = ... +# TODO(flying-sheep): ngr & csr_b2 matrices +# https://github.com/theislab/anndata2ri/issues/110 ngt = partial(r, 'Matrix::sparseMatrix(1:2, 3:2, dims = c(3, 3), giveCsparse = FALSE)') coo_b2 = [[0, 0, 1], [0, 1, 0], [0, 0, 0]] @@ -53,18 +62,22 @@ ] -@pytest.mark.parametrize('shape,cls,dtype,arr,dataset', mats) +@pytest.mark.parametrize(('shape', 'cls', 'dtype', 'arr', 'dataset'), mats) def test_py2rpy( - r2py, - shape: Tuple[int, int], - cls: Type[sparse.spmatrix], + r2py: R2Py, + shape: tuple[int, int], + cls: type[sparse.spmatrix], dtype: np.dtype, arr: np.ndarray, dataset: Callable[[], Sexp], -): +) -> None: sm = r2py(scipy2ri, dataset) assert isinstance(sm, cls) assert sm.shape == shape + # TODO(flying-sheep): check dtype + # https://github.com/theislab/anndata2ri/issues/113 + if dtype != np.bool_: + assert sm.dtype == dtype assert np.allclose(sm.toarray(), np.array(arr)) dm = numpy2ri.converter.rpy2py(baseenv['as.matrix'](dataset()))