Skip to content

Commit bb80611

Browse files
emerybergerclaude
andcommitted
Fix incorrect coverage for type annotations (Issue #69)
Python 3.14 introduced PEP 649 (deferred annotation evaluation), which creates __annotate__ code objects that are never executed during normal program flow. This caused slipcover to incorrectly report annotation lines as missed. Additionally, in Python < 3.14 where annotations are evaluated eagerly, multi-line function signatures had their annotation continuation lines (e.g., "b: int,") counted as executable code, leading to inconsistent line counts between Python versions. This fix: - Skips __annotate__ code objects in Python 3.14+ - Detects and excludes annotation-only lines in Python < 3.14 by identifying lines that only contain type-loading bytecode - Filters executed lines to only count lines in code_lines Fixes #69 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 672bbe0 commit bb80611

1 file changed

Lines changed: 55 additions & 3 deletions

File tree

src/slipcover/slipcover.py

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,42 @@ 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+
for instr in dis.get_instructions(co):
66+
if instr.positions and instr.positions.lineno:
67+
line = instr.positions.lineno
68+
if line not in ops_by_line:
69+
ops_by_line[line] = set()
70+
ops_by_line[line].add(instr.opname)
71+
72+
# Find lines where ALL ops are annotation-only ops
73+
annotation_lines = set()
74+
for line, ops in ops_by_line.items():
75+
if ops and ops.issubset(_ANNOTATION_ONLY_OPS):
76+
annotation_lines.add(line)
77+
78+
return frozenset(annotation_lines)
79+
4480
if TYPE_CHECKING:
4581
from typing import Dict, Iterable, Iterator, List, Optional, Tuple
4682

@@ -317,15 +353,25 @@ def _get_newly_seen(self):
317353
def lines_from_code(co: types.CodeType) -> Iterator[int]:
318354
for c in co.co_consts:
319355
if isinstance(c, types.CodeType):
356+
# Skip __annotate__ functions (PEP 649, Python 3.14+) - they're only
357+
# called when annotations are explicitly accessed, not during normal execution
358+
if c.co_name == '__annotate__':
359+
continue
320360
yield from Slipcover.lines_from_code(c)
321361

322-
yield from (line for _, line in findlinestarts(co) if not br.is_branch(line))
362+
# Exclude annotation-only lines (Python < 3.14 evaluates annotations eagerly)
363+
annotation_only = _get_annotation_only_lines(co)
364+
yield from (line for _, line in findlinestarts(co)
365+
if not br.is_branch(line) and line not in annotation_only)
323366

324367

325368
@staticmethod
326369
def branches_from_code(co: types.CodeType) -> Iterator[Tuple[int, int]]:
327370
for c in co.co_consts:
328371
if isinstance(c, types.CodeType):
372+
# Skip __annotate__ functions (PEP 649, Python 3.14+)
373+
if c.co_name == '__annotate__':
374+
continue
329375
yield from Slipcover.branches_from_code(c)
330376

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

340386
# Python 3.11 generates a 0th line; 3.11+ generates a line just for RESUME
341-
yield from (line for _, line in findlinestarts(co))
387+
# Exclude annotation-only lines (Python < 3.14 evaluates annotations eagerly)
388+
annotation_only = _get_annotation_only_lines(co)
389+
yield from (line for _, line in findlinestarts(co) if line not in annotation_only)
342390

343391

344392
@staticmethod
@@ -370,6 +418,9 @@ def instrument(self, co: types.CodeType, parent: Optional[types.CodeType] = None
370418
# handle functions-within-functions
371419
for c in co.co_consts:
372420
if isinstance(c, types.CodeType):
421+
# Skip __annotate__ functions (PEP 649, Python 3.14+)
422+
if c.co_name == '__annotate__':
423+
continue
373424
self.instrument(c, co)
374425

375426
if not parent:
@@ -591,7 +642,8 @@ def get_coverage(self):
591642
for f, f_code_lines in self.code_lines.items():
592643
if f in self.all_seen:
593644
branches_seen = {x for x in self.all_seen[f] if isinstance(x, tuple)}
594-
lines_seen = self.all_seen[f] - branches_seen
645+
# Only count lines that are in code_lines (excludes annotation-only lines)
646+
lines_seen = (self.all_seen[f] - branches_seen) & f_code_lines
595647
else:
596648
lines_seen = branches_seen = set()
597649

0 commit comments

Comments
 (0)