diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f18830b28..a27b67ca0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: select(.key | startswith("hatch-test")) | { name: .key, - "test-type": (if (.key | test("pre|min")) then "coverage" else null end), + "test-type": (if (.key | test("pre|low-vers")) then "coverage" else null end), python: .value.python } )') @@ -44,6 +44,8 @@ jobs: strategy: matrix: env: ${{ fromJSON(needs.get-environments.outputs.envs) }} + permissions: + id-token: write # for codecov OIDC steps: - uses: actions/checkout@v6 with: { filter: 'blob:none', fetch-depth: 0 } @@ -77,24 +79,26 @@ jobs: hatch run ${{ matrix.env.name }}:run-cov hatch run ${{ matrix.env.name }}:cov-combine hatch run ${{ matrix.env.name }}:coverage xml + hatch run ${{ matrix.env.name }}:cov-report - name: Upload coverage data if: ${{ !cancelled() && matrix.env.test-type == 'coverage' }} uses: codecov/codecov-action@v5 with: - token: ${{ secrets.CODECOV_TOKEN }} flags: ${{ matrix.env.name }} - fail_ci_if_error: true files: test-data/coverage.xml + use_oidc: true + fail_ci_if_error: true - name: Upload test results if: ${{ !cancelled() }} - uses: codecov/test-results-action@v1 + uses: codecov/codecov-action@v5 with: - token: ${{ secrets.CODECOV_TOKEN }} + report_type: test_results flags: ${{ matrix.env.name }} + files: test-data/test-results.xml + use_oidc: true fail_ci_if_error: true - file: test-data/test-results.xml - name: Publish debug artifacts if: ${{ !cancelled() }} diff --git a/docs/conf.py b/docs/conf.py index 052df2028f..f71668af48 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -126,6 +126,7 @@ ogp_image = "https://scanpy.readthedocs.io/en/stable/_static/Scanpy_Logo_BrightFG.svg" typehints_defaults = "braces" +always_use_bars_union = True # Don’t use `Union` even when building with Python ≤3.14 pygments_style = "default" pygments_dark_style = "native" @@ -157,11 +158,7 @@ pydeseq2=("https://pydeseq2.readthedocs.io/en/stable/", None), pynndescent=("https://pynndescent.readthedocs.io/en/latest/", None), pytest=("https://docs.pytest.org/en/latest/", None), - python=( - # TODO: switch to `/3` once docs are built with Python 3.14 - "https://docs.python.org/3.13", - None, - ), + python=("https://docs.python.org/3", None), rapids_singlecell=("https://rapids-singlecell.readthedocs.io/en/latest/", None), scipy=("https://docs.scipy.org/doc/scipy/", None), seaborn=("https://seaborn.pydata.org/", None), diff --git a/hatch.toml b/hatch.toml index 02e8df4ff3..7f9505fc29 100644 --- a/hatch.toml +++ b/hatch.toml @@ -14,6 +14,7 @@ scripts.build = "python3 ci/scripts/towncrier_automation.py {args}" scripts.clean = "git restore --source=HEAD --staged --worktree -- docs/release-notes" [envs.hatch-test] +python = "3.14" default-args = [ ] dependency-groups = [ "dev", "test-min" ] extra-dependencies = [ "ipykernel" ] @@ -28,10 +29,6 @@ overrides.matrix.deps.pre-install-commands = [ ] overrides.matrix.deps.python = [ { if = [ "low-vers" ], value = "3.12" }, - { if = [ "stable", "few-extras" ], value = "3.13" }, - # numba doesn’t support 3.14 in a stable release yet: - # https://github.com/numba/numba/issues/9957 - { if = [ "pre" ], value = "3.14" }, ] overrides.matrix.deps.extra-dependencies = [ { if = [ "pre" ], value = "anndata @ git+https://github.com/scverse/anndata.git" }, diff --git a/src/scanpy/external/pl.py b/src/scanpy/external/pl.py index 2aa3a9584a..73d63a2bff 100644 --- a/src/scanpy/external/pl.py +++ b/src/scanpy/external/pl.py @@ -7,11 +7,8 @@ import matplotlib.pyplot as plt import numpy as np -from anndata import AnnData # noqa: TC002 -from matplotlib.axes import Axes # noqa: TC002 -from sklearn.utils import deprecated -from .._compat import old_positionals +from .._compat import deprecated, old_positionals from .._utils import _doc_params from .._utils._doctests import doctest_needs from ..plotting import _scrublet, _utils, embedding @@ -28,6 +25,9 @@ from collections.abc import Collection from typing import Any + from anndata import AnnData + from matplotlib.axes import Axes + __all__ = [ "harmony_timeseries", diff --git a/src/scanpy/external/pp/__init__.py b/src/scanpy/external/pp/__init__.py index 71b1dae74d..a8b09f725d 100644 --- a/src/scanpy/external/pp/__init__.py +++ b/src/scanpy/external/pp/__init__.py @@ -2,8 +2,7 @@ from __future__ import annotations -from sklearn.utils import deprecated - +from ..._compat import deprecated from ...preprocessing import _scrublet from ._bbknn import bbknn from ._dca import dca diff --git a/src/scanpy/external/pp/_harmony_integrate.py b/src/scanpy/external/pp/_harmony_integrate.py index 0cfcc3216f..4fd908d955 100644 --- a/src/scanpy/external/pp/_harmony_integrate.py +++ b/src/scanpy/external/pp/_harmony_integrate.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Sequence # noqa: TC003 from typing import TYPE_CHECKING import numpy as np @@ -11,6 +10,8 @@ from ..._utils._doctests import doctest_needs if TYPE_CHECKING: + from collections.abc import Sequence + from anndata import AnnData diff --git a/src/scanpy/plotting/_tools/scatterplots.py b/src/scanpy/plotting/_tools/scatterplots.py index 39b047154c..5ac7c1cda3 100644 --- a/src/scanpy/plotting/_tools/scatterplots.py +++ b/src/scanpy/plotting/_tools/scatterplots.py @@ -1,44 +1,28 @@ from __future__ import annotations import inspect -from collections.abc import Mapping, Sequence # noqa: TC003 +import re +import textwrap +from collections.abc import Sequence from copy import copy -from functools import partial +from functools import cache, partial from itertools import combinations, product from numbers import Integral -from typing import ( - TYPE_CHECKING, - Any, # noqa: TC003 - Literal, # noqa: TC003 -) +from typing import TYPE_CHECKING import numpy as np import pandas as pd -from anndata import AnnData # noqa: TC002 -from cycler import Cycler # noqa: TC002 from matplotlib import colormaps, colors, patheffects, rcParams from matplotlib import pyplot as plt -from matplotlib.axes import Axes # noqa: TC002 -from matplotlib.colors import ( - Colormap, # noqa: TC002 - Normalize, -) -from matplotlib.figure import Figure # noqa: TC002 +from matplotlib.colors import Normalize from matplotlib.markers import MarkerStyle -from numpy.typing import NDArray # noqa: TC002 from ... import logging as logg from ..._compat import deprecated from ..._settings import settings -from ..._utils import ( - Empty, # noqa: TC001 - _doc_params, - _empty, - sanitize_anndata, -) +from ..._utils import _doc_params, _empty, sanitize_anndata from ..._utils._doctests import doctest_internet from ...get import _check_mask -from ...tools._draw_graph import _Layout # noqa: TC001 from .. import _utils from .._docs import ( doc_adata_color_etc, @@ -47,19 +31,23 @@ doc_scatter_spatial, doc_show_save_ax, ) -from .._utils import ( - ColorLike, # noqa: TC001 - VBound, # noqa: TC001 - _FontSize, # noqa: TC001 - _FontWeight, # noqa: TC001 - _LegendLoc, # noqa: TC001 - check_colornorm, - check_projection, - circles, -) +from .._utils import check_colornorm, check_projection, circles if TYPE_CHECKING: - from collections.abc import Collection + from collections.abc import Callable, Collection, Mapping + from types import FunctionType + from typing import Any, Literal + + from anndata import AnnData + from cycler import Cycler + from matplotlib.axes import Axes + from matplotlib.colors import Colormap + from matplotlib.figure import Figure + from numpy.typing import NDArray + + from ..._utils import Empty + from ...tools._draw_graph import _Layout + from .._utils import ColorLike, VBound, _FontSize, _FontWeight, _LegendLoc @_doc_params( @@ -600,10 +588,29 @@ def _get_vboundnorm( return tuple(out) -def _wraps_plot_scatter(wrapper): +_TYPE_GUARD_IMPORT_RE = re.compile(r"\nif TYPE_CHECKING:[^\n]*([\s\S]*?)(?=\n\S)") + + +@cache +def _get_guarded_imports(obj: FunctionType) -> Mapping[str, Any]: + """Simplified version from `sphinx-autodoc-typehints`.""" + module = inspect.getmodule(obj) + assert module + code = inspect.getsource(module) + rv: dict[str, Any] = {} + for m in _TYPE_GUARD_IMPORT_RE.finditer(code): + guarded_code = textwrap.dedent(m.group(1)) + rv.update(obj.__globals__) + exec(guarded_code, rv) + for k in obj.__globals__: + del rv[k] + return rv + + +def _wraps_plot_scatter[**P, R](wrapper: Callable[P, R]) -> Callable[P, R]: """Update the wrapper function to use the correct signature.""" - params = inspect.signature(embedding, eval_str=True).parameters.copy() - wrapper_sig = inspect.signature(wrapper, eval_str=True) + params = inspect.signature(embedding).parameters.copy() + wrapper_sig = inspect.signature(wrapper) wrapper_params = wrapper_sig.parameters.copy() params.pop("basis") @@ -619,6 +626,14 @@ def _wraps_plot_scatter(wrapper): if wrapper_sig.return_annotation is not inspect.Signature.empty: annotations["return"] = wrapper_sig.return_annotation + # `sphinx-autodoc-typehints` can execute `if TYPECHECKING` blocks, + # but all all users of `_wraps_plot_scatter` that aren’t in this module + # won’t have any imports. So we execute and inject the imports here. + wrapper.__globals__.update({ + k: v + for k, v in {**embedding.__globals__, **_get_guarded_imports(embedding)}.items() + if k not in wrapper.__globals__ + }) wrapper.__signature__ = inspect.Signature( list(params.values()), return_annotation=wrapper_sig.return_annotation )