Skip to content

Commit 2a35dc1

Browse files
authored
Allow overriding false roles in qualname_overrides (#192)
1 parent 7578770 commit 2a35dc1

File tree

5 files changed

+120
-22
lines changed

5 files changed

+120
-22
lines changed

src/scanpydoc/elegant_typehints/__init__.py

+17-6
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
that overrides automatically created links. It is used like this::
99
1010
qualname_overrides = {
11-
"pandas.core.frame.DataFrame": "pandas.DataFrame",
11+
"pandas.core.frame.DataFrame": "pandas.DataFrame", # fix qualname
12+
"numpy.int64": ("py:data", "numpy.int64"), # fix role
1213
...,
1314
}
1415
@@ -47,14 +48,15 @@ def x() -> Tuple[int, float]:
4748

4849
from __future__ import annotations
4950

50-
from typing import TYPE_CHECKING
51+
from typing import TYPE_CHECKING, cast
5152
from pathlib import Path
5253
from collections import ChainMap
5354
from dataclasses import dataclass
5455

5556
from sphinx.ext.autodoc import ClassDocumenter
5657

5758
from scanpydoc import metadata, _setup_sig
59+
from scanpydoc.elegant_typehints._role_mapping import RoleMapping
5860

5961
from .example import (
6062
example_func_prose,
@@ -95,11 +97,14 @@ def x() -> Tuple[int, float]:
9597
"scipy.sparse.csr.csr_matrix": "scipy.sparse.csr_matrix",
9698
"scipy.sparse.csc.csc_matrix": "scipy.sparse.csc_matrix",
9799
}
98-
qualname_overrides = ChainMap({}, qualname_overrides_default)
100+
qualname_overrides = ChainMap(
101+
RoleMapping(),
102+
RoleMapping.from_user(qualname_overrides_default), # type: ignore[arg-type]
103+
)
99104

100105

101106
def _init_vars(_app: Sphinx, config: Config) -> None:
102-
qualname_overrides.update(config.qualname_overrides)
107+
cast(RoleMapping, qualname_overrides.maps[0]).update_user(config.qualname_overrides)
103108
if (
104109
"sphinx_autodoc_typehints" in config.extensions
105110
and config.typehints_defaults is None
@@ -128,9 +133,15 @@ def _last_resolve(
128133

129134
from sphinx.ext.intersphinx import resolve_reference_detect_inventory
130135

131-
if (qualname := qualname_overrides.get(node["reftarget"])) is None:
136+
if (
137+
ref := qualname_overrides.get(
138+
(f"{node['refdomain']}:{node['reftype']}", node["reftarget"])
139+
)
140+
) is None:
132141
return None
133-
node["reftarget"] = qualname
142+
role, node["reftarget"] = ref
143+
if role is not None:
144+
node["refdomain"], node["reftype"] = role.split(":", 1)
134145
return resolve_reference_detect_inventory(env, node, contnode)
135146

136147

src/scanpydoc/elegant_typehints/_autodoc_patch.py

+15-12
Original file line numberDiff line numberDiff line change
@@ -12,29 +12,32 @@
1212

1313

1414
def dir_head_adder(
15-
qualname_overrides: Mapping[str, str],
15+
qualname_overrides: Mapping[tuple[str | None, str], tuple[str | None, str]],
1616
orig: Callable[[ClassDocumenter, str], None],
1717
) -> Callable[[ClassDocumenter, str], None]:
1818
@wraps(orig)
1919
def add_directive_header(self: ClassDocumenter, sig: str) -> None:
2020
orig(self, sig)
21-
lines: StringList = self.directive.result
22-
role, direc = (
23-
("exc", "exception")
21+
lines = self.directive.result
22+
inferred_role, direc = (
23+
("py:exc", "py:exception")
2424
if isinstance(self.object, type) and issubclass(self.object, BaseException)
25-
else ("class", "class")
25+
else ("py:class", "py:class")
2626
)
27-
for old, new in qualname_overrides.items():
27+
for (old_role, old_name), (new_role, new_name) in qualname_overrides.items():
28+
role = inferred_role if new_role is None else new_role
2829
# Currently, autodoc doesn’t link to bases using :exc:
29-
lines.replace(f":class:`{old}`", f":{role}:`{new}`")
30+
lines.replace(
31+
f":{old_role or 'py:class'}:`{old_name}`", f":{role}:`{new_name}`"
32+
)
3033
# But maybe in the future it will
31-
lines.replace(f":{role}:`{old}`", f":{role}:`{new}`")
32-
old_mod, old_cls = old.rsplit(".", 1)
33-
new_mod, new_cls = new.rsplit(".", 1)
34+
lines.replace(f":{role}:`{old_name}`", f":{role}:`{new_name}`")
35+
old_mod, old_cls = old_name.rsplit(".", 1)
36+
new_mod, new_cls = new_name.rsplit(".", 1)
3437
replace_multi_suffix(
3538
lines,
36-
(f".. py:{direc}:: {old_cls}", f" :module: {old_mod}"),
37-
(f".. py:{direc}:: {new_cls}", f" :module: {new_mod}"),
39+
(f".. {direc}:: {old_cls}", f" :module: {old_mod}"),
40+
(f".. {direc}:: {new_cls}", f" :module: {new_mod}"),
3841
)
3942

4043
return add_directive_header

src/scanpydoc/elegant_typehints/_formatting.py

+9-3
Original file line numberDiff line numberDiff line change
@@ -48,16 +48,22 @@ def typehints_formatter(annotation: type[Any], config: Config) -> str | None:
4848
# Only if this is a real class we override sphinx_autodoc_typehints
4949
if inspect.isclass(annotation):
5050
full_name = f"{annotation.__module__}.{annotation.__qualname__}"
51-
override = elegant_typehints.qualname_overrides.get(full_name)
51+
override = elegant_typehints.qualname_overrides.get((None, full_name))
5252
if override is not None:
53-
role = "exc" if issubclass(annotation_cls, BaseException) else "class"
5453
if args is None:
5554
formatted_args = ""
5655
else:
5756
formatted_args = ", ".join(
5857
format_annotation(arg, config) for arg in args
5958
)
6059
formatted_args = rf"\ \[{formatted_args}]"
61-
return f":py:{role}:`{tilde}{override}`{formatted_args}"
60+
role, qualname = override
61+
if role is None:
62+
role = (
63+
"py:exc"
64+
if issubclass(annotation_cls, BaseException)
65+
else "py:class"
66+
)
67+
return f":{role}:`{tilde}{qualname}`{formatted_args}"
6268

6369
return None
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
from itertools import chain
5+
from collections.abc import MutableMapping
6+
7+
8+
if TYPE_CHECKING:
9+
from typing import Self
10+
from collections.abc import Mapping, Iterator
11+
12+
13+
class RoleMapping(MutableMapping[tuple[str | None, str], tuple[str | None, str]]):
14+
data: dict[tuple[str | None, str], tuple[str | None, str]]
15+
16+
def __init__(
17+
self,
18+
mapping: Mapping[tuple[str | None, str], str | tuple[str | None, str]] = {},
19+
/,
20+
) -> None:
21+
self.data = dict(mapping) # type: ignore[arg-type]
22+
23+
@classmethod
24+
def from_user(
25+
cls, mapping: Mapping[str | tuple[str, str], str | tuple[str, str]]
26+
) -> Self:
27+
rm = cls({})
28+
rm.update_user(mapping)
29+
return rm
30+
31+
def update_user(
32+
self, mapping: Mapping[str | tuple[str, str], str | tuple[str, str]]
33+
) -> None:
34+
for k, v in mapping.items():
35+
self[k if isinstance(k, tuple) else (None, k)] = (
36+
v if isinstance(v, tuple) else (None, v)
37+
)
38+
39+
def __setitem__(
40+
self, key: tuple[str | None, str], value: tuple[str | None, str]
41+
) -> None:
42+
self.data[key] = value
43+
44+
def __getitem__(self, key: tuple[str | None, str]) -> tuple[str | None, str]:
45+
if key[0] is not None:
46+
try:
47+
return self.data[key]
48+
except KeyError:
49+
return self.data[None, key[1]]
50+
for known_role in chain([None], {r for r, _ in self}):
51+
try:
52+
return self.data[known_role, key[1]]
53+
except KeyError: # noqa: PERF203
54+
pass
55+
raise KeyError(key)
56+
57+
def __contains__(self, key: object) -> bool:
58+
if not isinstance(key, tuple):
59+
raise TypeError
60+
try:
61+
self[key]
62+
except KeyError:
63+
return False
64+
return True
65+
66+
def __delitem__(self, key: tuple[str | None, str]) -> None:
67+
del self.data[key]
68+
69+
def __iter__(self) -> Iterator[tuple[str | None, str]]:
70+
return self.data.__iter__()
71+
72+
def __len__(self) -> int:
73+
return len(self.data)

tests/test_elegant_typehints.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,12 @@ def test_resolve_failure(app: Sphinx, qualname: str) -> None:
279279

280280
resolved = _last_resolve(app, app.env, node, TextElement())
281281
assert resolved is None
282-
assert node["reftarget"] == qualname_overrides.get(qualname, qualname)
282+
type_ex, target_ex = qualname_overrides.get(
283+
("py:class", qualname), (None, qualname)
284+
)
285+
if type_ex is not None:
286+
assert node["refdomain"], node["reftype"] == type_ex.split(":", 1)
287+
assert node["reftarget"] == target_ex
283288

284289

285290
# These guys aren’t listed as classes in Python’s intersphinx index:

0 commit comments

Comments
 (0)