Open nedbat opened 8 years ago
Original comment by Loic Dachary (Bitbucket: dachary, GitHub: dachary)
During coverage report the arcs found by analysing the AST tree of a source file should show one line per multiline statement because they are remapped to the first line of the statement.
However, the first_line function relies on the multiline data member that only remaps positive line numbers.
For example, the set comprehension at line 3 in
#!python
def test_foo():
assert {i for i in range(10)} == {
i for i in range(10)
}
is added as two arcs as (3, -3) and (-3, 3) as shown by
#!bash
$ coverage run --branch --source=test_foo.py -m pytest test_foo.py
$ COVERAGE_ASTDUMP=1 COVERAGE_TRACK_ARCS=1 coverage report
...
Adding arc: (-3, 3): None, "didn't run the set comprehension on line 3"
Adding arc: (3, -3): None, "didn't finish the set comprehension on line 3"
The arcs are then renumbered by _analyze_ast and
#!python
[(-3, 3), (-2, 2), (-1, 1), (-1, 2), (1, -1), (2, -2), (2, -1), (3, -3)]
becomes
#!python
[(-3, 2), (-2, 2), (-1, 1), (-1, 2), (1, -1), (2, -2), (2, -1), (2, -3)]
As if the (-3, 2) and (2, -3) arcs existed, although they do not. When the arcs_missing function compares that to the actual arcs collected by coverage run which are stored in the .coverage file:
#!python
[(-2, 2), (-1, 1), (-1, 2), (1, -1), (2, -2), (2, -1), (2, 2)]
and concludes that the following arcs have not be executed
#!python
[(-3, 2), (2, -3)]
Note: this can be verified by printing the local variables of the arcs_missing function.
Original comment by Loic Dachary (Bitbucket: dachary, GitHub: dachary)
@nedbat I think it happens because multiline re-numbering does not happen for negative line numbers and it could be fixed with
#!diff
--- a/coverage/parser.py Mon Dec 12 15:07:48 2016 +0100
+++ b/coverage/parser.py Wed Dec 14 16:31:35 2016 +0100
@@ -183,6 +183,7 @@
# so record a multi-line range.
for l in range(first_line, elineno+1):
self._multiline[l] = first_line
+ self._multiline[-l] = -first_line
first_line = None
first_on_line = True
Without this patch the (3,-3) and (-3,3) arcs are renumbered (2,-3) and (-3,2) which is incorrect because there is no arc between line 2 and line 3. With this patch the (3,-3) and (-3,3) arcs are renumbered (2,-2) and (-2,2) and the code coverage is correct.
Do you think it could be the root cause of the problem ? If you're not sure I can try to explain why it accounts for all the symptoms.
I'm not following all the details here, but I love that you are digging into it! When you have something for me to do, leave a clear message telling me what it is!
Original comment by Loic Dachary (Bitbucket: dachary, GitHub: dachary)
@nicoddemus
#!python
def test_foo():
assert func_call() == \
func_call2()
works because it does not contain any branch and coverage.py does not care about the last line. It records the assert as a single line which, if not run, indicates the function never returned (see arc 8, -7 below and notice the absence of arc involving line 9 which is the call to func_call2):
#!python
$ coverage run --branch --source=test_bar.py -m pytest test_bar.py
$ COVERAGE_TRACK_ARCS=1 coverage report
..
Adding arc: (-1, 1): None, None
Adding arc: (1, 4): None, None
Adding arc: (4, 7): None, None
Adding arc: (7, -1): None, "didn't exit the module"
Adding arc: (-1, 2): None, None
Adding arc: (2, -1): None, "didn't return from function 'func_call'"
Adding arc: (-4, 5): None, None
Adding arc: (5, -4): None, "didn't return from function 'func_call2'"
Adding arc: (-7, 8): None, None
Adding arc: (8, -7): None, "didn't return from function 'test_foo'"
..
By contrast the assert from the original bug description contains arcs and coverage.py expects them to be run (see the arcs -3, 3 and 3, -3 which are about the second part of the assert):
#!python
$ coverage run --branch --source=test_foo.py -m pytest test_foo.py
$ COVERAGE_TRACK_ARCS=1 coverage report
..
Adding arc: (-1, 1): None, None
Adding arc: (1, -1): None, "didn't exit the module"
Adding arc: (-1, 2): None, None
Adding arc: (2, -1): None, "didn't return from function 'test_foo'"
Adding arc: (-2, 2): None, "didn't run the set comprehension on line 2"
Adding arc: (2, -2): None, "didn't finish the set comprehension on line 2"
Adding arc: (-3, 3): None, "didn't run the set comprehension on line 3"
Adding arc: (3, -3): None, "didn't finish the set comprehension on line 3"
Original comment by Loic Dachary (Bitbucket: dachary, GitHub: dachary)
pytest indeed assigns the line number of the rewritten assertion to the first line number of the assertion. However, since coverage.py recognize the assertion as a multiline expression, it should not make a difference.
Original comment by Loic Dachary (Bitbucket: dachary, GitHub: dachary)
The HTML message is associated with the line when the AST is analyzed (and as shown above, pytest does not modify the AST when rewriting). When the HTML report is created, this message is displayed because the line is reported to not be covered. Therefore it does not convey more information than the summary report stating the line is not covered.
Original comment by Loic Dachary (Bitbucket: dachary, GitHub: dachary)
I should have paid more attention to the original description that mentions the HTML message "line N didn't finish the set comprehension on line N" and try to figure out why it happens
Original comment by Loic Dachary (Bitbucket: dachary, GitHub: dachary)
I think it may simply be because the coverage.py parser does not recognize
#!python
assert {i for i in range(10)} == {
i for i in range(10)
}
as a multiline expression as it should.
Original comment by Loic Dachary (Bitbucket: dachary, GitHub: dachary)
Hum, you're right.
#!python
def func_call():
pass
def func_call2():
pass
def test_foo():
assert func_call() == \
func_call2()
does not cause any problem although line 9 does not show at all in the .coverage file.
#!bash
$ coverage run --branch --source=test_bar.py -m pytest test_bar.py
$ cat .coverage
...[[-4,5],[8,-7],[-1,1],[2,-1],[-1,2],[4,7],[1,4],[7,-1],[-7,8],[5,-4]]...
$ coverage report
Name Stmts Miss Branch BrPart Cover
-----------------------------------------------
test_bar.py 6 0 0 0 100%
$ coverage run --branch --source=test_bar.py -m pytest --assert=plain test_bar.py
$ cat .coverage
...[[-4,5],[-1,1],[2,-1],[-1,2],[4,7],[1,4],[9,-7],[8,9],[7,-1],[-7,8],[5,-4]]...
$ coverage report
Name Stmts Miss Branch BrPart Cover
-----------------------------------------------
test_bar.py 6 0 0 0 100%
Original comment by Bruno Oliveira (Bitbucket: nicoddemus, GitHub: nicoddemus)
This could be explained because pytest rewrites the assert as a single line instead of multiline. The content of the .coverage file is created from line events sent via the sys.setttrace to the coverage.py trace function and reflect the code re-written by pytest. However, coverage report compiles the unmodified source and finds that some lines are not covered.
This makes sense, but wouldn't this type of assert also cause the same problem?
def func_call(): return 1
def func_call2(): return 1
def test_foo():
assert func_call() == \
func_call2()
?
Pytest will break each operand into its own statement in that case as well:
=========================== Rewritten AST as Python ===========================
import builtins as @py_builtins
import _pytest.assertion.rewrite as @pytest_ar
def func_call():
return 1
def func_call2():
return 1
def test_foo():
@py_assert1 = func_call()
@py_assert5 = func_call2()
@py_assert3 = @py_assert1 == @py_assert5
if not @py_assert3:
@py_format7 = @pytest_ar._call_reprcompare(('==',), (@py_assert3,), ('''%(py2)s
{%(py2)s = %(py0)s()
} == %(py6)s
{%(py6)s = %(py4)s()
}''',), (@py_assert1, @py_assert5)) % {'py6': @pytest_ar._saferepr(@py_assert5), 'py4': @pytest_ar._saferepr(func_call2) if 'func_call2' in @py_builtins.locals() or @pytest_ar._should_repr_global_name(func_call2) else 'func_call2', 'py2': @pytest_ar._saferepr(@py_assert1), 'py0': @pytest_ar._saferepr(func_call) if 'func_call' in @py_builtins.locals() or @pytest_ar._should_repr_global_name(func_call) else 'func_call'}
@py_format9 = ('' + 'assert %(py8)s') % {'py8': @py_format7}
raise AssertionError(@pytest_ar._format_explanation(@py_format9))
@py_assert1 = @py_assert3 = @py_assert5 = None
Original comment by Loic Dachary (Bitbucket: dachary, GitHub: dachary)
The test_foo.py file is below and virtualenv is set with python-2.7.12 and pytest-2.8.7 and coverage.py at 24aff3d7bfd5 (bb)
#!python
def test_foo():
assert {i for i in range(10)} == {
i for i in range(10)
}
#!bash
$ coverage run --branch --source=test_foo.py -m pytest test_foo.py
$ cat .coverage ; echo
...[[2,-1],[-1,1],[-1,2],[2,-2],[-2,2],[2,2],[1,-1]]..
$ coverage report
Name Stmts Miss Branch BrPart Cover
-----------------------------------------------
test_foo.py 2 0 3 1 80%
This could be explained because pytest rewrites the assert as a single line instead of multiline. The content of the .coverage file is created from line events sent via sys.settrace to the coverage.py trace function and reflect the code re-written by pytest. However, coverage report compiles the unmodified source and finds that some lines are not covered.
Original comment by Bruno Oliveira (Bitbucket: nicoddemus, GitHub: nicoddemus)
Hi @dachary,
Here's the rewritten AST of your examplem, courtesy of pytest-ast-back-to-python:
============================= test session starts =============================
platform win32 -- Python 3.5.0, pytest-3.0.5, py-1.4.31, pluggy-0.4.0
rootdir: c:\pytest, inifile: tox.ini
plugins: ast-back-to-python-0.1.0
collected 1 items
c:\pytest\.tmp\cov_test.py .
=========================== Rewritten AST as Python ===========================
import builtins as @py_builtins
import _pytest.assertion.rewrite as @pytest_ar
def test_foo():
@py_assert0 = {i for i in range(10)}
@py_assert3 = {i for i in range(10)}
@py_assert2 = @py_assert0 == @py_assert3
if not @py_assert2:
@py_format5 = @pytest_ar._call_reprcompare(('==',), (@py_assert2,), ('%(py1)s == %(py4)s',), (@py_assert0, @py_assert3)) % {'py1': @pytest_ar._saferepr(@py_assert0), 'py4': @pytest_ar._saferepr(@py_assert3)}
@py_format7 = ('' + 'assert %(py6)s') % {'py6': @py_format5}
raise AssertionError(@pytest_ar._format_explanation(@py_format7))
@py_assert0 = @py_assert2 = @py_assert3 = None
========================== 1 passed in 0.01 seconds ===========================
(I have the same AST in Python 2.7 and Python 3.4)
(Also note that to reproduce this, you either have to use pytest~=2.8 or after use the branch from this PR).
It seems to me the rewritten expression is correct, not sure what could be the problem here.
Original comment by Loic Dachary (Bitbucket: dachary, GitHub: dachary)
For the record, updated the pytest issue with a theory.
Original comment by Loic Dachary (Bitbucket: dachary, GitHub: dachary)
Could it be that starting from python-3.5 sys.settrace claims that all event == line that belong to a multiline assert are from the first line of the assert ? If pytest rewrites assertions as if they were on a single line even if they are multiline, it would explain why it only breaks python versions before 3.5. It's just a theory at this point ;-)
Original comment by Loic Dachary (Bitbucket: dachary, GitHub: dachary)
Here is a minimal reproducer with test_foo.py as
#!python
def test_foo():
assert {i for i in range(10)} == {
i for i in range(10)
}
#!bash
coverage run --branch --source=test_foo.py -m pytest test_foo.py
using python-3.5 or python-2.7.12 both create a .coverage file containing
#!json
[[2,-1],[-1,1],[-1,2],[2,-2],[-2,2],[2,2],[1,-1]]
With --assert=plain the content of the .coverage file is different when using python-2.7.12 but remains the same with python-3.5
#!bash
coverage run --branch --source=test_foo.py -m pytest--assert=plain test_foo.py
#!json
[[-3,3],[-1,1],[-1,2],[3,3],[3,-3],[2,-2],[3,-1],[2,3],[-2,2],[2,2],[1,-1]]
Original comment by Loic Dachary (Bitbucket: dachary, GitHub: dachary)
Added a bug report to pytest in case the problem can be fixed on this end.
Original comment by Scott Colby (Bitbucket: scolby33, GitHub: scolby33)
I'd like to +1 this issue with what I think is a similar situation (Python 2.7.12 only, I haven't yet tried to reproduce with 3.5):
#!python
# in my module
data = {'a': {'a': 1, 'b': 2}, 'b': {'a': 1, 'b': None}}
if any([score is None for row in data.values() for score in row.values()]):
raise ValueError
# in a test function
with pytest.raises(ValueError):
function_that_was_in()
this reports a missed branch and "line 44 didn't run the list comprehension on line 44" or similar.
Replacing with a generator epxression:
#!python
if any(score is None for row in data.values() for score in row.values()):
raise ValueError
reports full coverage.
Messing with --assert=plain
didn't change anything, here. In this case, the comprehension/generator is in the code under test, not the test suite itself.
Original comment by Andy Freeland (Bitbucket: rouge8, GitHub: rouge8)
One thing that is strange to me is that branch count is not zero
The branches here are the possibility of exit before the generator is evaluated. In Python 2, set/dict comprehensions are implemented as generators I believe, so won't necessarily be evaluated, vs. list comprehensions which always are. If you look at the HTML coverage, the partial lines are annotated "line N didn't finish the set comprehension on line N"
Original comment by Artem Dayneko (Bitbucket: jamert, GitHub: jamert)
Issue is reproducible under 2.7.12, 3.3.6, 3.4.5 (and all supported pypy's - 2.4, 2.6, 3-2.4). Under 3.5.2 missing coverage is not observed.
With assert rewriting results looks like:
Name Stmts Miss Branch BrPart Cover Missing
---------------------------------------------------------
test_foo.py 4 0 5 1 89% 6->exit
Without assert rewriting (--assert=plain
)it looks like
Name Stmts Miss Branch BrPart Cover Missing
---------------------------------------------------------
test_foo.py 4 0 5 0 100%
One thing that is strange to me is that branch count is not zero. test_foo.py
does not have branching and if we change set comprehension to list comprehension then report correctly shows 0 branches in both cases:
Name Stmts Miss Branch BrPart Cover Missing
---------------------------------------------------------
test_foo.py 4 0 0 0 100%
FYI: under python 3.5.2 where issue is not observed, branch count is 4, not 5 (all covered)
Original comment by Andy Freeland (Bitbucket: rouge8, GitHub: rouge8)
Included the output in the gist, but that test is fully covered if running pytest without assertion rewriting, tox -- --assert=plain
. The snippet above though suggests that the two expressions are equivalent after the rewrite, so not sure what's going on.
Interestingly, list comprehensions behave in the opposite way:
def test_set_comprehension():
# covered!
assert {i for i in range(10)} == {i for i in range(10)}
# "didn't finish the set comprehension"
assert {i for i in range(10)} == {
i for i in range(10)
}
# covered!
assert True
def test_list_comprehension():
assert [i for i in range(10)] == [i for i in range(10)]
# "didn't finish the list comprehension"
assert [i for i in range(10)] == [
i for i in range(10)
]
# covered!
assert True
In test_set_comprehension
, Python 2.7 will report the branch "didn't finish the set comprehension" was uncovered and Python 3.6/3.7 will report it is covered, but in test_list_comprehension
, Python 2.7 sees the branch as covered and 3.6/3.7 see it as uncovered. š©
This now shows up in even more cases with all()
in the latest pytest, likely due to https://github.com/pytest-dev/pytest/pull/5360
We are about to revert the unroll functionality for all()
as we have found a bunch of corner cases. We should release 4.6.2 later today.
Originally reported by Andy Freeland (Bitbucket: rouge8, GitHub: rouge8)
Code/config to reproduce available as a gist. Fails on Python 2.7 but not Python 3.5.
Essentially, given this test:
When run under pytest with assertion rewriting (the default), the multiline set comprehension is reported as partially covered, even though the comprehension on oneline is fully covered. I think this is a bug in coverage, not pytest's assertion rewriting, because this code passes: