Skip to content

Commit

Permalink
Allow overriding false roles in qualname_overrides (#192)
Browse files Browse the repository at this point in the history
  • Loading branch information
flying-sheep authored Jan 16, 2025
1 parent 7578770 commit 2a35dc1
Show file tree
Hide file tree
Showing 5 changed files with 120 additions and 22 deletions.
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

0 comments on commit 2a35dc1

Please sign in to comment.