@@ -41,6 +41,55 @@ def findlinestarts(co: types.CodeType):
4141else :
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+
4493if 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