44from __future__ import annotations
55
66import ast
7+ import io
78import math
89import os
910import signal
11+ import tokenize
1012from contextlib import contextmanager
1113from hashlib import sha1 as _sha1
1214from typing import TYPE_CHECKING , Literal
@@ -152,6 +154,98 @@ def _stmt_count(node: ast.AST) -> int:
152154 return len (body ) if isinstance (body , list ) else 0
153155
154156
157+ def _source_tokens (source : str ) -> tuple [tokenize .TokenInfo , ...]:
158+ try :
159+ return tuple (tokenize .generate_tokens (io .StringIO (source ).readline ))
160+ except tokenize .TokenError :
161+ return ()
162+
163+
164+ def _declaration_token_name (node : ast .AST ) -> str :
165+ if isinstance (node , ast .ClassDef ):
166+ return "class"
167+ if isinstance (node , ast .AsyncFunctionDef ):
168+ return "async"
169+ return "def"
170+
171+
172+ def _declaration_token_index (
173+ * ,
174+ source_tokens : tuple [tokenize .TokenInfo , ...],
175+ start_line : int ,
176+ start_col : int ,
177+ declaration_token : str ,
178+ ) -> int | None :
179+ for idx , token in enumerate (source_tokens ):
180+ if token .start != (start_line , start_col ):
181+ continue
182+ if token .type == tokenize .NAME and token .string == declaration_token :
183+ return idx
184+ return None
185+
186+
187+ def _scan_declaration_colon_line (
188+ * ,
189+ source_tokens : tuple [tokenize .TokenInfo , ...],
190+ start_index : int ,
191+ ) -> int | None :
192+ nesting = 0
193+ for token in source_tokens [start_index + 1 :]:
194+ if token .type == tokenize .OP :
195+ if token .string in "([{" :
196+ nesting += 1
197+ continue
198+ if token .string in ")]}" :
199+ if nesting > 0 :
200+ nesting -= 1
201+ continue
202+ if token .string == ":" and nesting == 0 :
203+ return token .start [0 ]
204+ if token .type == tokenize .NEWLINE and nesting == 0 :
205+ return None
206+ return None
207+
208+
209+ def _fallback_declaration_end_line (node : ast .AST , * , start_line : int ) -> int :
210+ body = getattr (node , "body" , None )
211+ if not isinstance (body , list ) or not body :
212+ return start_line
213+
214+ first_body_line = int (getattr (body [0 ], "lineno" , 0 ))
215+ if first_body_line <= 0 or first_body_line == start_line :
216+ return start_line
217+ return max (start_line , first_body_line - 1 )
218+
219+
220+ def _declaration_end_line (
221+ node : ast .AST ,
222+ * ,
223+ source_tokens : tuple [tokenize .TokenInfo , ...],
224+ ) -> int :
225+ start_line = int (getattr (node , "lineno" , 0 ))
226+ start_col = int (getattr (node , "col_offset" , 0 ))
227+ if start_line <= 0 :
228+ return 0
229+
230+ declaration_token = _declaration_token_name (node )
231+ start_index = _declaration_token_index (
232+ source_tokens = source_tokens ,
233+ start_line = start_line ,
234+ start_col = start_col ,
235+ declaration_token = declaration_token ,
236+ )
237+ if start_index is None :
238+ return _fallback_declaration_end_line (node , start_line = start_line )
239+
240+ colon_line = _scan_declaration_colon_line (
241+ source_tokens = source_tokens ,
242+ start_index = start_index ,
243+ )
244+ if colon_line is not None :
245+ return colon_line
246+ return _fallback_declaration_end_line (node , start_line = start_line )
247+
248+
155249class _QualnameCollector (ast .NodeVisitor ):
156250 __slots__ = (
157251 "class_count" ,
@@ -577,6 +671,7 @@ def _collect_declaration_targets(
577671 filepath : str ,
578672 module_name : str ,
579673 collector : _QualnameCollector ,
674+ source_tokens : tuple [tokenize .TokenInfo , ...],
580675) -> tuple [DeclarationTarget , ...]:
581676 declarations : list [DeclarationTarget ] = []
582677
@@ -585,6 +680,10 @@ def _collect_declaration_targets(
585680 end = int (getattr (node , "end_lineno" , 0 ))
586681 if start <= 0 or end <= 0 :
587682 continue
683+ declaration_end_line = _declaration_end_line (
684+ node ,
685+ source_tokens = source_tokens ,
686+ )
588687 kind : Literal ["function" , "method" ] = (
589688 "method" if "." in local_name else "function"
590689 )
@@ -595,6 +694,7 @@ def _collect_declaration_targets(
595694 start_line = start ,
596695 end_line = end ,
597696 kind = kind ,
697+ declaration_end_line = declaration_end_line ,
598698 )
599699 )
600700
@@ -603,13 +703,18 @@ def _collect_declaration_targets(
603703 end = int (getattr (class_node , "end_lineno" , 0 ))
604704 if start <= 0 or end <= 0 :
605705 continue
706+ declaration_end_line = _declaration_end_line (
707+ class_node ,
708+ source_tokens = source_tokens ,
709+ )
606710 declarations .append (
607711 DeclarationTarget (
608712 filepath = filepath ,
609713 qualname = f"{ module_name } :{ class_qualname } " ,
610714 start_line = start ,
611715 end_line = end ,
612716 kind = "class" ,
717+ declaration_end_line = declaration_end_line ,
613718 )
614719 )
615720
@@ -657,6 +762,7 @@ def extract_units_and_stats_from_source(
657762 collector = _QualnameCollector ()
658763 collector .visit (tree )
659764 source_lines = source .splitlines ()
765+ source_tokens = _source_tokens (source )
660766 source_line_count = len (source_lines )
661767
662768 is_test_file = is_test_filepath (filepath )
@@ -676,6 +782,7 @@ def extract_units_and_stats_from_source(
676782 filepath = filepath ,
677783 module_name = module_name ,
678784 collector = collector ,
785+ source_tokens = source_tokens ,
679786 )
680787 suppression_bindings = bind_suppressions_to_declarations (
681788 directives = suppression_directives ,
0 commit comments