Coverage for apio/scons/plugin_util.py: 84%

286 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"""Helper functions for apio scons plugins.""" 

10 

11from glob import glob 

12import os 

13import re 

14import subprocess 

15from dataclasses import dataclass 

16from pathlib import Path 

17from rich.table import Table 

18from rich import box 

19from SCons import Scanner 

20from SCons.Builder import Builder 

21from SCons.Action import FunctionAction, Action 

22from SCons.Node.FS import File 

23from SCons.Script.SConscript import SConsEnvironment 

24from SCons.Node import NodeList 

25from SCons.Node.Alias import Alias 

26from apio.scons.apio_env import ApioEnv 

27from apio.common.proto.stubs.apio_scons_pb2 import SimParams, ApioTestParams 

28from apio.common.common_util import ( 

29 PROJECT_BUILD_PATH, 

30 has_testbench_name, 

31 is_source_file, 

32 cmd_in_env, 

33) 

34from apio.common.debug_util import is_debug 

35from apio.common.apio_console import cout, ctable, fatal_error 

36from apio.common.apio_styles import INFO, BORDER, EMPH1, EMPH2, EMPH3 

37from apio.scons import gtkwave_util 

38from apio.common.build_report import BuildReport, read_build_report 

39 

40TESTBENCH_HINT = "Testbench file names must end with '_tb.v' or '_tb.sv'." 

41 

42 

43def map_str_params(str_params: list[str] | None, fmt: str) -> str: 

44 """A common function construct a command string snippet from a list 

45 of arguments. The function does the following: 

46 1. If params arg is None replace it with [] 

47 2. Drops empty or white space only items. 

48 3. Maps the items using the format string which contains exactly one 

49 placeholder {}. 

50 4. Joins the items with a white space char. 

51 

52 For examples, see the unit test at test_scons_util.py. 

53 """ 

54 # -- Replace None with an empty list. 

55 if str_params is None: 

56 str_params = [] 

57 

58 # Convert params to stripped strings. 

59 str_params = [x.strip() for x in str_params] 

60 

61 # Drop the empty params and map the rest. 

62 mapped_params = [fmt.format(x) for x in str_params if x] 

63 

64 # Join using a single space. 

65 return " ".join(mapped_params) 

66 

67 

68def map_path_params(path_params: list[Path] | None, fmt: str) -> str: 

69 """Same as map_str_params() but accepts a list of Path that is first 

70 converted to a string and then passed to map_str_params()""" 

71 # -- Replace None with an empty list 

72 if path_params is None: 

73 path_params = [] 

74 

75 # -- Convert to a list of strings. 

76 str_params: list[str] = [] 

77 for p in path_params: 

78 assert isinstance(p, Path), type(p) 

79 str_params.append(str(p)) 

80 

81 # -- Map the strings. 

82 return map_str_params(str_params, fmt) 

83 

84 

85def get_constraint_file(apio_env: ApioEnv, file_ext: str) -> str: 

86 """Returns the name of the constraint file to use. 

87 

88 env is the sconstruction environment. 

89 

90 file_ext is a string with the constrained file extension. 

91 E.g. ".pcf" for ice40. 

92 

93 Returns the file name if found or exit with an error otherwise. 

94 """ 

95 

96 # -- If the user specified a 'constraint-file' in apio.ini then use it. 

97 user_specified = apio_env.params.apio_env_params.constraint_file 

98 

99 if user_specified: 

100 path = Path(user_specified) 

101 # -- Path should be relative. 

102 if path.is_absolute(): 102 ↛ 103line 102 didn't jump to line 103 because the condition on line 102 was never true

103 fatal_error( 

104 f"Constraint file path is not relative: {user_specified}" 

105 ) 

106 # -- Constrain file extension should match the architecture. 

107 if path.suffix != file_ext: 

108 fatal_error( 

109 f"Constraint file should have the extension '{file_ext}': " 

110 + f"{user_specified}." 

111 ) 

112 # -- File should not be under _build 

113 if PROJECT_BUILD_PATH in path.parents: 

114 fatal_error( 

115 f"Constraint file should not be under {PROJECT_BUILD_PATH}: " 

116 + f"{user_specified}." 

117 ) 

118 # -- Path should not contain '..' to avoid traveling outside of the 

119 # -- project and coming back. 

120 for part in path.parts: 

121 if part == "..": 

122 fatal_error( 

123 "Constraint file path should not contain '..': " 

124 + f"{user_specified}." 

125 ) 

126 

127 # -- Constrain file looks good. 

128 return user_specified 

129 

130 # -- No user specified constraint file, we will try to look for it 

131 # -- in the project tree. 

132 glob_files: list[str] = glob(f"**/*{file_ext}", recursive=True) 

133 

134 # -- Exclude files that are under _build 

135 filtered_files: list[str] = [ 

136 f for f in glob_files if PROJECT_BUILD_PATH not in Path(f).parents 

137 ] 

138 

139 # -- Handle by file count. 

140 n = len(filtered_files) 

141 

142 # -- Case 1: No matching constrain files. 

143 if n == 0: 

144 fatal_error(f"No constraint file '*{file_ext}' found.") 

145 

146 # -- Case 2: Exactly one constrain file found. 

147 if n == 1: 

148 result = str(filtered_files[0]) 

149 return result 

150 

151 # -- Case 3: Multiple matching constrain files. 

152 fatal_error( 

153 f"Found {n} constraint files '*{file_ext}' " 

154 + "in the project tree, which one to use?", 

155 info="Use the apio.ini constraint-file option to specify " 

156 + "the desired file.", 

157 ) 

158 

159 

160def verilog_src_scanner(apio_env: ApioEnv) -> Scanner.Base: 

161 """Creates and returns a scons Scanner object for scanning verilog 

162 files for dependencies. 

163 """ 

164 # A Regex to icestudio propriaetry references for *.list files. 

165 # Example: 

166 # Text: ' parameter v771499 = "v771499.list"' 

167 # Captures: 'v771499.list' 

168 icestudio_list_re = re.compile(r"[\n|\s][^\/]?\"(.*\.list?)\"", re.M) 

169 

170 # A regex to match a verilog include directive. 

171 # Example 

172 # Text: `include "apio_testing.vh" 

173 # Capture: 'apio_testing.vh' 

174 verilog_include_re = re.compile(r'`\s*include\s+["]([^"]+)["]', re.M) 

175 

176 # A regex for inclusion via $readmemh() 

177 # Example 

178 # Test: '$readmemh("my_data.hex", State_buff);' 

179 # Capture: 'my_data.hex' 

180 readmemh_reference_re = re.compile( 

181 r"\$readmemh\([\'\"]([^\'\"]+)[\'\"]", re.M 

182 ) 

183 

184 # -- List of required and optional files that may require a rebuild if 

185 # -- changed. 

186 core_dependencies = [ 

187 "apio.ini", 

188 "boards.jsonc", 

189 "fpgas.jsonc", 

190 "programmers.jsonc", 

191 ] 

192 

193 def verilog_src_scanner_func( 

194 file_node: File, env: SConsEnvironment, ignored_path 

195 ) -> list[str]: 

196 """Given a [System]Verilog file, scan it and return a list of 

197 references to other files it depends on. It's not require to report 

198 dependency on another source file in the project since scons loads 

199 anyway all the source files in the project. 

200 

201 Returns a list of files. Dependencies that don't have an existing 

202 file are ignored and not returned. This is to avoid references in 

203 commented out code to break scons dependencies. 

204 """ 

205 _ = env # Unused 

206 

207 # Sanity check. Should be called only to scan verilog files. If 

208 # this fails, this is a programming error rather than a user error. 

209 if not is_source_file(file_node.name): 209 ↛ 210line 209 didn't jump to line 210 because the condition on line 209 was never true

210 fatal_error(f"'{file_node.name}' is not a source file.") 

211 

212 # Get the directory of the file, relative to the project root which is 

213 # the current working directory. This value is equals to "." if the 

214 # file is in the project root. 

215 file_dir: str = file_node.get_dir().get_path() 

216 

217 # Prepare an empty set of dependencies. 

218 candidates_raw_set = set() 

219 

220 # Read the file. This returns [] if the file doesn't exist. 

221 file_content = file_node.get_text_contents() 

222 

223 # Get verilog includes references. 

224 candidates_raw_set.update(verilog_include_re.findall(file_content)) 

225 

226 # Get $readmemh() function references. 

227 candidates_raw_set.update(readmemh_reference_re.findall(file_content)) 

228 

229 # Get IceStudio references. 

230 candidates_raw_set.update(icestudio_list_re.findall(file_content)) 

231 

232 # Since we don't know if the dependency's path is relative to the file 

233 # location or the project root, we try both. We prefer to have high 

234 # recall of dependencies of high precision, risking at most unnecessary 

235 # rebuilds. 

236 candidates_set = candidates_raw_set.copy() 

237 # If the file is not in the project dir, add a dependency also relative 

238 # to the project dir. 

239 if file_dir != ".": 

240 for raw_candidate in candidates_raw_set: 

241 candidate: str = os.path.join(file_dir, raw_candidate) 

242 candidates_set.add(candidate) 

243 

244 # Add the core dependencies. They are always relative to the project 

245 # root. 

246 candidates_set.update(core_dependencies) 

247 

248 # Filter out candidates that don't have a matching files to prevert 

249 # breaking the build. This handle for example the case where the 

250 # file references is in a comment or non reachable code. 

251 # See also https://stackoverflow.com/q/79302552/15038713 

252 dependencies = [] 

253 for dependency in candidates_set: 

254 if Path(dependency).exists(): 

255 dependencies.append(dependency) 

256 elif is_debug(1): 256 ↛ 257line 256 didn't jump to line 257 because the condition on line 256 was never true

257 cout( 

258 f"Dependency candidate {dependency} does not exist, " 

259 "dropping." 

260 ) 

261 

262 # Sort the strings for determinism. 

263 dependencies = sorted(list(dependencies)) 

264 

265 # Debug info. 

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

267 cout(f"Dependencies of {file_node}:", style=EMPH2) 

268 for dependency in dependencies: 

269 cout(f" {dependency}", style=EMPH2) 

270 

271 # All done 

272 return apio_env.scons_env.File(dependencies) 

273 

274 return apio_env.scons_env.Scanner(function=verilog_src_scanner_func) 

275 

276 

277def verilator_lint_action( 

278 apio_env: ApioEnv, 

279 *, 

280 extra_params: list[str] | None = None, 

281 lib_dirs: list[Path] | None = None, 

282 lib_files: list[Path] | None = None, 

283) -> list[FunctionAction | str]: 

284 # -> list[ 

285 # Callable[ 

286 # [ 

287 # list[File], 

288 # list[Alias], 

289 # SConsEnvironment, 

290 # ], 

291 # None, 

292 # ] 

293 # | str, 

294 # ]: 

295 """Construct an verilator scons action. 

296 * extra_params: Optional additional arguments. 

297 * libs_dirs: Optional directories for include search. 

298 * lib_files: Optional additional files to include. 

299 Returns an action in a form of a list with two steps, a function to call 

300 and a string command. 

301 """ 

302 

303 # -- Sanity checks 

304 assert apio_env.targeting_one_of("lint") 

305 assert apio_env.params.target.HasField("lint") 

306 

307 # -- Keep short references. 

308 params = apio_env.params 

309 lint_params = params.target.lint 

310 

311 # -- Determine if linting the entire project or just a few files, 

312 lint_whole_project = not lint_params.file_names 

313 

314 # -- Determine if using a vlt file. We use it only when linting a whole 

315 # -- project and --novlt was not specified. 

316 using_vlt = lint_whole_project and (not lint_params.novlt) 

317 

318 # -- Determine the top module. 

319 if lint_params.top_module: 

320 # -- Case 1: Top module was specified in the command line. 

321 top_module = lint_params.top_module 

322 elif lint_whole_project: 

323 # -- Case 2: Linting the entire project, use top module from apio.ini, 

324 top_module = params.apio_env_params.top_module 

325 else: 

326 # -- Linting only a few files and top module was not specified. 

327 top_module = None 

328 

329 print(f"{params.apio_env_params.verilator_extra_options=}") 

330 # -- Construct the action 

331 action = ( 

332 "verilator_bin --lint-only --quiet --bbox-unsup --timing " 

333 "-Wno-TIMESCALEMOD -Wno-MULTITOP {0} {1} -DAPIO_SIM=0 " 

334 "{2} {3} {4} {5} {6} {7} {8} $SOURCES" 

335 ).format( 

336 "" if lint_params.nosynth else "-DSYNTHESIZE", 

337 "" if lint_whole_project else "-Wno-MODMISSING", 

338 " ".join(params.apio_env_params.verilator_extra_options), 

339 f"--top-module {top_module}" if top_module else "", 

340 get_define_flags(apio_env), 

341 map_str_params(extra_params, "{}"), 

342 (map_path_params(lib_dirs, '-I"{}"') if lint_whole_project else ""), 

343 apio_env.target + ".vlt" if using_vlt else "", 

344 (map_path_params(lib_files, '"{}"') if lint_whole_project else ""), 

345 ) 

346 

347 return [ 

348 source_files_issue_scanner_action(), 

349 str(action), 

350 ] 

351 

352 

353@dataclass(frozen=True) 

354class TestbenchInfo: 

355 """Testbench simulation parameters, used by apio sim and apio test 

356 commands.""" 

357 

358 testbench_path: str # The relative testbench file path. 

359 build_testbench_name: str # testbench_name prefixed by build dir. 

360 srcs: list[str] # List of source files to compile. 

361 

362 @property 

363 def testbench_name(self) -> str: 

364 """The testbench path without the file extension.""" 

365 return basename(self.testbench_path) 

366 

367 

368def detached_action(apio_env: ApioEnv, cmd: list[str]) -> Action: 

369 """ 

370 Launch the given command, given as a list of tokens, in a detached 

371 (non blocking) mode. 

372 """ 

373 

374 def action_func( 

375 target: list[Alias], source: list[File], env: SConsEnvironment 

376 ): 

377 """A call back function to perform the detached command invocation.""" 

378 

379 # -- Make the linter happy 

380 # pylint: disable=consider-using-with 

381 _ = (target, source, env) 

382 

383 # -- NOTE: To debug these Popen operations, comment out the stdout= 

384 # -- and stderr= lines to see the output and error messages from the 

385 # -- commands. 

386 

387 tools_env = apio_env.scons_env["ENV"] 

388 

389 # -- Handle the case of Window. 

390 if apio_env.apio_platform.is_windows: 

391 detached_flag = getattr(subprocess, "DETACHED_PROCESS", 0) 

392 group_flag = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) 

393 creationflags = detached_flag | group_flag 

394 

395 subprocess.Popen( 

396 cmd_in_env(cmd, tools_env), 

397 creationflags=creationflags, 

398 stdout=subprocess.DEVNULL, 

399 stderr=subprocess.DEVNULL, 

400 close_fds=True, 

401 shell=False, 

402 env=tools_env, 

403 ) 

404 return 0 

405 

406 # -- Handle the rest (macOS and Linux) 

407 subprocess.Popen( 

408 cmd_in_env(cmd, tools_env), 

409 stdout=subprocess.DEVNULL, 

410 stderr=subprocess.DEVNULL, 

411 close_fds=True, 

412 start_new_session=True, 

413 shell=False, 

414 env=tools_env, 

415 ) 

416 return 0 

417 

418 # -- Create the command display string that will be shown to the user. 

419 cmd_str: str = subprocess.list2cmdline(cmd) 

420 display_str: str = "[detached] " + cmd_str 

421 

422 # -- Create the action and return 

423 action = Action(action_func, display_str) 

424 return action 

425 

426 

427def gtkwave_target( 

428 apio_env: ApioEnv, 

429 target_name: str, # always 'sim' 

430 vcd_file_target: NodeList, 

431 testbench_info: TestbenchInfo, 

432 sim_params: SimParams, 

433 gtkwave_extra_options: list[str] | None, 

434) -> list[Alias]: 

435 """Construct a target to launch the QTWave signal viewer. 

436 vcd_file_target is the simulator target that generated the vcd file 

437 with the signals. Returns the new targets. 

438 """ 

439 

440 # pylint: disable=too-many-arguments 

441 # pylint: disable=too-many-positional-arguments 

442 

443 # -- Construct the list of actions. 

444 actions = [] 

445 

446 # -- If needed, generate default .gtkw file to make sure the top level 

447 # -- signals are shown by default. 

448 gtkw_path: str = testbench_info.testbench_name + ".gtkw" 

449 vcd_path = str(vcd_file_target[0]) 

450 

451 def create_default_gtkw_file( 

452 target: list[Alias], source: list[File], env: SConsEnvironment 

453 ): 

454 """The action function to generate the default .gtkw file.""" 

455 _ = (target, source, env) # Unused. 

456 cout(f"Generating default {gtkw_path}") 

457 gtkwave_util.create_gtkwave_file( 

458 testbench_info.testbench_path, vcd_path, gtkw_path 

459 ) 

460 

461 if gtkwave_util.is_user_gtkw_file(gtkw_path): 

462 cout(f"Found user saved {gtkw_path}") 

463 else: 

464 actions.append(Action(create_default_gtkw_file, strfunction=None)) 

465 

466 # -- Skip or execute gtkwave. 

467 if sim_params.no_gtkwave: 467 ↛ 483line 467 didn't jump to line 483 because the condition on line 467 was always true

468 # -- User asked to skip gtkwave. The '@' suppresses the printing 

469 # -- of the echo command itself. 

470 actions.append( 

471 "@echo 'Flag --no-gtkwave was found, skipping GTKWave.'" 

472 ) 

473 

474 else: 

475 # -- Normal case, invoking gtkwave. 

476 

477 # -- On windows we need to setup the cache. This could be done once 

478 # -- when the oss-cad-suite is installed but since we currently don't 

479 # -- have a package setup mechanism, we do it here on each invocation. 

480 # -- The time penalty is negligible. 

481 # -- With the stock oss-cad-suite windows package, this is done in the 

482 # -- environment.bat script. 

483 if apio_env.apio_platform.is_windows: 

484 actions.append("gdk-pixbuf-query-loaders --update-cache") 

485 

486 # -- The actual wave viewer command. 

487 gtkwave_cmd = ["gtkwave"] 

488 # -- NOTE: Users can override these rcvars by adding the desired 

489 # -- rcvar options in apio.ini gtkwave-extra-options which will win 

490 # -- since they will appear later in the command line. 

491 gtkwave_cmd.append("--rcvar=do_initial_zoom_fit 1") 

492 if gtkwave_extra_options: 

493 gtkwave_cmd.extend(gtkwave_extra_options) 

494 gtkwave_cmd.extend([vcd_path, gtkw_path]) 

495 

496 # -- Handle the case where gtkwave is run as a detached app, not 

497 # -- waiting for it to close and not showing its output. 

498 if sim_params.detach_gtkwave: 

499 gtkwave_action = detached_action(apio_env, gtkwave_cmd) 

500 else: 

501 gtkwave_action = subprocess.list2cmdline(gtkwave_cmd) 

502 

503 actions.append(gtkwave_action) 

504 

505 # -- Define a target with the action(s) we created. 

506 target = apio_env.add_alias( 

507 target_name, 

508 source=vcd_file_target, 

509 action=actions, 

510 always_build=True, 

511 ) 

512 

513 return target 

514 

515 

516def check_valid_testbench_name(testbench: str) -> None: 

517 """Check if a testbench name is valid. If not, print an error message 

518 and exit.""" 

519 if not is_source_file(testbench) or not has_testbench_name(testbench): 519 ↛ 520line 519 didn't jump to line 520 because the condition on line 519 was never true

520 fatal_error( 

521 f"'{testbench}' is not a valid testbench file name.", 

522 info=TESTBENCH_HINT, 

523 ) 

524 

525 

526def get_apio_sim_testbench_info( 

527 apio_env: ApioEnv, 

528 sim_params: SimParams, 

529 synth_srcs: list[str], 

530 test_srcs: list[str], 

531) -> TestbenchInfo: 

532 """Returns a SimulationConfig for a sim command. 'testbench' is 

533 an optional testbench file name. 'synth_srcs' and 'test_srcs' are the 

534 all the project's synth and testbench files found in the project as 

535 returned by get_project_source_files().""" 

536 

537 # -- Handle the testbench file selection. The end result is a single 

538 # -- testbench file name in testbench that we simulate, or a fatal error. 

539 if sim_params.testbench_path: 

540 # -- Case 1 - Testbench file name is specified in the command or 

541 # -- apio.ini. Fatal error if invalid. 

542 check_valid_testbench_name(sim_params.testbench_path) 

543 testbench = sim_params.testbench_path 

544 elif len(test_srcs) == 0: 544 ↛ 547line 544 didn't jump to line 547 because the condition on line 544 was never true

545 # -- Case 2 Testbench name was not specified and no testbench files 

546 # -- were found in the project. 

547 fatal_error( 

548 "No testbench files found in the project.", 

549 info=TESTBENCH_HINT, 

550 ) 

551 elif len(test_srcs) == 1: 551 ↛ 559line 551 didn't jump to line 559 because the condition on line 551 was always true

552 # -- Case 3 Testbench name was not specified but there is exactly 

553 # -- one in the project. 

554 testbench = test_srcs[0] 

555 cout(f"Found testbench file {testbench}", style=EMPH1) 

556 else: 

557 # -- Case 4 Testbench name was not specified and there are multiple 

558 # -- testbench files in the project. 

559 fatal_error( 

560 "Multiple testbench files found in the project.", 

561 info=[ 

562 "Please specify the testbench file name in the command ", 

563 "or specify the 'default-testbench' option in apio.ini.", 

564 ], 

565 ) 

566 

567 # -- This should not happen. If it does, it's a programming error. 

568 assert testbench, "get_sim_config(): Missing testbench file name" 

569 

570 # -- Construct a SimulationParams with all the synth files + the 

571 # -- testbench file. 

572 testbench_name = basename(testbench) 

573 build_testbench_name = str(apio_env.env_build_path / testbench_name) 

574 srcs = synth_srcs + [testbench] 

575 return TestbenchInfo(testbench, build_testbench_name, srcs) 

576 

577 

578def get_apio_test_testbenches_infos( 

579 apio_env: ApioEnv, 

580 test_params: ApioTestParams, 

581 synth_srcs: list[str], 

582 test_srcs: list[str], 

583) -> list[TestbenchInfo]: 

584 """Return a list of SimulationConfigs for each of the testbenches that 

585 need to be run for a 'apio test' command. If testbench is empty, 

586 all the testbenches in test_srcs will be tested. Otherwise, only the 

587 testbench in testbench will be tested. synth_srcs and test_srcs are 

588 source and test file lists as returned by get_project_source_files().""" 

589 # List of testbenches to be tested. 

590 

591 # -- Handle the testbench files selection. The end result is a list of one 

592 # -- or more testbench file names in testbenches that we test. 

593 if test_params.testbench_path: 

594 # -- Case 1 - a testbench file name is specified in the command or 

595 # -- apio.ini. Fatal error if invalid. 

596 check_valid_testbench_name(test_params.testbench_path) 

597 testbenches = [test_params.testbench_path] 

598 elif len(test_srcs) == 0: 598 ↛ 601line 598 didn't jump to line 601 because the condition on line 598 was never true

599 # -- Case 2 - Testbench file name was not specified and there are no 

600 # -- testbench files in the project. 

601 fatal_error( 

602 "No testbench files found in the project.", 

603 info=TESTBENCH_HINT, 

604 ) 

605 elif test_params.default_option: 

606 # -- Case 3: using --default option with no default testbench 

607 # -- specified in apio.ini. If we have exacly one testbench that 

608 # -- this is the default testbench, otherwise this is an error. 

609 if len(test_srcs) == 1: 609 ↛ 612line 609 didn't jump to line 612 because the condition on line 609 was always true

610 testbenches = [test_srcs[0]] 

611 else: 

612 fatal_error( 

613 "Multiple testbench files found in the project.", 

614 info=[ 

615 "To test only a single testbench, replace --default " 

616 + "with the testbench", 

617 "file path, or specify the 'default-testbench' " 

618 + "option in apio.ini.", 

619 ], 

620 ) 

621 else: 

622 # -- Case 4 - Testbench file name was not specified but there are one 

623 # -- or more testbench files in the project. 

624 testbenches = test_srcs 

625 

626 # -- If this fails, it's a programming error. 

627 assert testbenches, "get_tests_configs(): no testbenches" 

628 

629 # Construct a config for each testbench. 

630 configs = [] 

631 for tb in testbenches: 

632 testbench_name = basename(tb) 

633 build_testbench_name = str(apio_env.env_build_path / testbench_name) 

634 srcs = synth_srcs + [tb] 

635 configs.append(TestbenchInfo(tb, build_testbench_name, srcs)) 

636 

637 return configs 

638 

639 

640def announce_testbench_action() -> FunctionAction: 

641 """Returns an action that prints a title with the testbench name.""" 

642 

643 def announce_testbench( 

644 target: list[Alias], 

645 source: list[File], 

646 env: SConsEnvironment, 

647 ): 

648 """The action function.""" 

649 _ = (target, env) # Unused 

650 

651 # -- We expect to find exactly one testbench. 

652 testbenches = [ 

653 file 

654 for file in source 

655 if (is_source_file(file.name) and has_testbench_name(file.name)) 

656 ] 

657 assert len(testbenches) == 1, testbenches 

658 

659 # -- Announce it. 

660 cout() 

661 cout(f"Testbench {testbenches[0]}", style=EMPH3) 

662 

663 # -- Run the action but don't announce the action. 

664 return Action( 

665 announce_testbench, 

666 strfunction=None, 

667 ) 

668 

669 

670def source_files_issue_scanner_action() -> FunctionAction: 

671 """Returns a SCons action that scans the source files and print 

672 error or warning messages about issues it finds.""" 

673 

674 # A regex to identify "$dumpfile(" in testbenches. 

675 testbench_dumpfile_re = re.compile(r"[$]dumpfile\s*[(]") 

676 

677 def report_source_files_issues( 

678 target: list[Alias], 

679 source: list[File], 

680 env: SConsEnvironment, 

681 ): 

682 """The scanner function.""" 

683 

684 _ = (target, env) # Unused 

685 

686 for file in source: 

687 

688 # -- For now we report issues only in testbenches so skip 

689 # -- otherwise. 

690 if not is_source_file(file.name) or not has_testbench_name( 

691 file.name 

692 ): 

693 continue 

694 

695 # -- Read the testbench file text. 

696 file_text = file.get_text_contents() 

697 

698 # -- if contains $dumpfile, it's a fatal error. Apio sets the 

699 # -- default location of the testbenches output .vcd file. 

700 if testbench_dumpfile_re.findall(file_text): 700 ↛ 701line 700 didn't jump to line 701 because the condition on line 700 was never true

701 fatal_error( 

702 f"The testbench file '{file.name}' contains '$dumpfile'.", 

703 info=[ 

704 "Do not use $dumpfile(...) in your Apio testbenches.", 

705 "Let Apio configure automatically the proper " 

706 + "locations of the dump files.", 

707 ], 

708 ) 

709 

710 # -- Run the action but don't announce the action. We will print 

711 # -- ourselves in report_source_files_issues. 

712 return Action( 

713 report_source_files_issues, 

714 strfunction=None, 

715 ) 

716 

717 

718def _print_pnr_report( 

719 build_report: BuildReport, 

720 verbose: bool, 

721) -> None: 

722 """Emit a user friendly report from build report.""" 

723 

724 # -- Determine table title. 

725 title = "All FPGA resources" if verbose else "Used FPGA Resource" 

726 

727 # -- Utilization table 

728 table = Table( 

729 show_header=True, 

730 show_lines=False, 

731 box=box.SQUARE, 

732 border_style=BORDER, 

733 title=title, 

734 title_justify="left", 

735 padding=(0, 2), 

736 ) 

737 

738 # -- Add columns. 

739 table.add_column("RESOURCE", no_wrap=True) 

740 table.add_column("USED", no_wrap=True, justify="right") 

741 table.add_column("TOTAL", no_wrap=True, justify="right") 

742 table.add_column("USED%", no_wrap=True, justify="right") 

743 

744 # -- Add rows 

745 for res in build_report.resources: 

746 # -- By default we skip unused resources 

747 if not res.used and not verbose: 

748 continue 

749 

750 used_str = f"{res.used} " if res.used else "" 

751 available_str = f"{res.available} " 

752 if not res.used: 752 ↛ 753line 752 didn't jump to line 753 because the condition on line 752 was never true

753 percents_str = "" 

754 elif res.percentage is None: 

755 percents_str = "-- " 

756 else: 

757 percents_str = f"{int(res.percentage)}% " 

758 style = EMPH3 if res.used > 0 else None 

759 table.add_row( 

760 res.name, used_str, available_str, percents_str, style=style 

761 ) 

762 

763 # -- Render the utilization table table 

764 cout() 

765 ctable(table) 

766 

767 # -- Clocks table, if there is at least one clock. 

768 if len(build_report.clocks) > 0: 

769 

770 table = Table( 

771 show_header=True, 

772 show_lines=True, 

773 box=box.SQUARE, 

774 border_style=BORDER, 

775 title="Clock Information", 

776 title_justify="left", 

777 padding=(0, 2), 

778 ) 

779 

780 # -- Add columns 

781 table.add_column("CLOCK", no_wrap=True) 

782 table.add_column( 

783 "MAX SPEED [Mhz]", no_wrap=True, justify="right", style=EMPH3 

784 ) 

785 

786 # -- Add rows. 

787 for clk in build_report.clocks: 

788 table.add_row(clk.name, f"{clk.fmax_mhz:.2f}") 

789 

790 # -- Render the clocks table 

791 cout() 

792 ctable(table) 

793 

794 # -- Print hints. 

795 cout("") 

796 if len(build_report.clocks) == 0: 

797 cout("No clocks were found in the design.", style=INFO) 

798 

799 if not verbose: 

800 cout("Use '--verbose' for additional details.", style=INFO) 

801 

802 

803def report_action(verbose: bool) -> FunctionAction: 

804 """Returns a SCons action to format and print the PNR reort from the 

805 PNR json report file. Used by the 'apio report' command. 

806 'script_id' identifies the calling SConstruct script and 'verbose' 

807 indicates if the --verbose flag was invoked.""" 

808 

809 def print_pnr_report( 

810 target: list[Alias], 

811 source: list[File], 

812 env: SConsEnvironment, 

813 ): 

814 """Action function. Loads the pnr json report and print in a user 

815 friendly way.""" 

816 _ = (target, env) # Unused 

817 pnr_json_file: File = source[0] 

818 pnr_json_path: Path = Path(pnr_json_file.get_path()) 

819 build_report: BuildReport = read_build_report(pnr_json_path) 

820 _print_pnr_report(build_report, verbose) 

821 

822 return Action( 

823 print_pnr_report, 

824 "Formatting pnr report.", 

825 ) 

826 

827 

828def get_programmer_cmd(apio_env: ApioEnv) -> str: 

829 """Return the programmer command as derived from the scons "prog" 

830 arg.""" 

831 

832 # Should be called only if scons paramsm has 'upload' target parmas. 

833 params = apio_env.params 

834 assert params.target.HasField("upload"), params 

835 

836 # Get the programer command template arg. 

837 programmer_cmd = params.target.upload.programmer_cmd 

838 assert programmer_cmd, params 

839 

840 # -- [NOTE] Generally speaking we would expect the command to include 

841 # -- $SOURCE for the binary file path but since we allow custom commands 

842 # -- using apio.ini's 'programmer-cmd' option, we don't check for it here. 

843 

844 return programmer_cmd 

845 

846 

847def get_define_flags(apio_env: ApioEnv) -> str: 

848 """Return a string with the -D flags for the verilog defines. Returns 

849 an empty string if there are no defines.""" 

850 flags: list[str] = [] 

851 for define in apio_env.params.apio_env_params.defines: 

852 flags.append("-D" + define) 

853 

854 return " ".join(flags) 

855 

856 

857def iverilog_action( 

858 apio_env: ApioEnv, 

859 *, 

860 verbose: bool, 

861 is_interactive: bool, 

862 extra_params: list[str] | None = None, 

863 lib_dirs: list[Path] | None = None, 

864 lib_files: list[Path] | None = None, 

865) -> str: 

866 """Construct an iverilog scons action string. 

867 * env: Rhe scons environment. 

868 * verbose: IVerilog will show extra info. 

869 * is_interactive: True for apio sim, False otherwise. 

870 * extra_params: Optional list of additional IVerilog params. 

871 * lib_dirs: Optional list of dir paths to include. 

872 * lib_files: Optional list of library files to compile. 

873 * 

874 * Returns the scons action string for the IVerilog command. 

875 """ 

876 

877 # pylint: disable=too-many-arguments 

878 

879 # -- Construct the action string. 

880 # -- The -g2012 is for system-verilog support. 

881 action = ( 

882 "iverilog -g2012 {0} -o $TARGET {1} {2} {3} {4} {5} $SOURCES" 

883 ).format( 

884 "-v" if verbose else "", 

885 get_define_flags(apio_env), 

886 f"-DAPIO_SIM={int(is_interactive)}", 

887 map_str_params(extra_params, "{}"), 

888 map_path_params(lib_dirs, '-I"{}"'), 

889 map_path_params(lib_files, '"{}"'), 

890 ) 

891 

892 return action 

893 

894 

895def basename(file_name: str) -> str: 

896 """Given a file name, returns it with the extension removed.""" 

897 result, _ = os.path.splitext(file_name) 

898 return result 

899 

900 

901def make_verilator_config_builder( 

902 lib_path: Path, rules_to_suppress: list[str] 

903) -> Builder: 

904 """Create a scons Builder that writes a verilator config file 

905 (hardware.vlt) that suppresses warnings in the lib directory. 

906 Rules_to_suppress is a list of Verilator rules that should be suppressed 

907 for the given lib_path. 

908 """ 

909 assert isinstance(lib_path, Path), lib_path 

910 

911 # -- Construct a glob of all library files. 

912 glob_path = str(lib_path / "*") 

913 

914 # -- Escape for windows. A single backslash is converted into two. 

915 glob_str = str(glob_path).replace("\\", "\\\\") 

916 

917 # -- Generate the files lines. We suppress a union of all the errors we 

918 # -- encountered in all the architectures. 

919 lines = ["`verilator_config"] 

920 for rule in rules_to_suppress: 

921 lines.append(f'lint_off -rule {rule} -file "{glob_str}"') 

922 

923 # -- Join the lines into text. 

924 text = "\n".join(lines) + "\n" 

925 

926 def verilator_config_func(target, source, env): 

927 """Creates a verilator .vlt config files.""" 

928 _ = (source, env) # Unused 

929 with open(target[0].get_path(), "w", encoding="utf-8") as target_file: 

930 target_file.write(text) 

931 return 0 

932 

933 return Builder( 

934 action=Action( 

935 verilator_config_func, "Creating verilator config file." 

936 ), 

937 suffix=".vlt", 

938 )