Skip to content

Doctest incorrectly locates a decorated function #115392

Closed

Description

@chaudhary1337

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

Activity

  1. brianschubert commented on Feb 13, 2024

    @brianschubert
    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.

  2. boshea93 commented on Feb 13, 2024

    @boshea93

    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?

  3. brianschubert commented on Feb 13, 2024

    @brianschubert
    Contributor

    @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.

  4. added a commit that references this issue on Feb 14, 2024
  5. added 2 commits that reference this issue on Feb 14, 2024
  6. added 2 commits that reference this issue on Feb 14, 2024
  7. AlexWaygood commented on Feb 14, 2024

    @AlexWaygood
    Member

    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

Metadata

Metadata

Assignees

No one assigned

    Labels

    type-bugAn unexpected behavior, bug, or error

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions