Skip to content

Commit ead64df

Browse files
authored
Introduce a new type for documenter options (sphinx-doc#13783)
1 parent dfe9c2c commit ead64df

File tree

9 files changed

+201
-91
lines changed

9 files changed

+201
-91
lines changed

sphinx/ext/autodoc/_directive_options.py

Lines changed: 101 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88
from sphinx.locale import __
99

1010
if TYPE_CHECKING:
11-
from typing import Any
11+
from collections.abc import Mapping, Set
12+
from typing import Any, Literal, Self
1213

13-
from sphinx.ext.autodoc._documenters import Documenter
14+
from sphinx.ext.autodoc._sentinels import ALL_T, EMPTY_T, SUPPRESS_T
15+
from sphinx.util.typing import OptionSpec
1416

1517

1618
# common option names for autodoc directives
@@ -39,64 +41,123 @@
3941
})
4042

4143

44+
class _AutoDocumenterOptions:
45+
# TODO: make immutable.
46+
47+
no_index: Literal[True] | None = None
48+
no_index_entry: Literal[True] | None = None
49+
50+
# module-like options
51+
members: ALL_T | list[str] | None = None
52+
undoc_members: Literal[True] | None = None
53+
inherited_members: Set[str] | None = None
54+
show_inheritance: Literal[True] | None = None
55+
synopsis: str | None = None
56+
platform: str | None = None
57+
deprecated: Literal[True] | None = None
58+
member_order: Literal['alphabetical', 'bysource', 'groupwise'] | None = None
59+
exclude_members: EMPTY_T | set[str] | None = None
60+
private_members: ALL_T | list[str] | None = None
61+
special_members: ALL_T | list[str] | None = None
62+
imported_members: Literal[True] | None = None
63+
ignore_module_all: Literal[True] | None = None
64+
no_value: Literal[True] | None = None
65+
66+
# class-like options (class, exception)
67+
class_doc_from: Literal['both', 'class', 'init'] | None = None
68+
69+
# assignment-like (data, attribute)
70+
annotation: SUPPRESS_T | str | None = None
71+
72+
noindex: Literal[True] | None = None
73+
74+
def __init__(self, **kwargs: Any) -> None:
75+
vars(self).update(kwargs)
76+
77+
def __repr__(self) -> str:
78+
args = ', '.join(f'{k}={v!r}' for k, v in vars(self).items())
79+
return f'_AutoDocumenterOptions({args})'
80+
81+
def __getattr__(self, name: str) -> object:
82+
return None # return None for missing attributes
83+
84+
def copy(self) -> Self:
85+
return self.__class__(**vars(self))
86+
87+
@classmethod
88+
def from_directive_options(cls, opts: Mapping[str, Any], /) -> Self:
89+
return cls(**{k.replace('-', '_'): v for k, v in opts.items() if v is not None})
90+
91+
def merge_member_options(self) -> Self:
92+
"""Merge :private-members: and :special-members: into :members:"""
93+
if self.members is ALL:
94+
# merging is not needed when members: ALL
95+
return self
96+
97+
members = self.members or []
98+
for others in self.private_members, self.special_members:
99+
if others is not None and others is not ALL:
100+
members.extend(others)
101+
new = self.copy()
102+
new.members = list(dict.fromkeys(members)) # deduplicate; preserve order
103+
return new
104+
105+
42106
def identity(x: Any) -> Any:
43107
return x
44108

45109

46-
def members_option(arg: Any) -> object | list[str]:
110+
def members_option(arg: str | None) -> ALL_T | list[str] | None:
47111
"""Used to convert the :members: option to auto directives."""
48-
if arg in {None, True}:
112+
if arg is None or arg is True:
49113
return ALL
50-
elif arg is False:
114+
if arg is False:
51115
return None
52-
else:
53-
return [x.strip() for x in arg.split(',') if x.strip()]
116+
return [stripped for x in arg.split(',') if (stripped := x.strip())]
54117

55118

56-
def exclude_members_option(arg: Any) -> object | set[str]:
119+
def exclude_members_option(arg: str | None) -> EMPTY_T | set[str]:
57120
"""Used to convert the :exclude-members: option."""
58-
if arg in {None, True}:
121+
if arg is None or arg is True:
59122
return EMPTY
60-
return {x.strip() for x in arg.split(',') if x.strip()}
123+
return {stripped for x in arg.split(',') if (stripped := x.strip())}
61124

62125

63-
def inherited_members_option(arg: Any) -> set[str]:
126+
def inherited_members_option(arg: str | None) -> set[str]:
64127
"""Used to convert the :inherited-members: option to auto directives."""
65-
if arg in {None, True}:
128+
if arg is None or arg is True:
66129
return {'object'}
67-
elif arg:
130+
if arg:
68131
return {x.strip() for x in arg.split(',')}
69-
else:
70-
return set()
132+
return set()
71133

72134

73-
def member_order_option(arg: Any) -> str | None:
135+
def member_order_option(
136+
arg: str | None,
137+
) -> Literal['alphabetical', 'bysource', 'groupwise'] | None:
74138
"""Used to convert the :member-order: option to auto directives."""
75-
if arg in {None, True}:
139+
if arg is None or arg is True:
76140
return None
77-
elif arg in {'alphabetical', 'bysource', 'groupwise'}:
78-
return arg
79-
else:
80-
raise ValueError(__('invalid value for member-order option: %s') % arg)
141+
if arg in {'alphabetical', 'bysource', 'groupwise'}:
142+
return arg # type: ignore[return-value]
143+
raise ValueError(__('invalid value for member-order option: %s') % arg)
81144

82145

83-
def class_doc_from_option(arg: Any) -> str | None:
146+
def class_doc_from_option(arg: str | None) -> Literal['both', 'class', 'init']:
84147
"""Used to convert the :class-doc-from: option to autoclass directives."""
85148
if arg in {'both', 'class', 'init'}:
86-
return arg
87-
else:
88-
raise ValueError(__('invalid value for class-doc-from option: %s') % arg)
149+
return arg # type: ignore[return-value]
150+
raise ValueError(__('invalid value for class-doc-from option: %s') % arg)
89151

90152

91-
def annotation_option(arg: Any) -> Any:
92-
if arg in {None, True}:
153+
def annotation_option(arg: str | None) -> SUPPRESS_T | str | Literal[False]:
154+
if arg is None or arg is True:
93155
# suppress showing the representation of the object
94156
return SUPPRESS
95-
else:
96-
return arg
157+
return arg
97158

98159

99-
def bool_option(arg: Any) -> bool:
160+
def bool_option(arg: str | None) -> bool:
100161
"""Used to convert flag options to auto directives. (Instead of
101162
directives.flag(), which returns None).
102163
"""
@@ -137,14 +198,14 @@ def __getattr__(self, name: str) -> Any:
137198

138199

139200
def _process_documenter_options(
140-
documenter: type[Documenter],
141201
*,
202+
option_spec: OptionSpec,
142203
default_options: dict[str, str | bool],
143-
options: dict[str, str],
144-
) -> Options:
204+
options: dict[str, str | None],
205+
) -> dict[str, object]:
145206
"""Recognize options of Documenter from user input."""
146207
for name in AUTODOC_DEFAULT_OPTIONS:
147-
if name not in documenter.option_spec:
208+
if name not in option_spec:
148209
continue
149210

150211
negated = options.pop(f'no-{name}', True) is None
@@ -153,13 +214,13 @@ def _process_documenter_options(
153214
# take value from options if present or extend it
154215
# with autodoc_default_options if necessary
155216
if name in AUTODOC_EXTENDABLE_OPTIONS:
156-
if options[name] is not None and options[name].startswith('+'):
157-
options[name] = f'{default_options[name]},{options[name][1:]}'
217+
opt_value = options[name]
218+
if opt_value is not None and opt_value.startswith('+'):
219+
options[name] = f'{default_options[name]},{opt_value[1:]}'
158220
else:
159221
options[name] = default_options[name] # type: ignore[assignment]
160-
elif options.get(name) is not None:
222+
elif (opt_value := options.get(name)) is not None:
161223
# remove '+' from option argument if there's nothing to merge it with
162-
options[name] = options[name].removeprefix('+')
224+
options[name] = opt_value.removeprefix('+')
163225

164-
opts = assemble_option_dict(options.items(), documenter.option_spec)
165-
return Options(opts)
226+
return assemble_option_dict(options.items(), option_spec) # type: ignore[arg-type]

sphinx/ext/autodoc/_documenters.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
inherited_members_option,
2020
member_order_option,
2121
members_option,
22-
merge_members_option,
2322
)
2423
from sphinx.ext.autodoc._sentinels import (
2524
ALL,
@@ -62,6 +61,7 @@
6261
from sphinx.config import Config
6362
from sphinx.environment import BuildEnvironment, _CurrentDocument
6463
from sphinx.events import EventManager
64+
from sphinx.ext.autodoc._directive_options import _AutoDocumenterOptions
6565
from sphinx.ext.autodoc.directive import DocumenterBridge
6666
from sphinx.registry import SphinxComponentRegistry
6767
from sphinx.util.typing import OptionSpec, _RestifyMode
@@ -198,7 +198,7 @@ def __init__(
198198
self.env: BuildEnvironment = directive.env
199199
self._current_document: _CurrentDocument = directive.env.current_document
200200
self._events: EventManager = directive.env.events
201-
self.options = directive.genopt
201+
self.options: _AutoDocumenterOptions = directive.genopt
202202
self.name = name
203203
self.indent = indent
204204
# the module and object path within the module, and the fully
@@ -794,7 +794,7 @@ def is_filtered_inherited_member(name: str, obj: Any) -> bool:
794794
elif is_filtered_inherited_member(membername, obj):
795795
keep = False
796796
else:
797-
keep = has_doc or self.options.undoc_members
797+
keep = has_doc or self.options.undoc_members # type: ignore[assignment]
798798
else:
799799
keep = False
800800
elif (namespace, membername) in attr_docs:
@@ -823,7 +823,7 @@ def is_filtered_inherited_member(name: str, obj: Any) -> bool:
823823
keep = False
824824
else:
825825
# ignore undocumented members if :undoc-members: is not given
826-
keep = has_doc or self.options.undoc_members
826+
keep = has_doc or self.options.undoc_members # type: ignore[assignment]
827827

828828
if isinstance(obj, ObjectMember) and obj.skipped:
829829
# forcedly skipped member (ex. a module attribute not defined in __all__)
@@ -873,7 +873,7 @@ def document_members(self, all_members: bool = False) -> None:
873873
if self.objpath:
874874
self._current_document.autodoc_class = self.objpath[0]
875875

876-
want_all = (
876+
want_all = bool(
877877
all_members or self.options.inherited_members or self.options.members is ALL
878878
)
879879
# find out which members are documentable
@@ -1101,7 +1101,7 @@ class ModuleDocumenter(Documenter):
11011101

11021102
def __init__(self, *args: Any) -> None:
11031103
super().__init__(*args)
1104-
merge_members_option(self.options)
1104+
self.options = self.options.merge_member_options()
11051105
self.__all__: Sequence[str] | None = None
11061106

11071107
def add_content(self, more_content: StringList | None) -> None:
@@ -1210,6 +1210,7 @@ def get_object_members(self, want_all: bool) -> tuple[bool, list[ObjectMember]]:
12101210

12111211
return False, list(members.values())
12121212
else:
1213+
assert self.options.members is not ALL
12131214
memberlist = self.options.members or []
12141215
ret = []
12151216
for name in memberlist:
@@ -1469,12 +1470,12 @@ def __init__(self, *args: Any) -> None:
14691470

14701471
# show __init__() method
14711472
if self.options.special_members is None:
1472-
self.options['special-members'] = ['__new__', '__init__']
1473+
self.options.special_members = ['__new__', '__init__']
14731474
else:
14741475
self.options.special_members.append('__new__')
14751476
self.options.special_members.append('__init__')
14761477

1477-
merge_members_option(self.options)
1478+
self.options = self.options.merge_member_options()
14781479

14791480
@classmethod
14801481
def can_document_member(
@@ -1769,6 +1770,7 @@ def get_object_members(self, want_all: bool) -> tuple[bool, list[ObjectMember]]:
17691770
return False, []
17701771
# specific members given
17711772
selected = []
1773+
assert self.options.members is not ALL
17721774
for name in self.options.members:
17731775
if name in members:
17741776
selected.append(members[name])
@@ -1800,9 +1802,10 @@ def get_doc(self) -> list[list[str]] | None:
18001802
if lines is not None:
18011803
return lines
18021804

1803-
classdoc_from = self.options.get(
1804-
'class-doc-from', self.config.autoclass_content
1805-
)
1805+
if self.options.class_doc_from is not None:
1806+
classdoc_from = self.options.class_doc_from
1807+
else:
1808+
classdoc_from = self.config.autoclass_content
18061809

18071810
docstrings = []
18081811
attrdocstring = getdoc(self.object, self.get_attr)

sphinx/ext/autodoc/_sentinels.py

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
TYPE_CHECKING = False
44
if TYPE_CHECKING:
5-
from typing import NoReturn, Self, _SpecialForm
5+
from typing import Final, Literal, NoReturn, Self, TypeAlias, _SpecialForm
66

77

88
class _Sentinel:
@@ -42,7 +42,7 @@ def __getstate__(self) -> NoReturn:
4242
class _All(_Sentinel):
4343
"""A special value for :*-members: that matches to any member."""
4444

45-
def __contains__(self, item: object) -> bool:
45+
def __contains__(self, item: object) -> Literal[True]:
4646
return True
4747

4848
def append(self, item: object) -> None:
@@ -52,20 +52,49 @@ def append(self, item: object) -> None:
5252
class _Empty(_Sentinel):
5353
"""A special value for :exclude-members: that never matches to any member."""
5454

55-
def __contains__(self, item: object) -> bool:
55+
def __contains__(self, item: object) -> Literal[False]:
5656
return False
5757

5858

5959
if TYPE_CHECKING:
6060
# For the sole purpose of satisfying the type checker.
6161
# fmt: off
62-
class ALL: ...
63-
class EMPTY: ...
64-
class INSTANCE_ATTR: ...
65-
class RUNTIME_INSTANCE_ATTRIBUTE: ...
66-
class SLOTS_ATTR: ...
67-
class SUPPRESS: ...
68-
class UNINITIALIZED_ATTR: ...
62+
import enum
63+
class _AllTC(enum.Enum):
64+
ALL = enum.auto()
65+
66+
def __contains__(self, item: object) -> Literal[True]: return True
67+
def append(self, item: object) -> None: pass
68+
ALL_T: TypeAlias = Literal[_AllTC.ALL]
69+
ALL: Final[ALL_T] = _AllTC.ALL
70+
71+
class _EmptyTC(enum.Enum):
72+
EMPTY = enum.auto()
73+
74+
def __contains__(self, item: object) -> Literal[False]: return False
75+
EMPTY_T: TypeAlias = Literal[_EmptyTC.EMPTY]
76+
EMPTY: Final[EMPTY_T] = _EmptyTC.EMPTY
77+
78+
class _SentinelTC(enum.Enum):
79+
INSTANCE_ATTR = enum.auto()
80+
RUNTIME_INSTANCE_ATTRIBUTE = enum.auto()
81+
SLOTS_ATTR = enum.auto()
82+
SUPPRESS = enum.auto()
83+
UNINITIALIZED_ATTR = enum.auto()
84+
INSTANCE_ATTR_T: TypeAlias = Literal[_SentinelTC.INSTANCE_ATTR]
85+
RUNTIME_INSTANCE_ATTRIBUTE_T: TypeAlias = Literal[
86+
_SentinelTC.RUNTIME_INSTANCE_ATTRIBUTE
87+
]
88+
SLOTS_ATTR_T: TypeAlias = Literal[_SentinelTC.SLOTS_ATTR]
89+
SUPPRESS_T: TypeAlias = Literal[_SentinelTC.SUPPRESS]
90+
UNINITIALIZED_ATTR_T: TypeAlias = Literal[_SentinelTC.UNINITIALIZED_ATTR]
91+
INSTANCE_ATTR: Final[INSTANCE_ATTR_T] = _SentinelTC.INSTANCE_ATTR
92+
RUNTIME_INSTANCE_ATTRIBUTE: Final[RUNTIME_INSTANCE_ATTRIBUTE_T] = (
93+
_SentinelTC.RUNTIME_INSTANCE_ATTRIBUTE
94+
)
95+
SLOTS_ATTR: Final[SLOTS_ATTR_T] = _SentinelTC.SLOTS_ATTR
96+
SUPPRESS: Final[SUPPRESS_T] = _SentinelTC.SUPPRESS
97+
UNINITIALIZED_ATTR: Final[UNINITIALIZED_ATTR_T] = _SentinelTC.UNINITIALIZED_ATTR
6998
# fmt: on
7099
else:
71100
ALL = _All('ALL')

0 commit comments

Comments
 (0)