Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Doctest incorrectly locates a decorated function #115392

Closed
chaudhary1337 opened this issue Feb 13, 2024 · 4 comments
Closed

Doctest incorrectly locates a decorated function #115392

chaudhary1337 opened this issue Feb 13, 2024 · 4 comments
Labels
type-bug An unexpected behavior, bug, or error

Comments

@chaudhary1337
Copy link

chaudhary1337 commented Feb 13, 2024

Bug report

Bug description:

TL;DR: If a function is decorated, the doctest is unable to find the correct location of the function.

Example

Consider two simple files, main.py and decorate.py.

Contents of main.py:

from decorate import decorator


@decorator
def foo():
    """
    >>> foo()
    2
    """
    return 42

Contents of decorate.py:

import functools


def decorator(f):
    @functools.wraps(f)
    def inner():
        return f()

    return inner

If we run a doctest like so: python3 -m doctest main.py, we find the error correctly on the line number 7, the line which says >>> foo(). Traceback is output as follows.

**********************************************************************
File "/codemill/chaudhat/learning/demo/main.py", line 7, in main.foo
Failed example:
    foo()
Expected:
    2
Got:
    42
**********************************************************************
1 items had failures:
   1 of   2 in main.foo
***Test Failed*** 1 failures.

Incorrect Output

However, if we move the decorator definition in the decorate.py file by a few lines, as shown, (the space between could be empty/defining a function, etc.), we see that the doctest is unable to find the location of the decorated function, foo, and just outputs ? as the line number.

import functools







def decorator(f):
    @functools.wraps(f)
    def inner():
        return f()

    return inner

Traceback:

**********************************************************************
File "/codemill/chaudhat/learning/demo/main.py", line ?, in main.foo
Failed example:
    foo()
Expected:
    2
Got:
    42
**********************************************************************
1 items had failures:
   1 of   1 in main.foo
***Test Failed*** 1 failures.

PS: If move the decorator definition by even a line up, it shows that the line, >>> foo() incorrectly lives on line 10 and not line 7.

Why?

The "?" is printed simply because while doctest is able to find the example's lineno, it is unable to understand the test's lineno. I found this after printing out the line numbers in the _failure_header function in doctest.py.

CPython versions tested on:

3.11

Operating systems tested on:

Linux

Linked PRs

@chaudhary1337 chaudhary1337 added the type-bug An unexpected behavior, bug, or error label Feb 13, 2024
@brianschubert
Copy link
Contributor

I traced this bug down to inside DocTestFinder._find_lineno:

cpython/Lib/doctest.py

Lines 1141 to 1163 in 4deb705

if inspect.isfunction(obj) and getattr(obj, '__doc__', None):
# We don't use `docstring` var here, because `obj` can be changed.
obj = obj.__code__
if inspect.istraceback(obj): obj = obj.tb_frame
if inspect.isframe(obj): obj = obj.f_code
if inspect.iscode(obj):
lineno = obj.co_firstlineno - 1
# Find the line number where the docstring starts. Assume
# that it's the first line that begins with a quote mark.
# Note: this could be fooled by a multiline function
# signature, where a continuation line begins with a quote
# mark.
if lineno is not None:
if source_lines is None:
return lineno+1
pat = re.compile(r'(^|.*:)\s*\w*("|\')')
for lineno in range(lineno, len(source_lines)):
if pat.match(source_lines[lineno]):
return lineno
# We couldn't find the line number.
return None

While processing the decorated function foo, the following occurs

  • obj starts as main.foo, which thanks to the decorator is actually inner.
  • At line 1143, obj = obj.__code__ assigns the code object for inner to obj.
  • At line 1147, lineno = obj.co_firstlineno - 1 retrieves the line number of inner inside decorate.py
  • At line 1154, we search the source lines of main.py for the start of the next docstring after lineno. This is a problem, since we're searching the lines of main.py using a line number from decorate.py.

If we run a doctest like so: python3 -m doctest main.py, we find the error correctly on the line number 7

This is actually purely accidental. It only works because in this case because inner is defined on line 6 of decorate.py while the docstring of foo starts on line 6 of main.py. If you move inner down a few lines so that it starts on line 8 of decorate.py , you will get the line number to be 10 instead, since _find_lineno will find the next quote to be the closing quote of foo's docstring.

@boshea93
Copy link

boshea93 commented Feb 13, 2024

I traced this bug down to inside DocTestFinder._find_lineno:

cpython/Lib/doctest.py

Lines 1141 to 1163 in 4deb705

if inspect.isfunction(obj) and getattr(obj, '__doc__', None):
# We don't use `docstring` var here, because `obj` can be changed.
obj = obj.__code__
if inspect.istraceback(obj): obj = obj.tb_frame
if inspect.isframe(obj): obj = obj.f_code
if inspect.iscode(obj):
lineno = obj.co_firstlineno - 1
# Find the line number where the docstring starts. Assume
# that it's the first line that begins with a quote mark.
# Note: this could be fooled by a multiline function
# signature, where a continuation line begins with a quote
# mark.
if lineno is not None:
if source_lines is None:
return lineno+1
pat = re.compile(r'(^|.*:)\s*\w*("|\')')
for lineno in range(lineno, len(source_lines)):
if pat.match(source_lines[lineno]):
return lineno
# We couldn't find the line number.
return None

While processing the decorated function foo, the following occurs

  • obj starts as main.foo, which thanks to the decorator is actually inner.
  • At line 1143, obj = obj.__code__ assigns the code object for inner to obj.
  • At line 1147, lineno = obj.co_firstlineno - 1 retrieves the line number of inner inside decorate.py
  • At line 1154, we search the source lines of main.py for the start of the next docstring after lineno. This is a problem, since we're searching the lines of main.py using a line number from decorate.py.

If we run a doctest like so: python3 -m doctest main.py, we find the error correctly on the line number 7

This is actually purely accidental. It only works because in this case because inner is defined on line 6 of decorate.py while the docstring of foo starts on line 6 of main.py. If you move inner down a few lines so that it starts on line 8 of decorate.py , you will get the line number to be 10 instead, since _find_lineno will find the next quote to be the closing quote of foo's docstring.

@brianschubert Would the fix for this be to check if objects that are functions or methods are wrapped? There could be a check if the function has the attribute __wrapped__, or is a deeper fix needed?

@brianschubert
Copy link
Contributor

brianschubert commented Feb 13, 2024

@boshea93 I was just writing the same. I think a possible fix would be to check for a __wrapped__ attribute on obj inside the if inspect.isfunction(obj) check. We would need to do this repeatedly, since there could be multiple layers of decorators. This seem to work well enough in the tests I've run, but I'm still looking for other edge cases. PR forthcoming.

miss-islington pushed a commit to miss-islington/cpython that referenced this issue Feb 14, 2024
…orated functions (pythonGH-115440)

(cherry picked from commit bb791c7)

Co-authored-by: Brian Schubert <brianm.schubert@gmail.com>
miss-islington pushed a commit to miss-islington/cpython that referenced this issue Feb 14, 2024
…orated functions (pythonGH-115440)

(cherry picked from commit bb791c7)

Co-authored-by: Brian Schubert <brianm.schubert@gmail.com>
AlexWaygood pushed a commit that referenced this issue Feb 14, 2024
…corated functions (GH-115440) (#115458)

gh-115392: Fix doctest reporting incorrect line numbers for decorated functions (GH-115440)
(cherry picked from commit bb791c7)

Co-authored-by: Brian Schubert <brianm.schubert@gmail.com>
AlexWaygood pushed a commit that referenced this issue Feb 14, 2024
…corated functions (GH-115440) (#115459)

gh-115392: Fix doctest reporting incorrect line numbers for decorated functions (GH-115440)
(cherry picked from commit bb791c7)

Co-authored-by: Brian Schubert <brianm.schubert@gmail.com>
@AlexWaygood
Copy link
Member

AlexWaygood commented Feb 14, 2024

Thanks for the fix @brianschubert, and thanks for the report @chaudhary1337!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
type-bug An unexpected behavior, bug, or error
Projects
None yet
Development

No branches or pull requests

4 participants