Skip to content

Commit 84f5c1e

Browse files
committed
Fix and test async def linenos
1 parent 60d99d0 commit 84f5c1e

2 files changed

Lines changed: 337 additions & 35 deletions

File tree

src/xdoctest/static_analysis.py

Lines changed: 1 addition & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -244,8 +244,7 @@ def _visit_generic_FunctionDef(
244244
# callname = callname + '.fset'
245245
return
246246

247-
# TODO: Is this still necessary in modern Python versions?
248-
lineno = self._workaround_func_lineno(node)
247+
lineno = node.lineno
249248
docstr, doclineno, doclineno_end = self._get_docstring(node)
250249
calldef = CallDefNode(
251250
callname, lineno, docstr, doclineno, doclineno_end, args=node.args
@@ -714,39 +713,6 @@ def foo():
714713
doclineno_end = None
715714
return (docstr, doclineno, doclineno_end)
716715

717-
def _workaround_func_lineno(self, node):
718-
"""
719-
Finds the correct line for the original function definition even when
720-
decorators are involved.
721-
722-
Example:
723-
>>> source = utils.codeblock(
724-
'''
725-
@bar
726-
@baz
727-
def foo():
728-
'docstr'
729-
''')
730-
>>> self = TopLevelVisitor(source)
731-
>>> node = self.syntax_tree().body[0]
732-
>>> self._workaround_func_lineno(node)
733-
3
734-
"""
735-
# Try and find the lineno of the function definition
736-
# (maybe the fact that its on a decorator is actually right...)
737-
if node.decorator_list:
738-
# Decorators can throw off the line the function is declared on
739-
linex = node.lineno - 1
740-
pattern = r'\s*def\s*' + node.name
741-
# I think this is actually robust
742-
assert self.sourcelines is not None
743-
while not re.match(pattern, self.sourcelines[linex]):
744-
linex += 1
745-
lineno = linex + 1
746-
else:
747-
lineno = node.lineno
748-
return lineno
749-
750716

751717
def parse_static_calldefs(
752718
source: str | None = None, fpath: str | os.PathLike | None = None

tests/test_static.py

Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import pytest
12
from xdoctest import static_analysis as static
23
from xdoctest import utils
34

@@ -151,6 +152,341 @@ async def b():
151152
assert self.calldefs['b'].doclineno_end == 5
152153

153154

155+
def test_parse_decorated_async_function_lineno():
156+
source = utils.codeblock(
157+
'''
158+
def deco(func):
159+
return func
160+
161+
@deco
162+
async def foo():
163+
"""
164+
Example:
165+
>>> 1 + 1
166+
2
167+
"""
168+
return 42
169+
'''
170+
)
171+
172+
modpath = 'test_parse_decorated_async_function_lineno.py'
173+
calldefs = static.parse_static_calldefs(
174+
source=source,
175+
fpath=modpath,
176+
)
177+
178+
assert 'foo' in calldefs
179+
calldef = calldefs['foo']
180+
181+
# Should point to the `async def foo():` line, not the decorator line.
182+
source_lines = source.splitlines()
183+
assert source_lines[calldef.lineno - 1].strip() == 'async def foo():'
184+
185+
# And it should still extract the docstring normally.
186+
assert '>>> 1 + 1' in calldef.docstr
187+
188+
189+
def test_parse_multi_decorated_async_function_lineno():
190+
source = utils.codeblock(
191+
'''
192+
def deco1(func):
193+
return func
194+
195+
def deco2(func):
196+
return func
197+
198+
@deco1
199+
@deco2
200+
async def foo():
201+
"""
202+
Example:
203+
>>> 1 + 1
204+
2
205+
"""
206+
return 42
207+
'''
208+
)
209+
210+
calldefs = static.parse_static_calldefs(
211+
source=source,
212+
fpath='dummy.py',
213+
)
214+
calldef = calldefs['foo']
215+
216+
source_lines = source.splitlines()
217+
assert source_lines[calldef.lineno - 1].strip() == 'async def foo():'
218+
assert '>>> 1 + 1' in calldef.docstr
219+
220+
221+
@pytest.mark.parametrize('case', [
222+
{
223+
'name': 'decorated_async_single',
224+
'source': '''
225+
def deco(func):
226+
return func
227+
228+
@deco
229+
async def foo():
230+
"""
231+
Example:
232+
>>> 1 + 1
233+
2
234+
"""
235+
return 42
236+
''',
237+
'callname': 'foo',
238+
'expect_line': 'async def foo():',
239+
},
240+
{
241+
'name': 'decorated_async_multiple',
242+
'source': '''
243+
def deco1(func):
244+
return func
245+
246+
def deco2(func):
247+
return func
248+
249+
@deco1
250+
@deco2
251+
async def foo():
252+
"""
253+
Example:
254+
>>> 1 + 1
255+
2
256+
"""
257+
return 42
258+
''',
259+
'callname': 'foo',
260+
'expect_line': 'async def foo():',
261+
},
262+
{
263+
'name': 'decorated_async_with_args',
264+
'source': '''
265+
def deco(func):
266+
return func
267+
268+
def deco_factory(*args, **kwargs):
269+
def wrap(func):
270+
return func
271+
return wrap
272+
273+
@deco_factory(arg=1)
274+
async def foo():
275+
"""
276+
Example:
277+
>>> 1 + 1
278+
2
279+
"""
280+
return 42
281+
''',
282+
'callname': 'foo',
283+
'expect_line': 'async def foo():',
284+
},
285+
{
286+
'name': 'decorated_async_mixed',
287+
'source': '''
288+
def deco1(func):
289+
return func
290+
291+
def deco2(*args, **kwargs):
292+
def wrap(func):
293+
return func
294+
return wrap
295+
296+
@deco1
297+
@deco2(arg=1)
298+
async def foo():
299+
"""
300+
Example:
301+
>>> 1 + 1
302+
2
303+
"""
304+
return 42
305+
''',
306+
'callname': 'foo',
307+
'expect_line': 'async def foo():',
308+
},
309+
{
310+
'name': 'decorated_async_with_comment',
311+
'source': '''
312+
def deco1(func):
313+
return func
314+
315+
def deco2(func):
316+
return func
317+
318+
@deco1
319+
# comment between decorators
320+
@deco2
321+
async def foo():
322+
"""
323+
Example:
324+
>>> 1 + 1
325+
2
326+
"""
327+
return 42
328+
''',
329+
'callname': 'foo',
330+
'expect_line': 'async def foo():',
331+
},
332+
{
333+
'name': 'decorated_async_multiline_signature',
334+
'source': '''
335+
def deco(func):
336+
return func
337+
338+
@deco
339+
async def foo(
340+
a, b,
341+
c):
342+
"""
343+
Example:
344+
>>> 1 + 1
345+
2
346+
"""
347+
return 42
348+
''',
349+
'callname': 'foo',
350+
'expect_line': 'async def foo(',
351+
},
352+
{
353+
'name': 'decorated_sync_and_async_together',
354+
'source': '''
355+
def deco(func):
356+
return func
357+
358+
@deco
359+
def bar():
360+
"""
361+
Example:
362+
>>> "bar"
363+
'bar'
364+
"""
365+
return 'bar'
366+
367+
@deco
368+
async def foo():
369+
"""
370+
Example:
371+
>>> 1 + 1
372+
2
373+
"""
374+
return 42
375+
''',
376+
'callname': 'foo',
377+
'expect_line': 'async def foo():',
378+
},
379+
{
380+
'name': 'decorated_method_in_class',
381+
'source': '''
382+
def deco(func):
383+
return func
384+
385+
class C:
386+
@classmethod
387+
@deco
388+
async def foo(cls):
389+
"""
390+
Example:
391+
>>> 1 + 1
392+
2
393+
"""
394+
return 42
395+
''',
396+
'callname': 'C.foo',
397+
'expect_line': 'async def foo(cls):',
398+
},
399+
{
400+
'name': 'decorated_staticmethod_in_class',
401+
'source': '''
402+
def deco(func):
403+
return func
404+
405+
class C:
406+
@staticmethod
407+
@deco
408+
async def foo():
409+
"""
410+
Example:
411+
>>> 1 + 1
412+
2
413+
"""
414+
return 42
415+
''',
416+
'callname': 'C.foo',
417+
'expect_line': 'async def foo():',
418+
},
419+
{
420+
'name': 'decorated_async_after_module_docstring_and_blanks',
421+
'source': '''
422+
"""
423+
module docstring
424+
"""
425+
426+
427+
def deco(func):
428+
return func
429+
430+
431+
@deco
432+
async def foo():
433+
"""
434+
Example:
435+
>>> 1 + 1
436+
2
437+
"""
438+
return 42
439+
''',
440+
'callname': 'foo',
441+
'expect_line': 'async def foo():',
442+
},
443+
{
444+
'name': 'decorated_async_non_ascii_prefix',
445+
'source': '''
446+
# café
447+
448+
def deco(func):
449+
return func
450+
451+
@deco
452+
async def foo():
453+
"""
454+
Example:
455+
>>> 1 + 1
456+
2
457+
"""
458+
return 42
459+
''',
460+
'callname': 'foo',
461+
'expect_line': 'async def foo():',
462+
},
463+
])
464+
def test_parse_decorated_function_lineno_cases(case):
465+
source = utils.codeblock(case['source'])
466+
467+
calldefs = static.parse_static_calldefs(
468+
source=source,
469+
fpath=case['name'] + '.py',
470+
)
471+
472+
assert case['callname'] in calldefs, (
473+
'Missing calldef {!r} in case {!r}. Got keys={!r}'.format(
474+
case['callname'], case['name'], sorted(calldefs.keys()))
475+
)
476+
477+
calldef = calldefs[case['callname']]
478+
source_lines = source.splitlines()
479+
480+
got_line = source_lines[calldef.lineno - 1].strip()
481+
assert got_line == case['expect_line'], (
482+
'Wrong lineno for case={!r}. Expected line={!r}, got line={!r}, '
483+
'lineno={!r}'.format(
484+
case['name'], case['expect_line'], got_line, calldef.lineno)
485+
)
486+
487+
assert '>>> 1 + 1' in calldef.docstr or ">>> \"bar\"" in calldef.docstr
488+
489+
154490
if __name__ == '__main__':
155491
"""
156492
CommandLine:

0 commit comments

Comments
 (0)