22from __future__ import annotations
33
44import argparse
5- import json
65import logging
76import os
87import re
98import shutil
109import sys
1110import urllib .parse
12- from typing import Any , NamedTuple , Pattern , TextIO , TypeAlias
11+ from pathlib import Path
12+ from typing import TYPE_CHECKING , Pattern , TextIO , TypeAlias
1313from xml .etree import ElementTree as ET
1414
1515from ..helpers import setup_logger
1616from .junit_utils import add_junit_link_property , is_faulty_testcase
1717from .mute_utils import mute_target , pattern_to_re
1818
19+ if TYPE_CHECKING :
20+ from ..tracing .yatrace import Chunk , YaTraceCollection
21+
1922LOGGER = logging .getLogger (__name__ )
2023
2124# TODO: Replace with a named mute rule type in a follow-up cleanup.
2225CompiledPatternPair : TypeAlias = tuple [Pattern [str ], Pattern [str ]]
23- # TODO: Replace these broad trace aliases with typed structures in a follow-up
24- # cleanup.
25- TraceEvent : TypeAlias = dict [str , Any ]
2626LogMap : TypeAlias = dict [str , str ]
2727
2828
29- class SubtestTraceKey (NamedTuple ):
30- test_class : str
31- subtest : str
32-
33-
34- class ChunkTraceKey (NamedTuple ):
35- suite : str
36- chunk_index : int
37- chunks_total : int
38-
39-
4029def _escape_github_command (value : str ) -> str :
4130 return value .replace ("%" , "%25" ).replace ("\r " , "%0D" ).replace ("\n " , "%0A" )
4231
@@ -132,156 +121,6 @@ def __call__(self, suite_name: str, test_name: str, test_case: ET.Element) -> bo
132121 return set (matched_test_cases ) == set (failed_by_current_chunk_cases )
133122
134123
135- class YTestReportTrace :
136- def __init__ (self , out_root : str ) -> None :
137- self .out_root = out_root
138- self .traces : dict [SubtestTraceKey , TraceEvent ] = {}
139- self .chunk_traces : dict [ChunkTraceKey , list [TraceEvent ]] = {}
140-
141- def load (self , subdir : str ) -> None :
142- test_results_dir = os .path .join (self .out_root , f"{ subdir } /test-results/" )
143-
144- if not os .path .isdir (test_results_dir ):
145- LOGGER .info ("Directory %s doesn't exist" , test_results_dir )
146- return
147-
148- for folder in os .listdir (test_results_dir ):
149- fn = os .path .join (
150- self .out_root ,
151- test_results_dir ,
152- folder ,
153- "ytest.report.trace" ,
154- )
155-
156- if not os .path .isfile (fn ):
157- continue
158-
159- with open (fn , "r" ) as fp :
160- for line in fp :
161- event = json .loads (line .strip ())
162- if event ["name" ] == "subtest-finished" :
163- event = event ["value" ]
164- class_event = event ["class" ].replace ("::" , "." )
165- subtest = event ["subtest" ]
166- LOGGER .info ("loaded (%s, %s)" , class_event , subtest )
167- self .traces [SubtestTraceKey (class_event , subtest )] = event
168- elif event ["name" ] == "chunk-event" :
169- event = event ["value" ]
170- event ["test_results_folder" ] = folder
171- chunk_idx = event ["chunk_index" ]
172- chunk_total = event ["nchunks" ]
173- LOGGER .info (
174- "loaded (%s, %s, %s)" , subdir , chunk_idx , chunk_total
175- )
176- self .chunk_traces .setdefault (
177- ChunkTraceKey (subdir , chunk_idx , chunk_total ), []
178- ).append (event )
179-
180- def get_logs (self , class_event : str , name : str ) -> LogMap :
181- trace = self .traces .get (SubtestTraceKey (class_event , name ))
182- if not trace :
183- return {}
184-
185- logs = trace ["logs" ]
186- result : LogMap = {}
187- for key , path in logs .items ():
188- if key == "logsdir" :
189- continue
190- result [key ] = path .replace ("$(BUILD_ROOT)" , self .out_root )
191-
192- return result
193-
194- def select_chunk_trace (
195- self ,
196- suite : str ,
197- idx : int ,
198- total : int ,
199- failure_text : str | None = None ,
200- warn_on_ambiguity : bool = True ,
201- ) -> TraceEvent | None :
202- traces = self .chunk_traces .get (ChunkTraceKey (suite , idx , total ), [])
203- if not traces :
204- return None
205- if len (traces ) == 1 :
206- return traces [0 ]
207- if not failure_text :
208- if warn_on_ambiguity :
209- emit_transform_warning (
210- "Unable to disambiguate chunk logs for "
211- f"{ suite } [{ idx } /{ total } ]: failure text is empty; "
212- "leaving log links empty"
213- )
214- return None
215-
216- for trace in traces :
217- folder = trace .get ("test_results_folder" )
218- if folder and f"test-results/{ folder } " in failure_text :
219- return trace
220-
221- for trace in traces :
222- logs = trace .get ("logs" , {})
223- for path in logs .values ():
224- if path .replace ("$(BUILD_ROOT)" , self .out_root ) in failure_text :
225- return trace
226-
227- if warn_on_ambiguity :
228- emit_transform_warning (
229- "Unable to disambiguate chunk logs for "
230- f"{ suite } [{ idx } /{ total } ]; leaving log links empty"
231- )
232- return None
233-
234- def get_logs_chunks (
235- self , suite : str , idx : int , total : int , failure_text : str | None = None
236- ) -> LogMap :
237- trace = self .select_chunk_trace (suite , idx , total , failure_text )
238- if not trace :
239- return {}
240-
241- logs = trace ["logs" ]
242- result : LogMap = {}
243- for key , path in logs .items ():
244- if key == "logsdir" :
245- continue
246- result [key ] = path .replace ("$(BUILD_ROOT)" , self .out_root )
247-
248- return result
249-
250- def get_log_dir (self , class_event : str , name : str ) -> str | None :
251- logs_dir = (
252- self .traces .get (SubtestTraceKey (class_event , name ), {})
253- .get ("logs" , {})
254- .get ("logsdir" )
255- )
256-
257- if logs_dir is None :
258- return None
259-
260- return logs_dir .replace ("$(BUILD_ROOT)" , "" ).lstrip ("/" )
261-
262- def get_log_dir_chunk (
263- self ,
264- suite : str ,
265- idx : int ,
266- total : int ,
267- failure_text : str | None = None ,
268- warn_on_ambiguity : bool = True ,
269- ) -> str | None :
270- trace = self .select_chunk_trace (
271- suite ,
272- idx ,
273- total ,
274- failure_text ,
275- warn_on_ambiguity = warn_on_ambiguity ,
276- )
277- logs_dir = trace .get ("logs" , {}).get ("logsdir" ) if trace else None
278-
279- if logs_dir is None :
280- return None
281-
282- return logs_dir .replace ("$(BUILD_ROOT)" , "" ).lstrip ("/" )
283-
284-
285124def filter_empty_logs (logs : LogMap ) -> LogMap :
286125 result : LogMap = {}
287126 for key , value in logs .items ():
@@ -292,6 +131,42 @@ def filter_empty_logs(logs: LogMap) -> LogMap:
292131 return result
293132
294133
134+ def _select_chunk_event (
135+ traces : YaTraceCollection ,
136+ suite : str ,
137+ chunk_index : int ,
138+ chunks_total : int ,
139+ failure_text : str | None ,
140+ ) -> Chunk | None :
141+ event = traces .select_chunk_event (
142+ suite ,
143+ chunk_index ,
144+ chunks_total ,
145+ failure_text ,
146+ )
147+ candidates = traces .chunk_event_candidates (
148+ suite ,
149+ chunk_index ,
150+ chunks_total ,
151+ )
152+ if event is not None or len (candidates ) <= 1 :
153+ return event
154+
155+ if failure_text :
156+ message = (
157+ f"Unable to disambiguate chunk logs for "
158+ f"{ suite } [{ chunk_index } /{ chunks_total } ]; leaving log links empty"
159+ )
160+ else :
161+ message = (
162+ f"Unable to disambiguate chunk logs for "
163+ f"{ suite } [{ chunk_index } /{ chunks_total } ]: failure text is empty; "
164+ "leaving log links empty"
165+ )
166+ emit_transform_warning (message )
167+ return None
168+
169+
295170def save_log (
296171 build_root : str ,
297172 fn : str ,
@@ -327,6 +202,21 @@ def save_log(
327202 return f"{ log_url_prefix } { quoted_fpath } "
328203
329204
205+ def _load_ya_traces (ya_out : Path ) -> YaTraceCollection | None :
206+ try :
207+ # Trace enrichment is optional: keep JUnit processing usable even when
208+ # tracing dependencies could not be installed on the runner.
209+ from ..tracing .yatrace import YaTraceCollection
210+
211+ return YaTraceCollection .load (ya_out )
212+ except Exception as error :
213+ emit_transform_warning (
214+ f"Ya trace enrichment is unavailable: { error } ; "
215+ "continuing without trace-derived log links"
216+ )
217+ return None
218+
219+
330220def transform (
331221 fp : TextIO ,
332222 ya_mute_check : YaMuteCheck ,
@@ -340,13 +230,13 @@ def transform(
340230) -> None :
341231 tree = ET .parse (fp )
342232 root = tree .getroot ()
233+ ya_out = Path (ya_out_dir )
234+ traces = _load_ya_traces (ya_out )
343235
344236 for suite in root .findall ("testsuite" ):
345237 suite_name = suite .get ("name" )
346238 if suite_name is None :
347239 continue
348- traces = YTestReportTrace (ya_out_dir )
349- traces .load (suite_name )
350240
351241 for case in suite .findall ("testcase" ):
352242 test_name = case .get ("name" )
@@ -364,13 +254,18 @@ def transform(
364254
365255 if not is_fail :
366256 continue
257+ if traces is None :
258+ continue
367259
368260 if "." in test_name :
369261 test_name = test_name .replace ("kubernetes.io" , "kubernetes::io" )
370262 test_cls , test_method = test_name .rsplit ("." , maxsplit = 1 )
371263 test_method = test_method .replace ("kubernetes::io" , "kubernetes.io" )
372- logs = filter_empty_logs (traces .get_logs (test_cls , test_method ))
373- logs_directory = traces .get_log_dir (test_cls , test_method )
264+ trace_event = traces .finished_test (
265+ suite_name ,
266+ test_cls ,
267+ test_method ,
268+ )
374269 elif "chunk" in test_name :
375270 failure = case .find ("failure" )
376271 if failure is None :
@@ -389,21 +284,22 @@ def transform(
389284 continue
390285 chunk_idx = int (match .group (1 ))
391286 chunks_total = int (match .group (2 ))
392- logs = filter_empty_logs (
393- traces .get_logs_chunks (
394- suite_name , chunk_idx , chunks_total , failure_text
395- )
396- )
397- logs_directory = traces .get_log_dir_chunk (
287+ trace_event = _select_chunk_event (
288+ traces ,
398289 suite_name ,
399290 chunk_idx ,
400291 chunks_total ,
401292 failure_text ,
402- warn_on_ambiguity = False ,
403293 )
404294 else :
405295 continue
406296
297+ logs = filter_empty_logs (
298+ trace_event .resolved_logs (traces .root ) if trace_event else {}
299+ )
300+ logs_directory = (
301+ trace_event .logs_directory if trace_event is not None else None
302+ )
407303 if logs_directory is not None and not is_mute :
408304 add_junit_link_property (
409305 case ,
0 commit comments