2929 )
3030
3131
32- def make_frame (filename , lineno , funcname ):
33- """Create a FrameInfo struct sequence."""
34- location = LocationInfo ((lineno , lineno , - 1 , - 1 ))
35- return FrameInfo ((filename , location , funcname , None ))
32+ def make_frame (filename , lineno , funcname , end_lineno = None , column = None ,
33+ end_column = None , opcode = None ):
34+ """Create a FrameInfo struct sequence with full location info and opcode."""
35+ if end_lineno is None :
36+ end_lineno = lineno
37+ if column is None :
38+ column = 0
39+ if end_column is None :
40+ end_column = 0
41+ location = LocationInfo ((lineno , end_lineno , column , end_column ))
42+ return FrameInfo ((filename , location , funcname , opcode ))
3643
3744
3845def make_thread (thread_id , frames , status = 0 ):
@@ -54,6 +61,36 @@ def extract_lineno(location):
5461 return location
5562
5663
64+ def extract_location (location ):
65+ """Extract full location info as dict from location tuple or None."""
66+ if location is None :
67+ return {"lineno" : 0 , "end_lineno" : 0 , "column" : 0 , "end_column" : 0 }
68+ if isinstance (location , tuple ) and len (location ) >= 4 :
69+ return {
70+ "lineno" : location [0 ] if location [0 ] is not None else 0 ,
71+ "end_lineno" : location [1 ] if location [1 ] is not None else 0 ,
72+ "column" : location [2 ] if location [2 ] is not None else 0 ,
73+ "end_column" : location [3 ] if location [3 ] is not None else 0 ,
74+ }
75+ # Fallback for old-style location
76+ lineno = location [0 ] if isinstance (location , tuple ) else location
77+ return {"lineno" : lineno or 0 , "end_lineno" : lineno or 0 , "column" : 0 , "end_column" : 0 }
78+
79+
80+ def frame_to_dict (frame ):
81+ """Convert a FrameInfo to a dict."""
82+ loc = extract_location (frame .location )
83+ return {
84+ "filename" : frame .filename ,
85+ "funcname" : frame .funcname ,
86+ "lineno" : loc ["lineno" ],
87+ "end_lineno" : loc ["end_lineno" ],
88+ "column" : loc ["column" ],
89+ "end_column" : loc ["end_column" ],
90+ "opcode" : frame .opcode ,
91+ }
92+
93+
5794class RawCollector :
5895 """Collector that captures all raw data grouped by thread."""
5996
@@ -68,15 +105,7 @@ def collect(self, stack_frames, timestamps_us):
68105 count = len (timestamps_us )
69106 for interp in stack_frames :
70107 for thread in interp .threads :
71- frames = []
72- for frame in thread .frame_info :
73- frames .append (
74- {
75- "filename" : frame .filename ,
76- "funcname" : frame .funcname ,
77- "lineno" : extract_lineno (frame .location ),
78- }
79- )
108+ frames = [frame_to_dict (f ) for f in thread .frame_info ]
80109 key = (interp .interpreter_id , thread .thread_id )
81110 sample = {"status" : thread .status , "frames" : frames }
82111 for _ in range (count ):
@@ -93,15 +122,7 @@ def samples_to_by_thread(samples):
93122 for sample in samples :
94123 for interp in sample :
95124 for thread in interp .threads :
96- frames = []
97- for frame in thread .frame_info :
98- frames .append (
99- {
100- "filename" : frame .filename ,
101- "funcname" : frame .funcname ,
102- "lineno" : extract_lineno (frame .location ),
103- }
104- )
125+ frames = [frame_to_dict (f ) for f in thread .frame_info ]
105126 key = (interp .interpreter_id , thread .thread_id )
106127 by_thread [key ].append (
107128 {
@@ -187,25 +208,15 @@ def assert_samples_equal(self, expected_samples, collector):
187208 for j , (exp_frame , act_frame ) in enumerate (
188209 zip (exp ["frames" ], act ["frames" ])
189210 ):
190- self .assertEqual (
191- exp_frame ["filename" ],
192- act_frame ["filename" ],
193- f"Thread ({ interp_id } , { thread_id } ), sample { i } , "
194- f"frame { j } : filename mismatch" ,
195- )
196- self .assertEqual (
197- exp_frame ["funcname" ],
198- act_frame ["funcname" ],
199- f"Thread ({ interp_id } , { thread_id } ), sample { i } , "
200- f"frame { j } : funcname mismatch" ,
201- )
202- self .assertEqual (
203- exp_frame ["lineno" ],
204- act_frame ["lineno" ],
205- f"Thread ({ interp_id } , { thread_id } ), sample { i } , "
206- f"frame { j } : lineno mismatch "
207- f"(expected { exp_frame ['lineno' ]} , got { act_frame ['lineno' ]} )" ,
208- )
211+ for field in ("filename" , "funcname" , "lineno" , "end_lineno" ,
212+ "column" , "end_column" , "opcode" ):
213+ self .assertEqual (
214+ exp_frame [field ],
215+ act_frame [field ],
216+ f"Thread ({ interp_id } , { thread_id } ), sample { i } , "
217+ f"frame { j } : { field } mismatch "
218+ f"(expected { exp_frame [field ]!r} , got { act_frame [field ]!r} )" ,
219+ )
209220
210221
211222class TestBinaryRoundTrip (BinaryFormatTestBase ):
@@ -484,6 +495,97 @@ def test_threads_interleaved_samples(self):
484495 self .assertEqual (count , 60 )
485496 self .assert_samples_equal (samples , collector )
486497
498+ def test_full_location_roundtrip (self ):
499+ """Full source location (end_lineno, column, end_column) roundtrips."""
500+ frames = [
501+ make_frame ("test.py" , 10 , "func1" , end_lineno = 12 , column = 4 , end_column = 20 ),
502+ make_frame ("test.py" , 20 , "func2" , end_lineno = 20 , column = 8 , end_column = 45 ),
503+ make_frame ("test.py" , 30 , "func3" , end_lineno = 35 , column = 0 , end_column = 100 ),
504+ ]
505+ samples = [[make_interpreter (0 , [make_thread (1 , frames )])]]
506+ collector , count = self .roundtrip (samples )
507+ self .assertEqual (count , 1 )
508+ self .assert_samples_equal (samples , collector )
509+
510+ def test_opcode_roundtrip (self ):
511+ """Opcode values roundtrip exactly."""
512+ opcodes = [0 , 1 , 50 , 100 , 150 , 200 , 254 ] # Valid Python opcodes
513+ samples = []
514+ for opcode in opcodes :
515+ frame = make_frame ("test.py" , 10 , "func" , opcode = opcode )
516+ samples .append ([make_interpreter (0 , [make_thread (1 , [frame ])])])
517+ collector , count = self .roundtrip (samples )
518+ self .assertEqual (count , len (opcodes ))
519+ self .assert_samples_equal (samples , collector )
520+
521+ def test_opcode_none_roundtrip (self ):
522+ """Opcode=None (sentinel 255) roundtrips as None."""
523+ frame = make_frame ("test.py" , 10 , "func" , opcode = None )
524+ samples = [[make_interpreter (0 , [make_thread (1 , [frame ])])]]
525+ collector , count = self .roundtrip (samples )
526+ self .assertEqual (count , 1 )
527+ self .assert_samples_equal (samples , collector )
528+
529+ def test_mixed_location_and_opcode (self ):
530+ """Mixed full location and opcode data roundtrips."""
531+ frames = [
532+ make_frame ("a.py" , 10 , "a" , end_lineno = 15 , column = 4 , end_column = 30 , opcode = 100 ),
533+ make_frame ("b.py" , 20 , "b" , end_lineno = 20 , column = 0 , end_column = 50 , opcode = None ),
534+ make_frame ("c.py" , 30 , "c" , end_lineno = 32 , column = 8 , end_column = 25 , opcode = 50 ),
535+ ]
536+ samples = [[make_interpreter (0 , [make_thread (1 , frames )])]]
537+ collector , count = self .roundtrip (samples )
538+ self .assertEqual (count , 1 )
539+ self .assert_samples_equal (samples , collector )
540+
541+ def test_delta_encoding_multiline (self ):
542+ """Multi-line spans (large end_lineno delta) roundtrip correctly."""
543+ # This tests the delta encoding: end_lineno = lineno + delta
544+ frames = [
545+ make_frame ("test.py" , 1 , "small" , end_lineno = 1 , column = 0 , end_column = 10 ),
546+ make_frame ("test.py" , 100 , "medium" , end_lineno = 110 , column = 0 , end_column = 50 ),
547+ make_frame ("test.py" , 1000 , "large" , end_lineno = 1500 , column = 0 , end_column = 200 ),
548+ ]
549+ samples = [[make_interpreter (0 , [make_thread (1 , frames )])]]
550+ collector , count = self .roundtrip (samples )
551+ self .assertEqual (count , 1 )
552+ self .assert_samples_equal (samples , collector )
553+
554+ def test_column_positions_preserved (self ):
555+ """Various column positions are preserved exactly."""
556+ columns = [(0 , 10 ), (4 , 50 ), (8 , 100 ), (100 , 200 )]
557+ samples = []
558+ for col , end_col in columns :
559+ frame = make_frame ("test.py" , 10 , "func" , column = col , end_column = end_col )
560+ samples .append ([make_interpreter (0 , [make_thread (1 , [frame ])])])
561+ collector , count = self .roundtrip (samples )
562+ self .assertEqual (count , len (columns ))
563+ self .assert_samples_equal (samples , collector )
564+
565+ def test_same_line_different_opcodes (self ):
566+ """Same line with different opcodes creates distinct frames."""
567+ # This tests that opcode is part of the frame key
568+ frames = [
569+ make_frame ("test.py" , 10 , "func" , opcode = 100 ),
570+ make_frame ("test.py" , 10 , "func" , opcode = 101 ),
571+ make_frame ("test.py" , 10 , "func" , opcode = 102 ),
572+ ]
573+ samples = [[make_interpreter (0 , [make_thread (1 , [f ])]) for f in frames ]]
574+ collector , count = self .roundtrip (samples )
575+ # Verify all three opcodes are preserved distinctly
576+ self .assertEqual (count , 3 )
577+
578+ def test_same_line_different_columns (self ):
579+ """Same line with different columns creates distinct frames."""
580+ frames = [
581+ make_frame ("test.py" , 10 , "func" , column = 0 , end_column = 10 ),
582+ make_frame ("test.py" , 10 , "func" , column = 15 , end_column = 25 ),
583+ make_frame ("test.py" , 10 , "func" , column = 30 , end_column = 40 ),
584+ ]
585+ samples = [[make_interpreter (0 , [make_thread (1 , [f ])]) for f in frames ]]
586+ collector , count = self .roundtrip (samples )
587+ self .assertEqual (count , 3 )
588+
487589
488590class TestBinaryEdgeCases (BinaryFormatTestBase ):
489591 """Tests for edge cases in binary format."""
0 commit comments