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
23 changes: 17 additions & 6 deletions src/scanpydoc/elegant_typehints/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
that overrides automatically created links. It is used like this::

qualname_overrides = {
"pandas.core.frame.DataFrame": "pandas.DataFrame",
"pandas.core.frame.DataFrame": "pandas.DataFrame", # fix qualname
"numpy.int64": ("py:data", "numpy.int64"), # fix role
...,
}

Expand Down Expand Up @@ -47,14 +48,15 @@ def x() -> Tuple[int, float]:

from __future__ import annotations

from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, cast
from pathlib import Path
from collections import ChainMap
from dataclasses import dataclass

from sphinx.ext.autodoc import ClassDocumenter

from scanpydoc import metadata, _setup_sig
from scanpydoc.elegant_typehints._role_mapping import RoleMapping

from .example import (
example_func_prose,
Expand Down Expand Up @@ -95,11 +97,14 @@ def x() -> Tuple[int, float]:
"scipy.sparse.csr.csr_matrix": "scipy.sparse.csr_matrix",
"scipy.sparse.csc.csc_matrix": "scipy.sparse.csc_matrix",
}
qualname_overrides = ChainMap({}, qualname_overrides_default)
qualname_overrides = ChainMap(
RoleMapping(),
RoleMapping.from_user(qualname_overrides_default), # type: ignore[arg-type]
)


def _init_vars(_app: Sphinx, config: Config) -> None:
qualname_overrides.update(config.qualname_overrides)
cast(RoleMapping, qualname_overrides.maps[0]).update_user(config.qualname_overrides)
if (
"sphinx_autodoc_typehints" in config.extensions
and config.typehints_defaults is None
Expand Down Expand Up @@ -128,9 +133,15 @@ def _last_resolve(

from sphinx.ext.intersphinx import resolve_reference_detect_inventory

if (qualname := qualname_overrides.get(node["reftarget"])) is None:
if (
ref := qualname_overrides.get(
(f"{node['refdomain']}:{node['reftype']}", node["reftarget"])
)
) is None:
return None
node["reftarget"] = qualname
role, node["reftarget"] = ref
if role is not None:
node["refdomain"], node["reftype"] = role.split(":", 1)
return resolve_reference_detect_inventory(env, node, contnode)


Expand Down
27 changes: 15 additions & 12 deletions src/scanpydoc/elegant_typehints/_autodoc_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,29 +12,32 @@


def dir_head_adder(
qualname_overrides: Mapping[str, str],
qualname_overrides: Mapping[tuple[str | None, str], tuple[str | None, str]],
orig: Callable[[ClassDocumenter, str], None],
) -> Callable[[ClassDocumenter, str], None]:
@wraps(orig)
def add_directive_header(self: ClassDocumenter, sig: str) -> None:
orig(self, sig)
lines: StringList = self.directive.result
role, direc = (
("exc", "exception")
lines = self.directive.result
inferred_role, direc = (
("py:exc", "py:exception")
if isinstance(self.object, type) and issubclass(self.object, BaseException)
else ("class", "class")
else ("py:class", "py:class")
)
for old, new in qualname_overrides.items():
for (old_role, old_name), (new_role, new_name) in qualname_overrides.items():
role = inferred_role if new_role is None else new_role
# Currently, autodoc doesn’t link to bases using :exc:
lines.replace(f":class:`{old}`", f":{role}:`{new}`")
lines.replace(
f":{old_role or 'py:class'}:`{old_name}`", f":{role}:`{new_name}`"
)
# But maybe in the future it will
lines.replace(f":{role}:`{old}`", f":{role}:`{new}`")
old_mod, old_cls = old.rsplit(".", 1)
new_mod, new_cls = new.rsplit(".", 1)
lines.replace(f":{role}:`{old_name}`", f":{role}:`{new_name}`")
old_mod, old_cls = old_name.rsplit(".", 1)
new_mod, new_cls = new_name.rsplit(".", 1)
replace_multi_suffix(
lines,
(f".. py:{direc}:: {old_cls}", f" :module: {old_mod}"),
(f".. py:{direc}:: {new_cls}", f" :module: {new_mod}"),
(f".. {direc}:: {old_cls}", f" :module: {old_mod}"),
(f".. {direc}:: {new_cls}", f" :module: {new_mod}"),
)

return add_directive_header
Expand Down
12 changes: 9 additions & 3 deletions src/scanpydoc/elegant_typehints/_formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,22 @@ def typehints_formatter(annotation: type[Any], config: Config) -> str | None:
# Only if this is a real class we override sphinx_autodoc_typehints
if inspect.isclass(annotation):
full_name = f"{annotation.__module__}.{annotation.__qualname__}"
override = elegant_typehints.qualname_overrides.get(full_name)
override = elegant_typehints.qualname_overrides.get((None, full_name))
if override is not None:
role = "exc" if issubclass(annotation_cls, BaseException) else "class"
if args is None:
formatted_args = ""
else:
formatted_args = ", ".join(
format_annotation(arg, config) for arg in args
)
formatted_args = rf"\ \[{formatted_args}]"
return f":py:{role}:`{tilde}{override}`{formatted_args}"
role, qualname = override
if role is None:
role = (
"py:exc"
if issubclass(annotation_cls, BaseException)
else "py:class"
)
return f":{role}:`{tilde}{qualname}`{formatted_args}"

return None
73 changes: 73 additions & 0 deletions src/scanpydoc/elegant_typehints/_role_mapping.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from __future__ import annotations

from typing import TYPE_CHECKING
from itertools import chain
from collections.abc import MutableMapping


if TYPE_CHECKING:
from typing import Self
from collections.abc import Mapping, Iterator


class RoleMapping(MutableMapping[tuple[str | None, str], tuple[str | None, str]]):
data: dict[tuple[str | None, str], tuple[str | None, str]]

def __init__(
self,
mapping: Mapping[tuple[str | None, str], str | tuple[str | None, str]] = {},
/,
) -> None:
self.data = dict(mapping) # type: ignore[arg-type]

@classmethod
def from_user(
cls, mapping: Mapping[str | tuple[str, str], str | tuple[str, str]]
) -> Self:
rm = cls({})
rm.update_user(mapping)
return rm

def update_user(
self, mapping: Mapping[str | tuple[str, str], str | tuple[str, str]]
) -> None:
for k, v in mapping.items():
self[k if isinstance(k, tuple) else (None, k)] = (
v if isinstance(v, tuple) else (None, v)
)

def __setitem__(
self, key: tuple[str | None, str], value: tuple[str | None, str]
) -> None:
self.data[key] = value

def __getitem__(self, key: tuple[str | None, str]) -> tuple[str | None, str]:
if key[0] is not None:
try:
return self.data[key]
except KeyError:
return self.data[None, key[1]]
for known_role in chain([None], {r for r, _ in self}):
try:
return self.data[known_role, key[1]]
except KeyError: # noqa: PERF203
pass
raise KeyError(key)

def __contains__(self, key: object) -> bool:
if not isinstance(key, tuple):
raise TypeError
try:
self[key]
except KeyError:
return False
return True

def __delitem__(self, key: tuple[str | None, str]) -> None:
del self.data[key]

def __iter__(self) -> Iterator[tuple[str | None, str]]:
return self.data.__iter__()

def __len__(self) -> int:
return len(self.data)
7 changes: 6 additions & 1 deletion tests/test_elegant_typehints.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,12 @@ def test_resolve_failure(app: Sphinx, qualname: str) -> None:

resolved = _last_resolve(app, app.env, node, TextElement())
assert resolved is None
assert node["reftarget"] == qualname_overrides.get(qualname, qualname)
type_ex, target_ex = qualname_overrides.get(
("py:class", qualname), (None, qualname)
)
if type_ex is not None:
assert node["refdomain"], node["reftype"] == type_ex.split(":", 1)
assert node["reftarget"] == target_ex


# These guys aren’t listed as classes in Python’s intersphinx index:
Expand Down
Loading