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

151 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-25 02:31 +0000

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

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 

9import traceback 

10import os 

11import sys 

12import time 

13import shutil 

14from functools import wraps 

15from datetime import datetime 

16from google.protobuf import text_format 

17from apio.common import apio_console 

18from apio.common.apio_console import cout, cerror, cstyle, cunstyle 

19from apio.common.apio_styles import SUCCESS, ERROR, EMPH3, INFO 

20from apio.utils import util 

21from apio.apio_context import ApioContext 

22from apio.managers.scons_filter import SconsFilter 

23from apio.common.proto.apio_pb2 import ( 

24 FORCE_PIPE, 

25 FORCE_TERMINAL, 

26 Verbosity, 

27 Environment, 

28 SconsParams, 

29 TargetParams, 

30 FpgaInfo, 

31 ApioEnvParams, 

32 Ice40FpgaParams, 

33 Ecp5FpgaParams, 

34 GowinFpgaParams, 

35 ApioArch, 

36 GraphParams, 

37 LintParams, 

38 SimParams, 

39 ApioTestParams, 

40 UploadParams, 

41) 

42 

43# from apio.common import rich_lib_windows 

44 

45 

46# W0703: Catching too general exception Exception (broad-except) 

47# pylint: disable=W0703 

48# 

49# -- Based on 

50# -- https://stackoverflow.com/questions/5929107/decorators-with-parameters 

51def on_exception(*, exit_code: int): 

52 """Decorator for functions that return int exit code. If the function 

53 throws an exception, the error message is printed, and the caller see the 

54 returned value exit_code instead of the exception. 

55 """ 

56 

57 def decorator(function): 

58 @wraps(function) 

59 def wrapper(*args, **kwargs): 

60 try: 

61 return function(*args, **kwargs) 

62 except Exception as exc: 

63 if util.is_debug(1): 

64 traceback.print_tb(exc.__traceback__) 

65 

66 if str(exc): 

67 cerror(str(exc)) 

68 

69 if not util.is_debug(1): 

70 cout( 

71 "Setting env var APIO_DEBUG=1 may provide " 

72 "additional diagnostic information.", 

73 style=INFO, 

74 ) 

75 return exit_code 

76 

77 return wrapper 

78 

79 return decorator 

80 

81 

82class SConsManager: 

83 """Class for managing the scons tools""" 

84 

85 def __init__(self, apio_ctx: ApioContext): 

86 """Initialization.""" 

87 # -- Cache the apio context. 

88 self.apio_ctx = apio_ctx 

89 

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

91 os.chdir(apio_ctx.project_dir) 

92 

93 @on_exception(exit_code=1) 

94 def graph(self, graph_params: GraphParams, verbosity: Verbosity) -> int: 

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

96 exit code, 0 if ok.""" 

97 

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

99 scons_params = self.construct_scons_params( 

100 target_params=TargetParams(graph=graph_params), 

101 verbosity=verbosity, 

102 ) 

103 

104 # -- Run the scons process. 

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

106 

107 @on_exception(exit_code=1) 

108 def lint(self, lint_params: LintParams) -> int: 

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

110 exit code, 0 if ok.""" 

111 

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

113 scons_params = self.construct_scons_params( 

114 target_params=TargetParams(lint=lint_params) 

115 ) 

116 

117 # -- Run the scons process. 

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

119 

120 @on_exception(exit_code=1) 

121 def sim(self, sim_params: SimParams) -> int: 

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

123 exit code, 0 if ok.""" 

124 

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

126 scons_params = self.construct_scons_params( 

127 target_params=TargetParams(sim=sim_params) 

128 ) 

129 

130 # -- Run the scons process. 

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

132 

133 @on_exception(exit_code=1) 

134 def test(self, test_params: ApioTestParams) -> int: 

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

136 exit code, 0 if ok.""" 

137 

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

139 scons_params = self.construct_scons_params( 

140 target_params=TargetParams(test=test_params) 

141 ) 

142 

143 # -- Run the scons process. 

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

145 

146 @on_exception(exit_code=1) 

147 def build(self, nextpnr_gui: bool, verbosity: Verbosity) -> int: 

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

149 exit code, 0 if ok.""" 

150 

151 # -- Construct the scons params object. 

152 scons_params = self.construct_scons_params( 

153 nextpnr_gui=nextpnr_gui, 

154 verbosity=verbosity, 

155 ) 

156 

157 # -- Run the scons process. 

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

159 

160 @on_exception(exit_code=1) 

161 def report(self, verbosity: Verbosity) -> int: 

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

163 exit code, 0 if ok.""" 

164 

165 # -- Construct the scons params object. 

166 scons_params = self.construct_scons_params(verbosity=verbosity) 

167 

168 # -- Run the scons process. 

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

170 

171 @on_exception(exit_code=1) 

172 def upload(self, upload_params: UploadParams) -> int: 

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

174 exit code, 0 if ok. 

175 """ 

176 

177 # -- Construct the scons params. 

178 scons_params = self.construct_scons_params( 

179 target_params=TargetParams(upload=upload_params) 

180 ) 

181 

182 # -- Execute Scons for uploading! 

183 exit_code = self._run_scons_subprocess( 

184 "upload", scons_params=scons_params 

185 ) 

186 

187 return exit_code 

188 

189 def construct_scons_params( 

190 self, 

191 *, 

192 target_params: TargetParams = None, 

193 nextpnr_gui: bool = False, 

194 verbosity: Verbosity = None, 

195 ) -> SconsParams: 

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

197 process.""" 

198 

199 # -- Create a shortcut. 

200 apio_ctx = self.apio_ctx 

201 

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

203 result = SconsParams() 

204 

205 # -- Set the nextpnr_gui if True. 

206 if nextpnr_gui: 206 ↛ 207line 206 didn't jump to line 207 because the condition on line 206 was never true

207 result.nextpnr_gui = True 

208 

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

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

211 ts = datetime.now() 

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

213 

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

215 # -- to be in a project context. 

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

217 project = apio_ctx.project 

218 

219 # -- Get the project resources. 

220 pr = apio_ctx.project_resources 

221 fpga_info = pr.fpga_info 

222 

223 # -- Populate the common values of FpgaInfo. 

224 result.fpga_info.MergeFrom( 

225 FpgaInfo( 

226 fpga_id=pr.fpga_id, 

227 part_num=fpga_info["part-num"], 

228 size=fpga_info["size"], 

229 ) 

230 ) 

231 

232 # - Populate the architecture specific values of result.fpga_info. 

233 fpga_arch = fpga_info["arch"] 

234 match fpga_arch: 

235 case "ice40": 

236 params = fpga_info["ice40-params"] 

237 result.arch = ApioArch.ICE40 

238 result.fpga_info.ice40_params.MergeFrom( 

239 Ice40FpgaParams( 

240 type=params["type"], 

241 package=params["package"], 

242 ) 

243 ) 

244 case "ecp5": 

245 params = fpga_info["ecp5-params"] 

246 result.arch = ApioArch.ECP5 

247 result.fpga_info.ecp5_params.MergeFrom( 

248 Ecp5FpgaParams( 

249 type=params["type"], 

250 package=params["package"], 

251 speed=params["speed"], 

252 ) 

253 ) 

254 case "gowin": 254 ↛ 264line 254 didn't jump to line 264 because the pattern on line 254 always matched

255 params = fpga_info["gowin-params"] 

256 result.arch = ApioArch.GOWIN 

257 result.fpga_info.gowin_params.MergeFrom( 

258 GowinFpgaParams( 

259 yosys_family=params["yosys-family"], 

260 nextpnr_family=params["nextpnr-family"], 

261 packer_device=params["packer-device"], 

262 ) 

263 ) 

264 case _: 

265 cerror(f"Unexpected fpga_arch value {fpga_arch}") 

266 sys.exit(1) 

267 

268 # -- We are done populating The FpgaInfo params.. 

269 assert result.fpga_info.IsInitialized(), result 

270 

271 # -- Populate the optional Verbosity params. 

272 if verbosity: 

273 result.verbosity.MergeFrom(verbosity) 

274 assert result.verbosity.IsInitialized(), result 

275 

276 # -- Populate the Environment params. 

277 assert apio_ctx.platform_id, "Missing platform_id in apio context" 

278 oss_set_vars = apio_ctx.all_packages["oss-cad-suite"]["env"][ 

279 "set-vars" 

280 ] 

281 assert "YOSYS_LIB" in oss_set_vars, oss_set_vars 

282 assert "TRELLIS" in oss_set_vars, oss_set_vars 

283 

284 result.environment.MergeFrom( 

285 Environment( 

286 platform_id=apio_ctx.platform_id, 

287 is_windows=apio_ctx.is_windows, 

288 terminal_mode=( 

289 FORCE_TERMINAL 

290 if apio_console.is_terminal() 

291 else FORCE_PIPE 

292 ), 

293 theme_name=apio_console.current_theme_name(), 

294 debug_level=util.debug_level(), 

295 yosys_path=oss_set_vars["YOSYS_LIB"], 

296 trellis_path=oss_set_vars["TRELLIS"], 

297 scons_shell_id=apio_ctx.scons_shell_id, 

298 ) 

299 ) 

300 assert result.environment.IsInitialized(), result 

301 

302 # -- Populate the Project params. 

303 result.apio_env_params.MergeFrom( 

304 ApioEnvParams( 

305 env_name=apio_ctx.project.env_name, 

306 board_id=pr.board_id, 

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

308 defines=apio_ctx.project.get_list_option( 

309 "defines", default=[] 

310 ), 

311 yosys_extra_options=apio_ctx.project.get_list_option( 

312 "yosys-extra-options", None 

313 ), 

314 nextpnr_extra_options=apio_ctx.project.get_list_option( 

315 "nextpnr-extra-options", None 

316 ), 

317 gtkwave_extra_options=apio_ctx.project.get_list_option( 

318 "gtkwave-extra-options", None 

319 ), 

320 verilator_extra_options=apio_ctx.project.get_list_option( 

321 "verilator-extra-options", None 

322 ), 

323 constraint_file=apio_ctx.project.get_str_option( 

324 "constraint-file", None 

325 ), 

326 ) 

327 ) 

328 assert result.apio_env_params.IsInitialized(), result 

329 

330 # -- Populate the optional command specific params. 

331 if target_params: 

332 result.target.MergeFrom(target_params) 

333 assert result.target.IsInitialized(), result 

334 

335 # -- All done. 

336 assert result.IsInitialized(), result 

337 return result 

338 

339 def _run_scons_subprocess( 

340 self, scons_command: str, *, scons_params: SconsParams = None 

341 ): 

342 """Invoke an scons subprocess.""" 

343 

344 # pylint: disable=too-many-locals 

345 

346 # -- Create a shortcut. 

347 apio_ctx = self.apio_ctx 

348 

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

350 # -- should use. 

351 scons_dir = util.get_path_in_apio_package("scons") 

352 scons_file_path = scons_dir / "SConstruct" 

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

354 

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

356 # -- to the project root. 

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

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

359 

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

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

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

363 

364 # -- We set the env variables also for a command such as 'clean' 

365 # -- which doesn't use the packages, to satisfy the required env 

366 # -- variables of the scons arg parser. 

367 apio_ctx.set_env_for_packages() 

368 

369 if util.is_debug(1): 369 ↛ 370line 369 didn't jump to line 370 because the condition on line 369 was never true

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

371 cout(f"* command: {scons_command}") 

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

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

374 cout() 

375 

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

377 terminal_width, _ = shutil.get_terminal_size() 

378 

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

380 # -- to execute the apio command) 

381 start_time = time.time() 

382 

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

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

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

386 terminal_width -= 1 

387 

388 # -- Print a horizontal line 

389 cout("-" * terminal_width) 

390 

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

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

393 debug_options = ( 

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

395 if util.is_debug(1) 

396 else [] 

397 ) 

398 

399 # -- Construct the scons command line. 

400 # -- 

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

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

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

404 # -- information. 

405 # -- 

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

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

408 cmd = ( 

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

410 + ["-Q", scons_command] 

411 + debug_options 

412 + variables 

413 ) 

414 

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

416 # -- needed and write them to stdout. 

417 scons_filter = SconsFilter( 

418 colors_enabled=apio_console.is_colors_enabled() 

419 ) 

420 

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

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

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

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

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

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

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

428 

429 if util.is_debug(1): 429 ↛ 430line 429 didn't jump to line 430 because the condition on line 429 was never true

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

431 

432 # -- Execute the scons builder! 

433 result = util.exec_command( 

434 cmd, 

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

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

437 ) 

438 

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

440 is_error = result.exit_code != 0 

441 

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

443 duration = time.time() - start_time 

444 

445 # -- Determine status message 

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

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

448 else: 

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

450 

451 # -- Determine the summary text 

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

453 

454 # -- Construct the entire message. 

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

456 msg_len = len(cunstyle(styled_msg)) 

457 

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

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

460 # -- widths. 

461 pad1_len = (terminal_width - msg_len) // 2 

462 pad2_len = terminal_width - pad1_len - msg_len 

463 

464 # -- Print the entire line. 

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

466 

467 # -- Return the exit code 

468 return result.exit_code