nedbat / coveragepy

The code coverage tool for Python
https://coverage.readthedocs.io
Apache License 2.0
3.01k stars 432 forks source link

Exclusion of body doesn't propagate to `case` block #1563

Open mthuurne opened 1 year ago

mthuurne commented 1 year ago

Describe the bug

If the body of a case block inside a match statement is excluded from coverage, the case line is still marked as not covered.

To Reproduce

Code under test - testcase.py:

if 1 != 1:
    assert False

match 1:
    case 1:
        print("all is well")
    case _:  # reported as uncovered
        assert False

Configuration - .coveragerc:

[report]
exclude_lines =
    assert False$

Commands:

$ coverage run --source=. -m testcase
all is well
$ coverage report -m
Name          Stmts   Miss  Cover   Missing
-------------------------------------------
testcase.py       5      1    80%   7
-------------------------------------------
TOTAL             5      1    80%

I'm using coverage version 7.2.0 with Python 3.10 on Ubuntu Linux 22.04.

Expected behavior

I would expect the exclusion to be propagated to the surrounding block for case blocks similarly to how it does for if blocks.

Note that when enabling branch coverage measurement, the case 1: line is reported as partially covered, but I expect that is a direct consequence of line 8 being considered uncovered, so not a separate issue.

kevin-brown commented 1 year ago

Picking this up for the PyCon 2023 sprints.

kevin-brown commented 1 year ago

I need input from @nedbat on if this is actually a bug. Until then, I'm going to articulate the cause of the current behaviour and comparable behaviour that exists elsewhere.


The current bug is that the body of the case statement is excluded from coverage, therefore it would make sense for the case itself to be excluded from coverage. This is the case of if ... else statements, as stated in the original issue, and as such it would make sense to be consistent.

The current bug is caused by an arc between the current case statement and the previous case statement, with the expectation that the current case statement would always be executed as long as the previous case statement is not catching all of the cases. This is similar to the behaviour of if ... else where the else contains a nested if ... else where the statement within that is excluded. Here's a code example of that with the same exclusion (assert False$) used in the original issue:

if True:
    print("a")
else:
    if False:
        assert False

This example would result in the else being flagged as missing coverage even though it contains a block which contains a block which fails. I believe this behaviour is expected, but it shows a similar dependency in logic to that which occurs within match ... case statements.

The reason why the if statement within your original example does not fail is because the if statement is covered, but the branch beneath it is not. For the corresponding case _ though, the case itself is not covered because it is never executed because the match always detects the first case 1 as being the expected path.


Based on the explanation given for how this works, I believe this is in fact not a bug and is expected behaviour for keeping track of code coverage. Coverage checks do not bubble up to the callers for if statements, therefore they should not bubble up for case statements.

mscheifer commented 6 months ago

Would it make sense to special case case _ to behave like else here? I think this propagation actually only happens for else blocks and not if or elif anyway.

[tool.coverage.report]
exclude_also = [
    "assert False",
]

testcase.py

foo = 1

if foo == 1:
    print("foo")
elif foo == 2:
    assert False
else:
    assert False

match foo:
    case 1:
        print("foo")
    case 2:
        assert False
    case _:
        assert False

Commands:

$ poetry run coverage run -m testcase
foo
foo
$ poetry run coverage report -m --include testcase.py 
Name          Stmts   Miss  Cover   Missing
-------------------------------------------
testcase.py       9      3    67%   5, 13-15
-------------------------------------------
TOTAL             9      3    67%

Line 5, elif foo == 2:, is marked as not covered which makes sense as the expression was never evaluated. Similarly line 13 case 2 is marked as not covered.

The difference is line 7, else: being considered covered while the equivalent line 15 case _: is not.


Python 3.10 coverage 7.4.4

mscheifer commented 2 months ago

You can work around this with the new multi-line patterns in 7.6.0:

[tool.coverage.report]
exclude_also = [
    "assert False", # For else blocks and functions
    "case _:\\n\\s*assert False", # For wildcard cases
]