Coverage for apio / managers / scons_filter.py: 82%

101 statements  

« prev     ^ index     » next       coverage.py v7.13.3, created at 2026-02-08 02:47 +0000

1"""DOC: TODO""" 

2 

3# -*- coding: utf-8 -*- 

4# -- This file is part of the Apio project 

5# -- (C) 2016-2019 FPGAwars 

6# -- Author Jesús Arroyo 

7# -- License GPLv2 

8 

9 

10import re 

11import threading 

12from enum import Enum 

13from typing import List, Optional, Tuple 

14from apio.common.apio_console import cout, cunstyle, cwrite, cstyle 

15from apio.common.apio_styles import INFO, WARNING, SUCCESS, ERROR 

16from apio.utils import util 

17 

18 

19# -- A table with line coloring rules. If a line matches any regex, it gets 

20# -- The style of the first regex it matches. Patterns are case insensitive. 

21LINE_COLORING_TABLE = [ 

22 # -- Info patterns 

23 (r"^info:", INFO), 

24 # -- Warning patterns 

25 (r"^warning:", WARNING), 

26 (r"%warning-", WARNING), # Lint/Verilator 

27 # -- Error patterns. 

28 (r"^error:", ERROR), 

29 (r"^%Error:", ERROR), 

30 (r" error: ", ERROR), 

31 (r"fail: ", ERROR), 

32 (r"fatal: ", ERROR), 

33 (r"^fatal error:", ERROR), 

34 (r"assertion failed", ERROR), 

35 # -- Success patterns 

36 (r"is up to date", SUCCESS), 

37 (r"[$]finish called", SUCCESS), 

38 (r"^verify ok$", SUCCESS), 

39 (r"^done$", SUCCESS), 

40] 

41 

42# -- Lines that contain a substring that match any of these regex's are 

43# -- ignored. Regexs are case insensitive. 

44LINE_IGNORE_LIST = [ 

45 # -- Per https://github.com/fpgawars/apio/issues/824 

46 # -- TODO: Remove when fixed. 

47 r"Warning: define gw1n not used in the library", 

48] 

49 

50 

51class PipeId(Enum): 

52 """Represent the two output streams from the scons subprocess.""" 

53 

54 STDOUT = 1 

55 STDERR = 2 

56 

57 

58class RangeEvents(Enum): 

59 """An stdout/err line can trigger one of these events, when detecting a 

60 range of lines.""" 

61 

62 START_BEFORE = 1 # Range starts before the current line. 

63 START_AFTER = 2 # Range starts after the current line. 

64 END_BEFORE = 3 # Range ends before the current line. 

65 END_AFTER = 4 # Range ends, after the current line. 

66 

67 

68class RangeDetector: 

69 """Base detector of a range of lines within the sequence of stdout/err 

70 lines recieves from the scons subprocess.""" 

71 

72 def __init__(self): 

73 self._in_range = False 

74 

75 def update(self, pipe_id: PipeId, line: str) -> bool: 

76 """Updates the range detector with the next stdout/err line. 

77 return True iff detector classified this line to be within a range.""" 

78 

79 prev_state = self._in_range 

80 event = self.classify_line(pipe_id, line) 

81 

82 if event == RangeEvents.START_BEFORE: 82 ↛ 83line 82 didn't jump to line 83 because the condition on line 82 was never true

83 self._in_range = True 

84 return self._in_range 

85 

86 if event == RangeEvents.START_AFTER: 

87 self._in_range = True 

88 return prev_state 

89 

90 if event == RangeEvents.END_BEFORE: 90 ↛ 91line 90 didn't jump to line 91 because the condition on line 90 was never true

91 self._in_range = False 

92 return self._in_range 

93 

94 if event == RangeEvents.END_AFTER: 

95 self._in_range = False 

96 return prev_state 

97 

98 assert event is None, event 

99 return self._in_range 

100 

101 def classify_line( 

102 self, pipe_id: PipeId, line: str 

103 ) -> Optional[RangeEvents]: # pragma: no cover 

104 """Tests if the next stdout/err line affects the range begin/end. 

105 Subclasses should implement this with the necessary logic for the 

106 range that is being detected. 

107 Returns the event of None if no event.""" 

108 raise NotImplementedError("Should be implemented by a subclass") 

109 

110 

111class PnrRangeDetector(RangeDetector): 

112 """Implements a RangeDetector for the nextpnr command verbose 

113 log lines.""" 

114 

115 def classify_line(self, pipe_id: PipeId, line: str) -> RangeEvents: 

116 # -- Break line into words. 

117 tokens = line.split() 

118 

119 # -- Range start: A nextpnr command on stdout without 

120 # -- the -q (quiet) flag. 

121 # -- 

122 # -- IMPORTANT: Each of the supported architecture has a different 

123 # -- nextpnr command, including 'nextpnr', 'nextpnr-ecp5', and 

124 # -- 'nextpnr-himbaechel'. 

125 if ( 

126 pipe_id == PipeId.STDOUT 

127 and line.startswith("nextpnr") 

128 and "-q" not in tokens 

129 ): 

130 return RangeEvents.START_AFTER 

131 

132 # Range end: The end message of nextnpr. 

133 if pipe_id == PipeId.STDERR and "Program finished normally." in line: 

134 return RangeEvents.END_AFTER 

135 

136 return None 

137 

138 

139class SconsFilter: 

140 """Implements the filtering and printing of the stdout/err streams of the 

141 scons subprocess. Accepts a line one at a time, detects lines ranges of 

142 interest, mutates and colors the lines where applicable, and print to 

143 stdout.""" 

144 

145 def __init__(self, colors_enabled: bool): 

146 self.colors_enabled = colors_enabled 

147 self._pnr_detector = PnrRangeDetector() 

148 

149 # self._iverilog_detector = IVerilogRangeDetector() 

150 # self._iceprog_detector = IceProgRangeDetector() 

151 

152 # -- We cache the values to avoid reevaluating sys env. 

153 self._is_debug = util.is_debug(1) 

154 self._is_verbose_debug = util.is_debug(5) 

155 

156 # -- Accumulates string pieces until we write and flush them. This 

157 # -- mechanism is used to display progress bar correctly, Writing the 

158 # -- erasure string only when a new value is available. 

159 self._output_bfr: str = "" 

160 

161 # -- The stdout and stderr are called from independent threads, so we 

162 # -- protect the handling method with this lock. 

163 # -- 

164 # -- We don't protect the third thread which is main(). We hope that 

165 # -- it doesn't any print console output while these two threads are 

166 # -- active, otherwise it can mingle the output. 

167 self._thread_lock = threading.Lock() 

168 

169 def on_stdout_line(self, line: str, terminator: str) -> None: 

170 """Stdout pipe calls this on each line. Called from the stdout thread 

171 in AsyncPipe.""" 

172 with self._thread_lock: 

173 self.on_line(PipeId.STDOUT, line, terminator) 

174 

175 def on_stderr_line(self, line: str, terminator: str) -> None: 

176 """Stderr pipe calls this on each line. Called from the stderr thread 

177 in AsyncPipe.""" 

178 with self._thread_lock: 

179 self.on_line(PipeId.STDERR, line, terminator) 

180 

181 @staticmethod 

182 def _assign_line_color( 

183 line: str, patterns: List[Tuple[str, str]], default_color: str = None 

184 ) -> Optional[str]: 

185 """Assigns a color for a given line using a list of (regex, color) 

186 pairs. Returns the color of the first matching regex (case 

187 insensitive), or default_color if none match. 

188 """ 

189 for regex, color in patterns: 

190 if re.search(regex, line, re.IGNORECASE): 

191 return color 

192 return default_color 

193 

194 def _output_line( 

195 self, line: str, style: Optional[str], terminator: str 

196 ) -> None: 

197 """Output a line. If a style is given, force that style, otherwise, 

198 pass on any color information it may have. The implementation takes 

199 into consideration progress bars such as when uploading with the 

200 iceprog programmer. These progress bar require certain timing between 

201 the chars to have sufficient time to display the text before erasing 

202 it.""" 

203 

204 # -- Apply style if needed. 

205 if style: 

206 line_part = cstyle(cunstyle(line), style=style) 

207 else: 

208 line_part = line 

209 

210 # -- Get line conditions. 

211 is_white = len(line.strip()) == 0 

212 is_cr = terminator == "\r" 

213 

214 if not is_cr: 214 ↛ 219line 214 didn't jump to line 219 because the condition on line 214 was always true

215 # -- Terminator is EOF or \n. We flush everything. 

216 self._output_bfr += line_part + terminator 

217 leftover = "" 

218 flush = True 

219 elif is_white: 

220 # -- Terminator is \r and line is white space (progress bar 

221 # -- eraser). We queue and and wait for the updated text. 

222 self._output_bfr += line_part + terminator 

223 leftover = "" 

224 flush = False 

225 else: 

226 # -- Terminator is \r and line has actual text, we flush it out 

227 # -- but save queue the \r because on windows 10 cmd it clears the 

228 # -- line(?) 

229 self._output_bfr += line_part 

230 leftover = terminator 

231 flush = True 

232 

233 if flush: 233 ↛ 239line 233 didn't jump to line 239 because the condition on line 233 was always true

234 # -- Flush the buffer and queue the optional leftover terminator. 

235 cwrite(self._output_bfr) 

236 self._output_bfr = leftover 

237 else: 

238 # -- We just queued. Should have no leftover here. 

239 assert not leftover 

240 

241 def _ignore_line(self, line: str) -> None: 

242 """Handle an ignored line. It's dumped if in debug mode.""" 

243 if self._is_debug: 243 ↛ 244line 243 didn't jump to line 244 because the condition on line 243 was never true

244 cout(f"IGNORED: {line}") 

245 

246 def on_line(self, pipe_id: PipeId, line: str, terminator) -> None: 

247 """A shared handler for stdout/err lines from the scons sub process. 

248 The handler writes both stdout and stderr lines to stdout, possibly 

249 with modifications such as text deletion, coloring, and cursor 

250 directives. 

251 

252 For the possible values of terminator, see AsyncPipe.__init__(). 

253 

254 NOTE: Ideally, the program specific patterns such as for Fumo and 

255 Iceprog should should be condition by a range detector for lines that 

256 came from that program. That is to minimize the risk of matching lines 

257 from other programs. See the PNR detector for an example. 

258 """ 

259 

260 if self._is_verbose_debug: 260 ↛ 261line 260 didn't jump to line 261 because the condition on line 260 was never true

261 cout( 

262 f"*** LINE: [{pipe_id}], [{repr(line)}], [{repr(terminator)}]", 

263 style=INFO, 

264 ) 

265 

266 # -- Update the range detectors. 

267 in_pnr_verbose_range = self._pnr_detector.update(pipe_id, line) 

268 

269 # -- If the line match any of the ignore patterns ignore it. 

270 for regex in LINE_IGNORE_LIST: 

271 if re.search(regex, line, re.IGNORECASE): 

272 self._ignore_line(line) 

273 return 

274 

275 # -- Remove the 'Info: ' prefix. Nextpnr write a long log where 

276 # -- each line starts with "Info: " 

277 if ( 277 ↛ 282line 277 didn't jump to line 282 because the condition on line 277 was never true

278 pipe_id == PipeId.STDERR 

279 and in_pnr_verbose_range 

280 and line.startswith("Info: ") 

281 ): 

282 line = line[6:] 

283 

284 # -- Output the line in the appropriate style. 

285 line_color = self._assign_line_color(line, LINE_COLORING_TABLE) 

286 self._output_line(line, line_color, terminator)