Coverage for apio/managers/scons_manager.py: 87%

107 statements  

« prev     ^ index     » next       coverage.py v7.17.2, created at 2026-11-07 05:08 +0000

1# This file is part of the Apio project 

2# (C) 2016-present FPGAwars 

3# Project authors: 

4# * Jesús Arroyo (Jesus89) (2016-2019) 

5# * Juan Gonzalez (obijuan) (2019-2024) 

6# * The Apio Team (2024-present) 

7# License: GPLv2 

8 

9"""A manager class for to dispatch the Apio SCONS targets.""" 

10 

11import os 

12import sys 

13import time 

14import shutil 

15 

16from datetime import datetime 

17from google.protobuf import text_format 

18from apio.common.debug_util import is_debug 

19from apio.common import apio_console 

20from apio.common.apio_console import ( 

21 cout, 

22 cstyle, 

23 cunstyle, 

24) 

25from apio.common.apio_styles import SUCCESS, ERROR, EMPH3 

26from apio.utils import util 

27from apio.apio_context import ApioContext 

28from apio.managers.scons_filter import SconsFilter 

29 

30# from apio.common.proto.stubs.apio_common_pb2 import ApioArch 

31from apio.common.proto.stubs.apio_scons_pb2 import ( 

32 FORCE_PIPE, 

33 FORCE_TERMINAL, 

34 Verbosity, 

35 Environment, 

36 SconsParams, 

37 TargetParams, 

38 ApioEnvParams, 

39 GraphParams, 

40 LintParams, 

41 SimParams, 

42 ApioTestParams, 

43 UploadParams, 

44) 

45 

46 

47class SConsManager: 

48 """Class for managing the scons tools""" 

49 

50 def __init__(self, apio_ctx: ApioContext): 

51 """Initialization.""" 

52 # -- Cache the apio context. 

53 self.apio_ctx = apio_ctx 

54 

55 # -- Change to the project's folder. 

56 os.chdir(apio_ctx.project_dir) 

57 

58 def graph( 

59 self, graph_params: GraphParams, verbosity: Verbosity 

60 ) -> int | None: 

61 """Runs a scons subprocess with the 'graph' target. Returns process 

62 exit code, 0 if ok.""" 

63 

64 # -- Construct scons params with graph command info. 

65 scons_params = self.construct_scons_params( 

66 target_params=TargetParams(graph=graph_params), 

67 verbosity=verbosity, 

68 ) 

69 

70 # -- Run the scons process. 

71 return self._run_scons_subprocess("graph", scons_params=scons_params) 

72 

73 def lint(self, lint_params: LintParams) -> int | None: 

74 """Runs a scons subprocess with the 'lint' target. Returns process 

75 exit code, 0 if ok.""" 

76 

77 # -- Construct scons params with graph command info. 

78 scons_params = self.construct_scons_params( 

79 target_params=TargetParams(lint=lint_params) 

80 ) 

81 

82 # -- Run the scons process. 

83 return self._run_scons_subprocess("lint", scons_params=scons_params) 

84 

85 def sim(self, sim_params: SimParams) -> int | None: 

86 """Runs a scons subprocess with the 'sim' target. Returns process 

87 exit code, 0 if ok.""" 

88 

89 # -- Construct scons params with graph command info. 

90 scons_params = self.construct_scons_params( 

91 target_params=TargetParams(sim=sim_params) 

92 ) 

93 

94 # -- Run the scons process. 

95 return self._run_scons_subprocess("sim", scons_params=scons_params) 

96 

97 def test(self, test_params: ApioTestParams) -> int | None: 

98 """Runs a scons subprocess with the 'test' target. Returns process 

99 exit code, 0 if ok.""" 

100 

101 # -- Construct scons params with graph command info. 

102 scons_params = self.construct_scons_params( 

103 target_params=TargetParams(test=test_params) 

104 ) 

105 

106 # -- Run the scons process. 

107 return self._run_scons_subprocess("test", scons_params=scons_params) 

108 

109 def build(self, verbosity: Verbosity) -> int | None: 

110 """Runs a scons subprocess with the 'build' target. Returns process 

111 exit code, 0 if ok.""" 

112 

113 # -- Construct the scons params object. 

114 scons_params = self.construct_scons_params( 

115 verbosity=verbosity, 

116 ) 

117 

118 # -- Run the scons process. 

119 return self._run_scons_subprocess("build", scons_params=scons_params) 

120 

121 def report(self, verbosity: Verbosity) -> int | None: 

122 """Runs a scons subprocess with the 'report' target. Returns process 

123 exit code, 0 if ok.""" 

124 

125 # -- Construct the scons params object. 

126 scons_params = self.construct_scons_params( 

127 verbosity=verbosity, 

128 ) 

129 

130 # -- Run the scons process. 

131 return self._run_scons_subprocess("report", scons_params=scons_params) 

132 

133 def upload(self, upload_params: UploadParams) -> int | None: 

134 """Runs a scons subprocess with the 'time' target. Returns process 

135 exit code, 0 if ok. 

136 """ 

137 

138 # -- Construct the scons params. 

139 scons_params = self.construct_scons_params( 

140 target_params=TargetParams(upload=upload_params) 

141 ) 

142 

143 # -- Execute Scons for uploading! 

144 exit_code = self._run_scons_subprocess( 

145 "upload", scons_params=scons_params 

146 ) 

147 

148 return exit_code 

149 

150 def construct_scons_params( 

151 self, 

152 *, 

153 target_params: TargetParams | None = None, 

154 verbosity: Verbosity | None = None, 

155 ) -> SconsParams: 

156 """Populate and return the SconsParam proto to pass to the scons 

157 process.""" 

158 

159 # -- Create a shortcut. 

160 apio_ctx = self.apio_ctx 

161 

162 # -- Create an empty proto object that will be populated. 

163 result = SconsParams() 

164 

165 # -- Populate the timestamp. We use to to make sure scons reads the 

166 # -- correct version of the scons.params file. 

167 ts = datetime.now() 

168 result.timestamp = ts.strftime("%d%H%M%S%f")[:-3] 

169 

170 # -- Get the project data. All commands that invoke scons are expected 

171 # -- to be in a project context. 

172 assert apio_ctx.has_project, "Scons encountered a missing project." 

173 project = apio_ctx.project 

174 

175 # -- Get the project resources. 

176 pr = apio_ctx.project_resources 

177 

178 # -- Get the fpga definition. 

179 fpga_definition = pr.fpga_definition 

180 # result.arch = fpga_definition.arch 

181 

182 # -- Populate the the 'fpga_definition' field. 

183 result.fpga_definition.CopyFrom(fpga_definition) 

184 

185 # -- Populate the optional Verbosity params. 

186 if verbosity: 

187 result.verbosity.MergeFrom(verbosity) 

188 assert result.verbosity.IsInitialized(), result 

189 

190 # -- Populate the Environment params. 

191 env_consts = apio_ctx.config.platform_env.define_consts 

192 assert "YOSYS_LIB" in env_consts, env_consts 

193 assert "TRELLIS" in env_consts, env_consts 

194 assert "PRJXRAY_DB_DIR" in env_consts, env_consts 

195 

196 # -- Mutations that scons should apply to the env of teh tools it 

197 # -- invokes. 

198 tools_env_mutations = apio_ctx.get_env_mutations_for_subprocess( 

199 include_apio_packages=True 

200 ) 

201 

202 result.environment.MergeFrom( 

203 Environment( 

204 platform_id=apio_ctx.platform.id, 

205 terminal_mode=( 

206 FORCE_TERMINAL 

207 if apio_console.is_terminal() 

208 else FORCE_PIPE 

209 ), 

210 theme_name=apio_console.current_theme_name(), 

211 yosys_path=env_consts["YOSYS_LIB"], 

212 trellis_path=env_consts["TRELLIS"], 

213 scons_shell_id=apio_ctx.scons_shell_id(), 

214 xilinx_prjxray_db_path=env_consts["PRJXRAY_DB_DIR"], 

215 tools_env_mutations=tools_env_mutations, 

216 ) 

217 ) 

218 assert result.environment.IsInitialized(), result 

219 

220 # -- Populate the Project params. 

221 result.apio_env_params.MergeFrom( 

222 ApioEnvParams( 

223 env_name=apio_ctx.project.env_name, 

224 board_id=pr.board_id, 

225 top_module=project.get_str_option("top-module"), 

226 defines=apio_ctx.project.get_list_option( 

227 "defines", default=[] 

228 ), 

229 yosys_extra_options=apio_ctx.project.get_list_option( 

230 "yosys-extra-options", None 

231 ), 

232 nextpnr_extra_options=apio_ctx.project.get_list_option( 

233 "nextpnr-extra-options", None 

234 ), 

235 gtkwave_extra_options=apio_ctx.project.get_list_option( 

236 "gtkwave-extra-options", None 

237 ), 

238 verilator_extra_options=apio_ctx.project.get_list_option( 

239 "verilator-extra-options", None 

240 ), 

241 constraint_file=apio_ctx.project.get_str_option( 

242 "constraint-file", None 

243 ), 

244 ) 

245 ) 

246 assert result.apio_env_params.IsInitialized(), result 

247 

248 # -- Populate the optional command specific params. 

249 if target_params: 

250 result.target.MergeFrom(target_params) 

251 assert result.target.IsInitialized(), result 

252 

253 # -- All done. 

254 assert result.IsInitialized(), result 

255 return result 

256 

257 def _run_scons_subprocess( 

258 self, scons_target: str, *, scons_params: SconsParams 

259 ) -> int | None: 

260 """Invoke an scons subprocess.""" 

261 

262 # pylint: disable=too-many-locals 

263 

264 # -- Create a shortcut. 

265 apio_ctx = self.apio_ctx 

266 

267 # -- Pass to the scons process the name of the sconstruct file it 

268 # -- should use. 

269 scons_dir = util.get_path_in_apio_package("scons") 

270 scons_file_path = scons_dir / "SConstruct" 

271 variables = ["-f", f"{scons_file_path}"] 

272 

273 # -- Pass the path to the proto params file. The path is relative 

274 # -- to the project root. 

275 params_file_path = apio_ctx.env_build_path / "scons.params" 

276 variables += [f"params={str(params_file_path)}"] 

277 

278 # -- Pass to the scons process the timestamp of the scons params we 

279 # -- pass via a file. This is for verification purposes only. 

280 variables += [f"timestamp={scons_params.timestamp}"] 

281 

282 if is_debug(1): 282 ↛ 283line 282 didn't jump to line 283 because the condition on line 282 was never true

283 cout("\nSCONS CALL:", style=EMPH3) 

284 cout(f"* target: {scons_target}") 

285 cout(f"* variables: {variables}") 

286 cout(f"* scons params: \n{scons_params}") 

287 cout() 

288 

289 # -- Get the terminal width (typically 80) 

290 terminal_width, _ = shutil.get_terminal_size() 

291 

292 # -- Read the time (for measuring how long does it take 

293 # -- to execute the apio command) 

294 start_time = time.time() 

295 

296 # -- Subtracting 1 to avoid line overflow on windows, Observed with 

297 # -- Windows 10 and cmd.exe shell. 

298 if apio_ctx.is_windows: 298 ↛ 299line 298 didn't jump to line 299 because the condition on line 298 was never true

299 terminal_width -= 1 

300 

301 # -- Print a horizontal line 

302 cout("-" * terminal_width) 

303 

304 # -- Create the scons debug options. See details at 

305 # -- https://scons.org/doc/2.4.1/HTML/scons-man.html 

306 debug_options = ( 

307 ["--debug=explain,prepare,stacktrace", "--tree=all"] 

308 if is_debug(1) 

309 else [] 

310 ) 

311 

312 # -- Construct the scons command line. 

313 # -- 

314 # -- sys.executable is resolved to the full path of the python 

315 # -- interpreter or to apio if running from a pyinstall setup. 

316 # -- See https://github.com/orgs/pyinstaller/discussions/9023 for more 

317 # -- information. 

318 # -- 

319 # -- We use exec -m SCons instead of scones also for non pyinstaller 

320 # -- deployment in case the scons binary is not on the PATH. 

321 cmd = ( 

322 [sys.executable, "-m", "apio", "--scons"] 

323 + ["-Q", scons_target] 

324 + debug_options 

325 + variables 

326 ) 

327 

328 # -- An output filter that manipulates the scons stdout/err lines as 

329 # -- needed and write them to stdout. 

330 scons_filter = SconsFilter( 

331 colors_enabled=apio_console.is_colors_enabled() 

332 ) 

333 

334 # -- Write the scons parameters to a temp file in the build 

335 # -- directory. It will be cleaned up as part of 'apio cleanup'. 

336 # -- At this point, the project is the current directory, even if 

337 # -- the command used the --project-dir option. 

338 os.makedirs(apio_ctx.env_build_path, exist_ok=True) 

339 with open(params_file_path, "w", encoding="utf8") as f: 

340 f.write(text_format.MessageToString(scons_params)) 

341 

342 if is_debug(1): 342 ↛ 343line 342 didn't jump to line 343 because the condition on line 342 was never true

343 cout(f"\nFull scons command: {cmd}\n\n") 

344 

345 # -- Execute the scons builder. 

346 # -- 

347 # -- Note that the env settings for the apio packages is passed to 

348 # -- the scons process in the params rather than setting them directly 

349 # -- to the scons subprocess itself. The scons subprocess will apply 

350 # -- those env mutations to the tools it will dispatch. 

351 result = util.exec_command( 

352 cmd, 

353 stdout=util.AsyncPipe(scons_filter.on_stdout_line), 

354 stderr=util.AsyncPipe(scons_filter.on_stderr_line), 

355 env=apio_ctx.get_env_for_subprocess(include_apio_packages=False), 

356 ) 

357 

358 # -- Is there an error? True/False 

359 is_error = result.exit_code != 0 

360 

361 # -- Calculate the time it took to execute the command 

362 duration = time.time() - start_time 

363 

364 # -- Determine status message 

365 if is_error: 365 ↛ 366line 365 didn't jump to line 366 because the condition on line 365 was never true

366 styled_status = cstyle("ERROR", style=ERROR) 

367 else: 

368 styled_status = cstyle("SUCCESS", style=SUCCESS) 

369 

370 # -- Determine the summary text 

371 summary = f"Took {duration:.2f} seconds" 

372 

373 # -- Construct the entire message. 

374 styled_msg = f" [{styled_status}] {summary} " 

375 msg_len = len(cunstyle(styled_msg)) 

376 

377 # -- Determine the lengths of the paddings before and after 

378 # -- the message. Should be correct for odd and even terminal 

379 # -- widths. 

380 pad1_len = (terminal_width - msg_len) // 2 

381 pad2_len = terminal_width - pad1_len - msg_len 

382 

383 # -- Print the entire line. 

384 cout(f"{'=' * pad1_len}{styled_msg}{'=' * pad2_len}") 

385 

386 # -- Return the exit code 

387 return result.exit_code