Skip to content

Commit 76658c4

Browse files
picnixzAA-Turner
andauthored
Fix sphinx.ext.autodoc.preserve_defaults (#11550)
Co-authored-by: Adam Turner <[email protected]>
1 parent 4dee162 commit 76658c4

File tree

6 files changed

+305
-9
lines changed

6 files changed

+305
-9
lines changed

CHANGES

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ Deprecated
2727
``sphinx.builders.html.StandaloneHTMLBuilder.script_files``.
2828
Use ``sphinx.application.Sphinx.add_css_file()``
2929
and ``sphinx.application.Sphinx.add_js_file()`` instead.
30+
* #11459: Deprecate ``sphinx.ext.autodoc.preserve_defaults.get_function_def()``.
31+
Patch by Bénédikt Tran.
3032

3133
Features added
3234
--------------
@@ -91,6 +93,9 @@ Bugs fixed
9193
* #11594: HTML Theme: Enhancements to horizontal scrolling on smaller
9294
devices in the ``agogo`` theme.
9395
Patch by Lukas Engelter.
96+
* #11459: Fix support for async and lambda functions in
97+
``sphinx.ext.autodoc.preserve_defaults``.
98+
Patch by Bénédikt Tran.
9499

95100
Testing
96101
-------

doc/extdev/deprecated.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ The following is a list of deprecated interfaces.
2222
- Removed
2323
- Alternatives
2424

25+
* - ``sphinx.ext.autodoc.preserve_defaults.get_function_def()``
26+
- 7.2
27+
- 9.0
28+
- N/A (replacement is private)
29+
2530
* - ``sphinx.builders.html.StandaloneHTMLBuilder.css_files``
2631
- 7.2
2732
- 9.0

sphinx/ext/autodoc/preserve_defaults.py

Lines changed: 75 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,23 @@
88

99
import ast
1010
import inspect
11-
from typing import TYPE_CHECKING, Any
11+
import types
12+
import warnings
13+
from typing import TYPE_CHECKING
1214

1315
import sphinx
16+
from sphinx.deprecation import RemovedInSphinx90Warning
1417
from sphinx.locale import __
1518
from sphinx.pycode.ast import unparse as ast_unparse
1619
from sphinx.util import logging
1720

1821
if TYPE_CHECKING:
22+
from typing import Any
23+
1924
from sphinx.application import Sphinx
2025

2126
logger = logging.getLogger(__name__)
27+
_LAMBDA_NAME = (lambda: None).__name__
2228

2329

2430
class DefaultValue:
@@ -31,12 +37,19 @@ def __repr__(self) -> str:
3137

3238
def get_function_def(obj: Any) -> ast.FunctionDef | None:
3339
"""Get FunctionDef object from living object.
40+
3441
This tries to parse original code for living object and returns
3542
AST node for given *obj*.
3643
"""
44+
warnings.warn('sphinx.ext.autodoc.preserve_defaults.get_function_def is'
45+
' deprecated and scheduled for removal in Sphinx 9.'
46+
' Use sphinx.ext.autodoc.preserve_defaults._get_arguments() to'
47+
' extract AST arguments objects from a lambda or regular'
48+
' function.', RemovedInSphinx90Warning, stacklevel=2)
49+
3750
try:
3851
source = inspect.getsource(obj)
39-
if source.startswith((' ', r'\t')):
52+
if source.startswith((' ', '\t')):
4053
# subject is placed inside class or block. To read its docstring,
4154
# this adds if-block before the declaration.
4255
module = ast.parse('if True:\n' + source)
@@ -48,6 +61,53 @@ def get_function_def(obj: Any) -> ast.FunctionDef | None:
4861
return None
4962

5063

64+
def _get_arguments(obj: Any, /) -> ast.arguments | None:
65+
"""Parse 'ast.arguments' from an object.
66+
67+
This tries to parse the original code for an object and returns
68+
an 'ast.arguments' node.
69+
"""
70+
try:
71+
source = inspect.getsource(obj)
72+
if source.startswith((' ', '\t')):
73+
# 'obj' is in some indented block.
74+
module = ast.parse('if True:\n' + source)
75+
subject = module.body[0].body[0] # type: ignore[attr-defined]
76+
else:
77+
module = ast.parse(source)
78+
subject = module.body[0]
79+
except (OSError, TypeError):
80+
# bail; failed to load source for 'obj'.
81+
return None
82+
except SyntaxError:
83+
if _is_lambda(obj):
84+
# Most likely a multi-line arising from detecting a lambda, e.g.:
85+
#
86+
# class Egg:
87+
# x = property(
88+
# lambda self: 1, doc="...")
89+
return None
90+
91+
# Other syntax errors that are not due to the fact that we are
92+
# documenting a lambda function are propagated
93+
# (in particular if a lambda is renamed by the user).
94+
raise
95+
96+
return _get_arguments_inner(subject)
97+
98+
99+
def _is_lambda(x, /):
100+
return isinstance(x, types.LambdaType) and x.__name__ == _LAMBDA_NAME
101+
102+
103+
def _get_arguments_inner(x: Any, /) -> ast.arguments | None:
104+
if isinstance(x, (ast.AsyncFunctionDef, ast.FunctionDef, ast.Lambda)):
105+
return x.args
106+
if isinstance(x, (ast.Assign, ast.AnnAssign)):
107+
return _get_arguments_inner(x.value)
108+
return None
109+
110+
51111
def get_default_value(lines: list[str], position: ast.AST) -> str | None:
52112
try:
53113
if position.lineno == position.end_lineno:
@@ -67,18 +127,24 @@ def update_defvalue(app: Sphinx, obj: Any, bound_method: bool) -> None:
67127

68128
try:
69129
lines = inspect.getsource(obj).splitlines()
70-
if lines[0].startswith((' ', r'\t')):
71-
lines.insert(0, '') # insert a dummy line to follow what get_function_def() does.
130+
if lines[0].startswith((' ', '\t')):
131+
# insert a dummy line to follow what _get_arguments() does.
132+
lines.insert(0, '')
72133
except (OSError, TypeError):
73134
lines = []
74135

75136
try:
76-
function = get_function_def(obj)
77-
assert function is not None # for mypy
78-
if function.args.defaults or function.args.kw_defaults:
137+
args = _get_arguments(obj)
138+
if args is None:
139+
# If the object is a built-in, we won't be always able to recover
140+
# the function definition and its arguments. This happens if *obj*
141+
# is the `__init__` method generated automatically for dataclasses.
142+
return
143+
144+
if args.defaults or args.kw_defaults:
79145
sig = inspect.signature(obj)
80-
defaults = list(function.args.defaults)
81-
kw_defaults = list(function.args.kw_defaults)
146+
defaults = list(args.defaults)
147+
kw_defaults = list(args.kw_defaults)
82148
parameters = list(sig.parameters.values())
83149
for i, param in enumerate(parameters):
84150
if param.default is param.empty:

tests/roots/test-ext-autodoc/target/preserve_defaults.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,31 @@ def clsmeth(cls, name: str = CONSTANT, sentinel: Any = SENTINEL,
3030
now: datetime = datetime.now(), color: int = 0xFFFFFF,
3131
*, kwarg1, kwarg2 = 0xFFFFFF) -> None:
3232
"""docstring"""
33+
34+
35+
get_sentinel = lambda custom=SENTINEL: custom
36+
"""docstring"""
37+
38+
39+
class MultiLine:
40+
"""docstring"""
41+
42+
# The properties will raise a silent SyntaxError because "lambda self: 1"
43+
# will be detected as a function to update the default values of. However,
44+
# only prop3 will not fail because it's on a single line whereas the others
45+
# will fail to parse.
46+
47+
prop1 = property(
48+
lambda self: 1, doc="docstring")
49+
50+
prop2 = property(
51+
lambda self: 2, doc="docstring"
52+
)
53+
54+
prop3 = property(lambda self: 3, doc="docstring")
55+
56+
prop4 = (property
57+
(lambda self: 4, doc="docstring"))
58+
59+
prop5 = property\
60+
(lambda self: 5, doc="docstring")
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from __future__ import annotations
2+
3+
from collections import namedtuple
4+
from dataclasses import dataclass, field
5+
from typing import NamedTuple, TypedDict
6+
7+
#: docstring
8+
SENTINEL = object()
9+
10+
11+
#: docstring
12+
ze_lambda = lambda z=SENTINEL: None
13+
14+
15+
def foo(x, y, z=SENTINEL):
16+
"""docstring"""
17+
18+
19+
@dataclass
20+
class DataClass:
21+
"""docstring"""
22+
a: int
23+
b: object = SENTINEL
24+
c: list[int] = field(default_factory=lambda: [1, 2, 3])
25+
26+
27+
@dataclass(init=False)
28+
class DataClassNoInit:
29+
"""docstring"""
30+
a: int
31+
b: object = SENTINEL
32+
c: list[int] = field(default_factory=lambda: [1, 2, 3])
33+
34+
35+
class MyTypedDict(TypedDict):
36+
"""docstring"""
37+
a: int
38+
b: object
39+
c: list[int]
40+
41+
42+
class MyNamedTuple1(NamedTuple):
43+
"""docstring"""
44+
a: int
45+
b: object = object()
46+
c: list[int] = [1, 2, 3]
47+
48+
49+
class MyNamedTuple2(namedtuple('Base', ('a', 'b'), defaults=(0, SENTINEL))):
50+
"""docstring"""

tests/test_ext_autodoc_preserve_defaults.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,153 @@ def test_preserve_defaults(app):
4040
' docstring',
4141
'',
4242
'',
43+
'.. py:class:: MultiLine()',
44+
' :module: target.preserve_defaults',
45+
'',
46+
' docstring',
47+
'',
48+
'',
49+
' .. py:property:: MultiLine.prop1',
50+
' :module: target.preserve_defaults',
51+
'',
52+
' docstring',
53+
'',
54+
'',
55+
' .. py:property:: MultiLine.prop2',
56+
' :module: target.preserve_defaults',
57+
'',
58+
' docstring',
59+
'',
60+
'',
61+
' .. py:property:: MultiLine.prop3',
62+
' :module: target.preserve_defaults',
63+
'',
64+
' docstring',
65+
'',
66+
'',
67+
' .. py:property:: MultiLine.prop4',
68+
' :module: target.preserve_defaults',
69+
'',
70+
' docstring',
71+
'',
72+
'',
73+
' .. py:property:: MultiLine.prop5',
74+
' :module: target.preserve_defaults',
75+
'',
76+
' docstring',
77+
'',
78+
'',
4379
'.. py:function:: foo(name: str = CONSTANT, sentinel: ~typing.Any = SENTINEL, '
4480
'now: ~datetime.datetime = datetime.now(), color: int = %s, *, kwarg1, '
4581
'kwarg2=%s) -> None' % (color, color),
4682
' :module: target.preserve_defaults',
4783
'',
4884
' docstring',
4985
'',
86+
'',
87+
'.. py:function:: get_sentinel(custom=SENTINEL)',
88+
' :module: target.preserve_defaults',
89+
'',
90+
' docstring',
91+
'',
92+
]
93+
94+
95+
@pytest.mark.sphinx('html', testroot='ext-autodoc',
96+
confoverrides={'autodoc_preserve_defaults': True})
97+
def test_preserve_defaults_special_constructs(app):
98+
options = {"members": None}
99+
actual = do_autodoc(app, 'module', 'target.preserve_defaults_special_constructs', options)
100+
101+
# * dataclasses.dataclass:
102+
# - __init__ source code is not available
103+
# - default values specified at class level are not discovered
104+
# - values wrapped in a field(...) expression cannot be analyzed
105+
# easily even if annotations were to be parsed
106+
# * typing.NamedTuple:
107+
# - __init__ source code is not available
108+
# - default values specified at class level are not discovered
109+
# * collections.namedtuple:
110+
# - default values are specified as "default=(d1, d2, ...)"
111+
#
112+
# In the future, it might be possible to find some additional default
113+
# values by parsing the source code of the annotations but the task is
114+
# rather complex.
115+
116+
assert list(actual) == [
117+
'',
118+
'.. py:module:: target.preserve_defaults_special_constructs',
119+
'',
120+
'',
121+
'.. py:class:: DataClass('
122+
'a: int, b: object = <object object>, c: list[int] = <factory>)',
123+
' :module: target.preserve_defaults_special_constructs',
124+
'',
125+
' docstring',
126+
'',
127+
'',
128+
'.. py:class:: DataClassNoInit()',
129+
' :module: target.preserve_defaults_special_constructs',
130+
'',
131+
' docstring',
132+
'',
133+
'',
134+
'.. py:class:: MyNamedTuple1('
135+
'a: int, b: object = <object object>, c: list[int] = [1, 2, 3])',
136+
' :module: target.preserve_defaults_special_constructs',
137+
'',
138+
' docstring',
139+
'',
140+
'',
141+
' .. py:attribute:: MyNamedTuple1.a',
142+
' :module: target.preserve_defaults_special_constructs',
143+
' :type: int',
144+
'',
145+
' Alias for field number 0',
146+
'',
147+
'',
148+
' .. py:attribute:: MyNamedTuple1.b',
149+
' :module: target.preserve_defaults_special_constructs',
150+
' :type: object',
151+
'',
152+
' Alias for field number 1',
153+
'',
154+
'',
155+
' .. py:attribute:: MyNamedTuple1.c',
156+
' :module: target.preserve_defaults_special_constructs',
157+
' :type: list[int]',
158+
'',
159+
' Alias for field number 2',
160+
'',
161+
'',
162+
'.. py:class:: MyNamedTuple2(a=0, b=<object object>)',
163+
' :module: target.preserve_defaults_special_constructs',
164+
'',
165+
' docstring',
166+
'',
167+
'',
168+
'.. py:class:: MyTypedDict',
169+
' :module: target.preserve_defaults_special_constructs',
170+
'',
171+
' docstring',
172+
'',
173+
'',
174+
'.. py:data:: SENTINEL',
175+
' :module: target.preserve_defaults_special_constructs',
176+
' :value: <object object>',
177+
'',
178+
' docstring',
179+
'',
180+
'',
181+
'.. py:function:: foo(x, y, z=SENTINEL)',
182+
' :module: target.preserve_defaults_special_constructs',
183+
'',
184+
' docstring',
185+
'',
186+
'',
187+
'.. py:function:: ze_lambda(z=SENTINEL)',
188+
' :module: target.preserve_defaults_special_constructs',
189+
'',
190+
' docstring',
191+
'',
50192
]

0 commit comments

Comments
 (0)