Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 54 additions & 18 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,31 +18,67 @@ env:
FORCE_COLOR: "1"

jobs:
build:
get-environments:
runs-on: ubuntu-latest
outputs:
envs: ${{ steps.get-envs.outputs.envs }}
steps:
- uses: actions/checkout@v5
with: { filter: "blob:none", fetch-depth: 0 }
- uses: astral-sh/setup-uv@v7
with: { enable-cache: false }
- id: get-envs
run: |
ENVS_JSON=$(NO_COLOR=1 uvx hatch env show --json | jq -c 'to_entries
| map(
select(.key | startswith("hatch-test"))
| { name: .key, python: .value.python }
)')
echo "envs=${ENVS_JSON}" | tee $GITHUB_OUTPUT

test:
needs: get-environments
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
env: ${{ fromJSON(needs.get-environments.outputs.envs) }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with: { fetch-depth: 0, filter: "blob:none" }
- uses: astral-sh/setup-uv@v7
with:
fetch-depth: 0
filter: blob:none
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- uses: hynek/setup-cached-uv@v2
with:
cache-dependency-path: pyproject.toml
- name: dependencies
# TODO: remove typer constraint after fixing https://github.com/WaylonWalker/coverage-rich/issues/4
run: uv pip install --system .[test,typehints,myst] coverage-rich 'typer <0.14' 'anyconfig[toml] >=0.14'
- name: tests
run: coverage run -m pytest --verbose --color=yes
- name: show coverage
run: coverage-rich report
python-version: ${{ matrix.env.python-version }}
- name: Install dependencies
run: |
uv tool install hatch
hatch -v env create ${{ matrix.env.name }}
- name: Run tests
run: |
hatch run ${{ matrix.env.name }}:run-cov -v --color=yes -n auto --junitxml=test-data/test-results.xml
hatch run ${{ matrix.env.name }}:cov-combine
hatch run ${{ matrix.env.name }}:coverage xml
- name: upload coverage
if: ${{ !cancelled() }}
uses: codecov/codecov-action@v5
with:
token: c66a2830-d3c7-4ae7-a230-21aef89dcf65
flags: ${{ matrix.env.name }}
fail_ci_if_error: true
- name: Upload test results
if: ${{ !cancelled() }}
uses: codecov/test-results-action@v1
with:
token: c66a2830-d3c7-4ae7-a230-21aef89dcf65
flags: ${{ matrix.env.name }}
fail_ci_if_error: true

check:
if: always()
needs:
- get-environments
- test
runs-on: ubuntu-latest
steps:
- uses: re-actors/alls-green@release/v1
with:
jobs: ${{ toJSON(needs) }}
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ repos:
- types-docutils
- legacy-api-wrap
- myst-parser
- sphinx-autodoc-typehints
5 changes: 2 additions & 3 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@
import re
from typing import TYPE_CHECKING
from pathlib import PurePosixPath
from datetime import datetime
from datetime import timezone as tz
from datetime import UTC, datetime
from importlib.metadata import metadata

from jinja2.tests import TESTS
Expand Down Expand Up @@ -42,7 +41,7 @@
meta = metadata("scanpydoc")
project = meta["name"]
author = meta["author-email"].split(" <")[0]
copyright = f"{datetime.now(tz=tz.utc):%Y}, {author}." # noqa: A001
copyright = f"{datetime.now(tz=UTC):%Y}, {author}." # noqa: A001
version = release = meta["version"]

master_doc = "index"
Expand Down
10 changes: 7 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ classifiers = [
'Framework :: Sphinx :: Extension',
'Typing :: Typed',
]
requires-python = '>=3.10'
requires-python = '>=3.12'
dependencies = [
'sphinx>=7.0',
]
Expand Down Expand Up @@ -94,14 +94,16 @@ version-file = 'src/scanpydoc/_version.py'
[tool.hatch.envs.default]
dependencies = ['types-docutils']
[tool.hatch.envs.docs]
python = '3.11'
python = '3.13'
features = ['doc']
[tool.hatch.envs.docs.scripts]
build = 'sphinx-build -M html docs docs/_build'
clean = 'git clean -fdX docs'

[tool.hatch.envs.hatch-test]
features = ['test', 'typehints', 'myst']
[[tool.hatch.envs.hatch-test.matrix]]
python = ['3.12', '3.14']

[tool.pytest.ini_options]
addopts = [
Expand All @@ -115,8 +117,10 @@ filterwarnings = [

[tool.coverage.run]
source_pkgs = ['scanpydoc']
concurrency = ['multiprocessing']
patch = ['subprocess']
[tool.coverage.paths]
scanpydoc = ['src/scanpydoc']
source = ['src']
[tool.coverage.report]
exclude_lines = [
'no cov',
Expand Down
6 changes: 2 additions & 4 deletions src/scanpydoc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from __future__ import annotations

from typing import TYPE_CHECKING, Any, TypeVar
from typing import TYPE_CHECKING, Any
from textwrap import indent
from collections.abc import Callable

Expand All @@ -31,10 +31,8 @@
:ref:`Metadata <sphinx:ext-metadata>` for this extension.
"""

C = TypeVar("C", bound=Callable[..., Any])


def _setup_sig(fn: C) -> C:
def _setup_sig[C: Callable[..., Any]](fn: C) -> C:
fn.__doc__ = f"{fn.__doc__ or ''}\n\n{indent(setup_sig_str, ' ' * 4)}"
return fn

Expand Down
7 changes: 1 addition & 6 deletions src/scanpydoc/autosummary_generate_imported.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,7 @@


if TYPE_CHECKING:
import sys

if sys.version_info >= (3, 11):
from typing import Never
else: # pragma: no cover
from typing import NoReturn as Never
from typing import Never

from sphinx.application import Sphinx

Expand Down
14 changes: 6 additions & 8 deletions src/scanpydoc/definition_list_typed_field.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@


if TYPE_CHECKING:
from typing import Any, TypeAlias
from typing import Any
from collections.abc import Iterable

from sphinx.application import Sphinx
from sphinx.environment import BuildEnvironment

TextLikeNode: TypeAlias = nodes.Text | nodes.TextElement
type TextLikeNode = nodes.Text | nodes.TextElement


class DLTypedField(PyTypedField):
Expand Down Expand Up @@ -98,12 +98,10 @@ def handle_item(
@_setup_sig
def setup(app: Sphinx) -> dict[str, Any]:
"""Replace :class:`~sphinx.domains.python.PyTypedField` with ours."""
napoleon_requested = "sphinx.ext.napoleon" in app.config.extensions
napoleon_loaded = next(
(True for ft in PyObject.doc_field_types if ft.name == "keyword"),
False,
)
if napoleon_requested and not napoleon_loaded:
if (
"sphinx.ext.napoleon" in app.config.extensions
and "sphinx.ext.napoleon" not in app.extensions
):
msg = f"Please load sphinx.ext.napoleon before {__name__}"
raise RuntimeError(msg)

Expand Down
7 changes: 5 additions & 2 deletions src/scanpydoc/elegant_typehints/_formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import inspect
from types import GenericAlias
from typing import TYPE_CHECKING, cast, get_args, get_origin
from typing import TYPE_CHECKING, TypeAliasType, cast, get_args, get_origin

from sphinx_autodoc_typehints import format_annotation

Expand All @@ -16,7 +16,7 @@
from sphinx.config import Config


def typehints_formatter(annotation: type[Any], config: Config) -> str | None:
def typehints_formatter(annotation: object, config: Config) -> str | None:
"""Generate reStructuredText containing links to the types.

Can be used as ``typehints_formatter`` for :mod:`sphinx_autodoc_typehints`,
Expand All @@ -33,6 +33,9 @@ def typehints_formatter(annotation: type[Any], config: Config) -> str | None:
-------
reStructuredText describing the type
"""
if isinstance(annotation, TypeAliasType):
return format_annotation(annotation.__value__, config)

if inspect.isclass(annotation) and annotation.__module__ == "builtins":
return None

Expand Down
2 changes: 1 addition & 1 deletion src/scanpydoc/elegant_typehints/_role_mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def __getitem__(self, key: tuple[str | None, str]) -> tuple[str | None, str]:
for known_role in chain([None], {r for r, _ in self}):
try:
return self.data[known_role, key[1]]
except KeyError: # noqa: PERF203
except KeyError:
pass
raise KeyError(key)

Expand Down
7 changes: 3 additions & 4 deletions src/scanpydoc/rtd_github_links/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,10 @@

if TYPE_CHECKING:
from types import CodeType, FrameType, MethodType, FunctionType, TracebackType
from typing import Any, TypeAlias
from typing import Any
from collections.abc import Callable

_SourceObjectType: TypeAlias = (
type _SourceObjectType = (
ModuleType
| type[Any]
| MethodType
Expand Down Expand Up @@ -211,8 +211,7 @@ def github_url(qualname: str) -> str:
try:
obj, module = _get_obj_module(qualname)
except Exception as e:
if sys.version_info >= (3, 11):
e.add_note(f"Qualname: {qualname!r}")
e.add_note(f"Qualname: {qualname!r}")
raise
assert rtd_links_prefix is not None # noqa: S101
path = rtd_links_prefix / _module_path(obj, module)
Expand Down
4 changes: 2 additions & 2 deletions src/scanpydoc/rtd_github_links/_linkcode.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ class JSInfo(TypedDict):


if TYPE_CHECKING:
from typing import Literal, TypeAlias
from typing import Literal

Domain = Literal["py", "c", "cpp", "javascript"]
DomainInfo: TypeAlias = PyInfo | CInfo | JSInfo
type DomainInfo = PyInfo | CInfo | JSInfo


@overload
Expand Down
14 changes: 10 additions & 4 deletions src/scanpydoc/rtd_github_links/_testdata.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,25 @@
from typing import TypeAlias


class _G[T]:
pass


_T = TypeVar("_T")


class _G(Generic[_T]):
class _G_Old(Generic[_T]): # noqa: N801, UP046
pass


# make sure that TestGenericClass keeps its __module__
_G.__module__ = "somewhere_else"
_G.__module__ = _G_Old.__module__ = "somewhere_else"


TestGenericBuiltin: TypeAlias = list[str]
TestGenericClass: TypeAlias = _G[int]
type TestGenericBuiltin = list[str]
type TestGenericClass = _G[int]
TestGenericBuiltinOld: TypeAlias = list[str] # noqa: UP040
TestGenericClassOld: TypeAlias = _G_Old[int] # noqa: UP040


@dataclass
Expand Down
29 changes: 18 additions & 11 deletions tests/test_elegant_typehints.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,10 @@ class SubCl(Class): pass
class Excep(RuntimeError): pass
class Excep2(Excep): pass

T = TypeVar('T')
class Gen(Generic[T]): pass
class Gen[T]: pass

_T = TypeVar('T')
class GenOld(Generic[_T]): pass
""",
)

Expand All @@ -89,6 +91,7 @@ def app(make_app_setup: MakeApp) -> Sphinx:
"testmod.Excep": "test.Excep",
"testmod.Excep2": ("py:exc", "test.Excep2"),
"testmod.Gen": "test.Gen",
"testmod.GenOld": "test.GenOld",
},
)

Expand Down Expand Up @@ -131,8 +134,14 @@ def test_app(app: Sphinx) -> None:
assert "testmod.Class" in app.config.qualname_overrides


def test_default(app: Sphinx) -> None:
assert typehints_formatter(str, app.config) is None
@pytest.mark.parametrize("annotation", [str, int | str], ids=["type", "union"])
def test_default(app: Sphinx, annotation: object) -> None:
assert typehints_formatter(annotation, app.config) is None


def test_typealiastype(app: Sphinx) -> None:
type Foo = int # pyright: ignore[reportGeneralTypeIssues]
assert typehints_formatter(Foo, app.config) == ":py:class:`int`"


def _escape_sat(rst: str) -> str:
Expand Down Expand Up @@ -240,6 +249,11 @@ def fn_test(m: Mapping[str, int] = {}) -> None: # pragma: no cover
r":py:class:`~test.Gen`\ \[:py:class:`~test.Class`]",
id="generic",
),
pytest.param(
lambda m: m.GenOld[m.Class],
r":py:class:`~test.GenOld`\ \[:py:class:`~test.Class`]",
id="generic-old",
),
],
)
def test_qualname_overrides(
Expand Down Expand Up @@ -329,18 +343,11 @@ def test_typing_classes(app: Sphinx, annotation: type) -> None:
getattr(annotation, "_name", None)
or getattr(annotation, "__name__", None)
or getattr(get_origin(annotation), "_name", None)
# 3.6 _Any and _Union
or annotation.__class__.__name__[1:]
)
output = typehints_formatter(annotation, app.config)
assert output is None or output.startswith(f":py:data:`typing.{name}")


def test_union_type(app: Sphinx) -> None:
union = eval("int | str") # noqa: S307
assert typehints_formatter(union, app.config) is None


@pytest.mark.parametrize(
("direc", "base", "sub"),
[
Expand Down
Loading