Skip to content

Commit 03f0468

Browse files
authored
Merge pull request #72 from plasma-umass/fix-issue-69-annotations
Fix incorrect coverage for type annotations (Issue #69)
2 parents 672bbe0 + 54466ad commit 03f0468

File tree

1 file changed

+68
-3
lines changed

1 file changed

+68
-3
lines changed

src/slipcover/slipcover.py

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,55 @@ def findlinestarts(co: types.CodeType):
4141
else:
4242
findlinestarts = dis.findlinestarts
4343

44+
45+
# Opcodes used only for loading type annotations (for function parameter/return annotations)
46+
# Lines that ONLY contain these ops are annotation-only lines and should be excluded from coverage
47+
_ANNOTATION_ONLY_OPS = frozenset({'LOAD_NAME', 'LOAD_GLOBAL', 'LOAD_ATTR', 'BINARY_SUBSCR'})
48+
49+
50+
def _get_annotation_only_lines(co: types.CodeType) -> frozenset:
51+
"""Find lines that only contain annotation-loading bytecode.
52+
53+
In Python < 3.14, function annotations are evaluated eagerly and their bytecode
54+
appears in the module code. Lines that ONLY load types (e.g., continuation lines
55+
of multi-line function signatures) should be excluded from coverage since they're
56+
just metadata, not actual program logic.
57+
58+
In Python 3.14+, annotations are deferred (PEP 649), so this returns empty.
59+
"""
60+
if sys.version_info >= (3, 14):
61+
return frozenset()
62+
63+
# Collect opcodes per line
64+
ops_by_line: dict = {}
65+
current_line = None
66+
for instr in dis.get_instructions(co):
67+
# Python 3.11+ has instr.positions.lineno for every instruction
68+
# Python < 3.11 has instr.starts_line only for first instruction on each line
69+
if sys.version_info >= (3, 11):
70+
if instr.positions and instr.positions.lineno:
71+
line = instr.positions.lineno
72+
else:
73+
continue
74+
else:
75+
if instr.starts_line is not None:
76+
current_line = instr.starts_line
77+
line = current_line
78+
if line is None:
79+
continue
80+
81+
if line not in ops_by_line:
82+
ops_by_line[line] = set()
83+
ops_by_line[line].add(instr.opname)
84+
85+
# Find lines where ALL ops are annotation-only ops
86+
annotation_lines = set()
87+
for line, ops in ops_by_line.items():
88+
if ops and ops.issubset(_ANNOTATION_ONLY_OPS):
89+
annotation_lines.add(line)
90+
91+
return frozenset(annotation_lines)
92+
4493
if TYPE_CHECKING:
4594
from typing import Dict, Iterable, Iterator, List, Optional, Tuple
4695

@@ -317,15 +366,25 @@ def _get_newly_seen(self):
317366
def lines_from_code(co: types.CodeType) -> Iterator[int]:
318367
for c in co.co_consts:
319368
if isinstance(c, types.CodeType):
369+
# Skip __annotate__ functions (PEP 649, Python 3.14+) - they're only
370+
# called when annotations are explicitly accessed, not during normal execution
371+
if c.co_name == '__annotate__':
372+
continue
320373
yield from Slipcover.lines_from_code(c)
321374

322-
yield from (line for _, line in findlinestarts(co) if not br.is_branch(line))
375+
# Exclude annotation-only lines (Python < 3.14 evaluates annotations eagerly)
376+
annotation_only = _get_annotation_only_lines(co)
377+
yield from (line for _, line in findlinestarts(co)
378+
if not br.is_branch(line) and line not in annotation_only)
323379

324380

325381
@staticmethod
326382
def branches_from_code(co: types.CodeType) -> Iterator[Tuple[int, int]]:
327383
for c in co.co_consts:
328384
if isinstance(c, types.CodeType):
385+
# Skip __annotate__ functions (PEP 649, Python 3.14+)
386+
if c.co_name == '__annotate__':
387+
continue
329388
yield from Slipcover.branches_from_code(c)
330389

331390
yield from (br.decode_branch(line) for _, line in findlinestarts(co) if br.is_branch(line))
@@ -338,7 +397,9 @@ def lines_from_code(co: types.CodeType) -> Iterator[int]:
338397
yield from Slipcover.lines_from_code(c)
339398

340399
# Python 3.11 generates a 0th line; 3.11+ generates a line just for RESUME
341-
yield from (line for _, line in findlinestarts(co))
400+
# Exclude annotation-only lines (Python < 3.14 evaluates annotations eagerly)
401+
annotation_only = _get_annotation_only_lines(co)
402+
yield from (line for _, line in findlinestarts(co) if line not in annotation_only)
342403

343404

344405
@staticmethod
@@ -370,6 +431,9 @@ def instrument(self, co: types.CodeType, parent: Optional[types.CodeType] = None
370431
# handle functions-within-functions
371432
for c in co.co_consts:
372433
if isinstance(c, types.CodeType):
434+
# Skip __annotate__ functions (PEP 649, Python 3.14+)
435+
if c.co_name == '__annotate__':
436+
continue
373437
self.instrument(c, co)
374438

375439
if not parent:
@@ -591,7 +655,8 @@ def get_coverage(self):
591655
for f, f_code_lines in self.code_lines.items():
592656
if f in self.all_seen:
593657
branches_seen = {x for x in self.all_seen[f] if isinstance(x, tuple)}
594-
lines_seen = self.all_seen[f] - branches_seen
658+
# Only count lines that are in code_lines (excludes annotation-only lines)
659+
lines_seen = (self.all_seen[f] - branches_seen) & f_code_lines
595660
else:
596661
lines_seen = branches_seen = set()
597662

0 commit comments

Comments
 (0)