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

301 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-09 01:55 +0000

1# -*- coding: utf-8 -*- 

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

3# -- (C) 2016-2018 FPGAwars 

4# -- Author Jesús Arroyo 

5# -- License GPLv2 

6# -- Derived from: 

7# ---- Platformio project 

8# ---- (C) 2014-2016 Ivan Kravets <me@ikravets.com> 

9# ---- License Apache v2 

10"""Helper functions for apio scons plugins.""" 

11 

12from glob import glob 

13import sys 

14import os 

15import re 

16import subprocess 

17from dataclasses import dataclass 

18from pathlib import Path 

19from typing import List, Optional, Union 

20from rich.table import Table 

21from rich import box 

22from SCons import Scanner 

23from SCons.Builder import Builder 

24from SCons.Action import FunctionAction, Action 

25from SCons.Node.FS import File 

26from SCons.Script.SConscript import SConsEnvironment 

27from SCons.Node import NodeList 

28from SCons.Node.Alias import Alias 

29from apio.scons.apio_env import ApioEnv 

30from apio.common.proto.apio_pb2 import SimParams, ApioTestParams 

31from apio.common.common_util import ( 

32 PROJECT_BUILD_PATH, 

33 has_testbench_name, 

34 is_source_file, 

35) 

36from apio.common.apio_console import cout, cerror, ctable 

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

38from apio.scons import gtkwave_util 

39from apio.common.build_report import BuildReport, read_build_report 

40 

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

42 

43 

44def map_params(params: Optional[List[Union[str, Path]]], fmt: str) -> str: 

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

46 of arguments. The functon does the following: 

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

48 2. Drops empty or white space only items. 

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

50 placeholder {}. 

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

52 

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

54 """ 

55 # None designates empty list. Avoiding the pylint non safe default 

56 # warning. 

57 if params is None: 

58 params = [] 

59 

60 # Convert params to stripped strings. 

61 params = [str(x).strip() for x in params] 

62 

63 # Drop the empty params and map the rest. 

64 mapped_params = [fmt.format(x) for x in params if x] 

65 

66 # Join using a single space. 

67 return " ".join(mapped_params) 

68 

69 

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

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

72 

73 env is the sconstruction environment. 

74 

75 file_ext is a string with the constrained file extension. 

76 E.g. ".pcf" for ice40. 

77 

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

79 """ 

80 

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

82 user_specified = apio_env.params.apio_env_params.constraint_file 

83 

84 if user_specified: 

85 path = Path(user_specified) 

86 # -- Path should be relative. 

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

88 cerror(f"Constraint file path is not relative: {user_specified}") 

89 sys.exit(1) 

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

91 if path.suffix != file_ext: 

92 cerror( 

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

94 f"{user_specified}." 

95 ) 

96 sys.exit(1) 

97 # -- File should not be under _build 

98 if PROJECT_BUILD_PATH in path.parents: 

99 cerror( 

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

101 f"{user_specified}." 

102 ) 

103 sys.exit(1) 

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

105 # -- project and coming back. 

106 for part in path.parts: 

107 if part == "..": 

108 cerror( 

109 f"Constraint file path should not contain '..': " 

110 f"{user_specified}." 

111 ) 

112 sys.exit(1) 

113 

114 # -- Constrain file looks good. 

115 return user_specified 

116 

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

118 # -- in the project tree. 

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

120 

121 # -- Exclude files that are under _build 

122 filtered_files: List[str] = [ 

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

124 ] 

125 

126 # -- Handle by file count. 

127 n = len(filtered_files) 

128 

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

130 if n == 0: 

131 cerror(f"No constraint file '*{file_ext}' found.") 

132 sys.exit(1) 

133 

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

135 if n == 1: 

136 result = str(filtered_files[0]) 

137 return result 

138 

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

140 cerror( 

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

142 "in the project tree, which one to use?" 

143 ) 

144 cout( 

145 "Use the apio.ini constraint-file option to specify the desired file.", 

146 style=INFO, 

147 ) 

148 sys.exit(1) 

149 

150 

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

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

153 files for dependencies. 

154 """ 

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

156 # Example: 

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

158 # Captures: 'v771499.list' 

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

160 

161 # A regex to match a verilog include directive. 

162 # Example 

163 # Text: `include "apio_testing.vh" 

164 # Capture: 'apio_testing.vh' 

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

166 

167 # A regex for inclusion via $readmemh() 

168 # Example 

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

170 # Capture: 'my_data.hex' 

171 readmemh_reference_re = re.compile( 

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

173 ) 

174 

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

176 # -- changed. 

177 core_dependencies = [ 

178 "apio.ini", 

179 "boards.jsonc", 

180 "fpgas.jsonc", 

181 "programmers.jsonc", 

182 ] 

183 

184 def verilog_src_scanner_func( 

185 file_node: File, env: SConsEnvironment, ignored_path 

186 ) -> List[str]: 

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

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

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

190 anyway all the source files in the project. 

191 

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

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

194 commented out code to break scons dependencies. 

195 """ 

196 _ = env # Unused 

197 

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

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

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

201 cerror(f"'{file_node.name}' is not a source file.") 

202 sys.exit(1) 

203 

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

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

206 # file is in the project root. 

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

208 

209 # Prepare an empty set of dependencies. 

210 candidates_raw_set = set() 

211 

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

213 file_content = file_node.get_text_contents() 

214 

215 # Get verilog includes references. 

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

217 

218 # Get $readmemh() function references. 

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

220 

221 # Get IceStudio references. 

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

223 

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

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

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

227 # rebuilds. 

228 candidates_set = candidates_raw_set.copy() 

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

230 # to the project dir. 

231 if file_dir != ".": 

232 for raw_candidate in candidates_raw_set: 

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

234 candidates_set.add(candidate) 

235 

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

237 # root. 

238 candidates_set.update(core_dependencies) 

239 

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

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

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

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

244 dependencies = [] 

245 for dependency in candidates_set: 

246 if Path(dependency).exists(): 

247 dependencies.append(dependency) 

248 elif apio_env.is_debug(1): 248 ↛ 249line 248 didn't jump to line 249 because the condition on line 248 was never true

249 cout( 

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

251 "dropping." 

252 ) 

253 

254 # Sort the strings for determinism. 

255 dependencies = sorted(list(dependencies)) 

256 

257 # Debug info. 

258 if apio_env.is_debug(1): 258 ↛ 259line 258 didn't jump to line 259 because the condition on line 258 was never true

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

260 for dependency in dependencies: 

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

262 

263 # All done 

264 return apio_env.scons_env.File( 

265 dependencies 

266 ) # pyright: ignore[reportReturnType] 

267 

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

269 

270 

271def verilator_lint_action( 

272 apio_env: ApioEnv, 

273 *, 

274 extra_params: List[str] | None = None, 

275 lib_dirs: List[Path] | None = None, 

276 lib_files: List[Path] | None = None, 

277) -> List[FunctionAction | str]: 

278 # -> List[ 

279 # Callable[ 

280 # [ 

281 # List[File], 

282 # List[Alias], 

283 # SConsEnvironment, 

284 # ], 

285 # None, 

286 # ] 

287 # | str, 

288 # ]: 

289 """Construct an verilator scons action. 

290 * extra_params: Optional additional arguments. 

291 * libs_dirs: Optional directories for include search. 

292 * lib_files: Optional additional files to include. 

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

294 and a string command. 

295 """ 

296 

297 # -- Sanity checks 

298 assert apio_env.targeting_one_of("lint") 

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

300 

301 # -- Keep short references. 

302 params = apio_env.params 

303 lint_params = params.target.lint 

304 

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

306 lint_whole_project = not lint_params.file_names 

307 

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

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

310 using_vlt = lint_whole_project and (not lint_params.novlt) 

311 

312 # -- Determine the top module. 

313 if lint_params.top_module: 

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

315 top_module = lint_params.top_module 

316 elif lint_whole_project: 

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

318 top_module = params.apio_env_params.top_module 

319 else: 

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

321 top_module = None 

322 

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

324 # -- Construct the action 

325 action = ( 

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

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

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

329 ).format( 

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

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

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

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

334 get_define_flags(apio_env), 

335 map_params(extra_params, "{}"), # pyright: ignore[reportArgumentType] 

336 ( 

337 map_params( 

338 lib_dirs, '-I"{}"' # pyright: ignore[reportArgumentType] 

339 ) 

340 if lint_whole_project 

341 else "" 

342 ), 

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

344 ( 

345 map_params( 

346 lib_files, '"{}"' # pyright: ignore[reportArgumentType] 

347 ) 

348 if lint_whole_project 

349 else "" 

350 ), 

351 ) 

352 

353 # pyright: ignore[reportReturnType] 

354 return [ 

355 source_files_issue_scanner_action(), 

356 str(action), 

357 ] 

358 

359 

360@dataclass(frozen=True) 

361class TestbenchInfo: 

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

363 commands.""" 

364 

365 testbench_path: str # The relative testbench file path. 

366 build_testbench_name: str # testbench_name prefixed by build dir. 

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

368 

369 @property 

370 def testbench_name(self) -> str: 

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

372 return basename(self.testbench_path) 

373 

374 

375def detached_action( 

376 api_env: ApioEnv, cmd: List[str] 

377) -> Action: # pyright: ignore[reportGeneralTypeIssues] 

378 """ 

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

380 (non blocking) mode. 

381 """ 

382 

383 def action_func( 

384 target: List[Alias], source: List[File], env: SConsEnvironment 

385 ): 

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

387 

388 # -- Make the linter happy 

389 # pylint: disable=consider-using-with 

390 _ = (target, source, env) 

391 

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

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

394 # -- commands. 

395 

396 # -- Handle the case of Window. 

397 if api_env.is_windows: 

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

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

400 creationflags = detached_flag | group_flag 

401 

402 subprocess.Popen( 

403 cmd, 

404 creationflags=creationflags, 

405 stdout=subprocess.DEVNULL, 

406 stderr=subprocess.DEVNULL, 

407 close_fds=True, 

408 shell=False, 

409 ) 

410 return 0 

411 

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

413 subprocess.Popen( 

414 cmd, 

415 stdout=subprocess.DEVNULL, 

416 stderr=subprocess.DEVNULL, 

417 close_fds=True, 

418 start_new_session=True, 

419 shell=False, 

420 ) 

421 return 0 

422 

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

424 cmd_str: str = subprocess.list2cmdline(cmd) 

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

426 

427 # -- Create the action and return 

428 action = Action(action_func, display_str) 

429 return action 

430 

431 

432def gtkwave_target( 

433 apio_env: ApioEnv, 

434 target_name: str, # always 'sim' 

435 vcd_file_target: NodeList, 

436 testbench_info: TestbenchInfo, 

437 sim_params: SimParams, 

438 gtkwave_extra_options: Optional[List[str]], 

439) -> List[Alias]: 

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

441 vcd_file_target is the simulator target that generated the vcd file 

442 with the signals. Returns the new targets. 

443 """ 

444 

445 # pylint: disable=too-many-arguments 

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

447 

448 # -- Construct the list of actions. 

449 actions = [] 

450 

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

452 # -- signals are shown by default. 

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

454 vcd_path = str(vcd_file_target[0]) 

455 

456 def create_default_gtkw_file( 

457 target: List[Alias], source: List[File], env: SConsEnvironment 

458 ): 

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

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

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

462 gtkwave_util.create_gtkwave_file( 

463 testbench_info.testbench_path, vcd_path, gtkw_path 

464 ) 

465 

466 if gtkwave_util.is_user_gtkw_file(gtkw_path): 

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

468 else: 

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

470 

471 # -- Skip or execute gtkwave. 

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

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

474 # -- of the echo command itself. 

475 actions.append( 

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

477 ) 

478 

479 else: 

480 # -- Normal case, invoking gtkwave. 

481 

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

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

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

485 # -- The time penalty is negligible. 

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

487 # -- environment.bat script. 

488 if apio_env.is_windows: 

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

490 

491 # -- The actual wave viewer command. 

492 gtkwave_cmd = ["gtkwave"] 

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

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

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

496 gtkwave_cmd.append("--rcvar=splash_disable on") 

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

498 if gtkwave_extra_options: 

499 gtkwave_cmd.extend(gtkwave_extra_options) 

500 gtkwave_cmd.extend([vcd_path, gtkw_path]) 

501 

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

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

504 if sim_params.detach_gtkwave: 

505 gtkwave_action = detached_action(apio_env, gtkwave_cmd) 

506 else: 

507 gtkwave_action = subprocess.list2cmdline(gtkwave_cmd) 

508 

509 actions.append(gtkwave_action) 

510 

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

512 target = apio_env.alias( 

513 target_name, 

514 source=vcd_file_target, 

515 action=actions, 

516 always_build=True, 

517 ) 

518 

519 return target 

520 

521 

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

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

524 and exit.""" 

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

526 cerror(f"'{testbench}' is not a valid testbench file name.") 

527 cout(TESTBENCH_HINT, style=INFO) 

528 sys.exit(1) 

529 

530 

531def get_apio_sim_testbench_info( 

532 apio_env: ApioEnv, 

533 sim_params: SimParams, 

534 synth_srcs: List[str], 

535 test_srcs: List[str], 

536) -> TestbenchInfo: 

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

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

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

540 returned by get_project_source_files().""" 

541 

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

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

544 if sim_params.testbench_path: 

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

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

547 check_valid_testbench_name(sim_params.testbench_path) 

548 testbench = sim_params.testbench_path 

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

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

551 # -- were found in the project. 

552 cerror("No testbench files found in the project.") 

553 cout(TESTBENCH_HINT, style=INFO) 

554 sys.exit(1) 

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

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

557 # -- one in the project. 

558 testbench = test_srcs[0] 

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

560 else: 

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

562 # -- testbench files in the project. 

563 cerror("Multiple testbench files found in the project.") 

564 cout( 

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

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

567 style=INFO, 

568 ) 

569 sys.exit(1) 

570 

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

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

573 

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

575 # -- testbench file. 

576 testbench_name = basename(testbench) 

577 build_testbench_name = str(apio_env.env_build_path / testbench_name) 

578 srcs = synth_srcs + [testbench] 

579 return TestbenchInfo(testbench, build_testbench_name, srcs) 

580 

581 

582def get_apio_test_testbenches_infos( 

583 apio_env: ApioEnv, 

584 test_params: ApioTestParams, 

585 synth_srcs: List[str], 

586 test_srcs: list[str], 

587) -> List[TestbenchInfo]: 

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

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

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

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

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

593 # List of testbenches to be tested. 

594 

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

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

597 if test_params.testbench_path: 

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

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

600 check_valid_testbench_name(test_params.testbench_path) 

601 testbenches = [test_params.testbench_path] 

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

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

604 # -- testbench files in the project. 

605 cerror("No testbench files found in the project.") 

606 cout(TESTBENCH_HINT, style=INFO) 

607 sys.exit(1) 

608 elif test_params.default_option: 

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

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

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

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

613 testbenches = [test_srcs[0]] 

614 else: 

615 cerror("Multiple testbench files found in the project.") 

616 cout( 

617 "To test only a single testbench, replace --default with the " 

618 + "testbench", 

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

620 + "option in apio.ini.", 

621 style=INFO, 

622 ) 

623 sys.exit(1) 

624 else: 

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

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

627 testbenches = test_srcs 

628 

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

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

631 

632 # Construct a config for each testbench. 

633 configs = [] 

634 for tb in testbenches: 

635 testbench_name = basename(tb) 

636 build_testbench_name = str(apio_env.env_build_path / testbench_name) 

637 srcs = synth_srcs + [tb] 

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

639 

640 return configs 

641 

642 

643def announce_testbench_action() -> FunctionAction: 

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

645 

646 def announce_testbench( 

647 target: List[Alias], 

648 source: List[File], 

649 env: SConsEnvironment, 

650 ): 

651 """The action function.""" 

652 _ = (target, env) # Unused 

653 

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

655 testbenches = [ 

656 file 

657 for file in source 

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

659 ] 

660 assert len(testbenches) == 1, testbenches 

661 

662 # -- Announce it. 

663 cout() 

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

665 

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

667 return Action( 

668 announce_testbench, # pyright: ignore[reportReturnType] 

669 strfunction=None, 

670 ) 

671 

672 

673def source_files_issue_scanner_action() -> FunctionAction: 

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

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

676 

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

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

679 

680 def report_source_files_issues( 

681 target: List[Alias], 

682 source: List[File], 

683 env: SConsEnvironment, 

684 ): 

685 """The scanner function.""" 

686 

687 _ = (target, env) # Unused 

688 

689 for file in source: 

690 

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

692 # -- otherwise. 

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

694 file.name 

695 ): 

696 continue 

697 

698 # -- Read the testbench file text. 

699 file_text = file.get_text_contents() 

700 

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

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

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

704 cerror( 

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

706 ) 

707 cout( 

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

709 "Let Apio configure automatically the proper locations of " 

710 + "the dump files.", 

711 style=INFO, 

712 ) 

713 sys.exit(1) 

714 

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

716 # -- ourselves in report_source_files_issues. 

717 return Action( 

718 report_source_files_issues, # pyright: ignore[reportReturnType] 

719 strfunction=None, 

720 ) 

721 

722 

723def _print_pnr_report( 

724 build_report: BuildReport, 

725 report_all: bool, 

726 verbose: bool, 

727) -> None: 

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

729 

730 # -- Determine table title. 

731 title = "All FPGA resources" if report_all else "Used FPGA Resource" 

732 

733 # -- Utilization table 

734 table = Table( 

735 show_header=True, 

736 show_lines=False, 

737 box=box.SQUARE, 

738 border_style=BORDER, 

739 title=title, 

740 title_justify="left", 

741 padding=(0, 2), 

742 ) 

743 

744 # -- Add columns. 

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

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

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

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

749 

750 # -- Add rows 

751 skipped_resources = 0 

752 for res in build_report.resources: 

753 # -- By default we skip unused resources 

754 if not res.used and not report_all: 

755 skipped_resources += 1 

756 continue 

757 

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

759 available_str = f"{res.available} " 

760 percents = int(100 * res.used / res.available) 

761 percents_str = f"{percents}% " if res.used else "" 

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

763 table.add_row( 

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

765 ) 

766 

767 # -- Render the utilization table table 

768 cout() 

769 ctable(table) 

770 

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

772 if len(build_report.clocks) > 0: 

773 

774 table = Table( 

775 show_header=True, 

776 show_lines=True, 

777 box=box.SQUARE, 

778 border_style=BORDER, 

779 title="Clock Information", 

780 title_justify="left", 

781 padding=(0, 2), 

782 ) 

783 

784 # -- Add columns 

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

786 table.add_column( 

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

788 ) 

789 

790 # -- Add rows. 

791 for clk in build_report.clocks: 

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

793 

794 # -- Render the clocks table 

795 cout() 

796 ctable(table) 

797 

798 # -- Print hints. 

799 cout("") 

800 if skipped_resources: 800 ↛ 805line 800 didn't jump to line 805 because the condition on line 800 was always true

801 cout( 

802 "Use --all to report also unused resources.", 

803 style=INFO, 

804 ) 

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

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

807 if not verbose: 807 ↛ exitline 807 didn't return from function '_print_pnr_report' because the condition on line 807 was always true

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

809 

810 

811def report_action(report_all: bool, verbose: bool) -> FunctionAction: 

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

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

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

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

816 

817 def print_pnr_report( 

818 target: List[Alias], 

819 source: List[File], 

820 env: SConsEnvironment, 

821 ): 

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

823 friendly way.""" 

824 _ = (target, env) # Unused 

825 pnr_json_file: File = source[0] 

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

827 build_report: BuildReport = read_build_report(pnr_json_path) 

828 _print_pnr_report(build_report, report_all, verbose) 

829 

830 return Action( 

831 print_pnr_report, # pyright: ignore[reportReturnType] 

832 "Formatting pnr report.", 

833 ) 

834 

835 

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

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

838 arg.""" 

839 

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

841 params = apio_env.params 

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

843 

844 # Get the programer command template arg. 

845 programmer_cmd = params.target.upload.programmer_cmd 

846 assert programmer_cmd, params 

847 

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

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

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

851 

852 return programmer_cmd 

853 

854 

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

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

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

858 flags: List[str] = [] 

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

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

861 

862 return " ".join(flags) 

863 

864 

865def iverilog_action( 

866 apio_env: ApioEnv, 

867 *, 

868 verbose: bool, 

869 vcd_output_name: str, 

870 is_interactive: bool, 

871 extra_params: List[str] | None = None, 

872 lib_dirs: List[Path] | None = None, 

873 lib_files: List[Path] | None = None, 

874) -> str: 

875 """Construct an iverilog scons action string. 

876 * env: Rhe scons environment. 

877 * verbose: IVerilog will show extra info. 

878 * vcd_output_name: Value for the macro VCD_OUTPUT. 

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

880 * extra_params: Optional list of additional IVerilog params. 

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

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

883 * 

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

885 """ 

886 

887 # pylint: disable=too-many-arguments 

888 

889 # Escaping for windows. '\' -> '\\' 

890 escaped_vcd_output_name = vcd_output_name.replace("\\", "\\\\") 

891 

892 # -- Construct the action string. 

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

894 action = ( 

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

896 ).format( 

897 "-v" if verbose else "", 

898 f"-DVCD_OUTPUT={escaped_vcd_output_name}", 

899 get_define_flags(apio_env), 

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

901 map_params(extra_params, "{}"), # pyright: ignore[reportArgumentType] 

902 map_params(lib_dirs, '-I"{}"'), # pyright: ignore[reportArgumentType] 

903 map_params(lib_files, '"{}"'), # pyright: ignore[reportArgumentType] 

904 ) 

905 

906 return action 

907 

908 

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

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

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

912 return result 

913 

914 

915def make_verilator_config_builder( 

916 lib_path: Path, rules_to_supress: List[str] 

917) -> Builder: # pyright: ignore[reportGeneralTypeIssues] 

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

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

920 Rules_to_supress is a list of Verilator rules that should be supressed 

921 for the given lib_path. 

922 """ 

923 assert isinstance(lib_path, Path), lib_path 

924 

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

926 glob_path = str(lib_path / "*") 

927 

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

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

930 

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

932 # -- encountered in all the architectures. 

933 lines = ["`verilator_config"] 

934 for rule in rules_to_supress: 

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

936 

937 # -- Join the lines into text. 

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

939 

940 def verilator_config_func(target, source, env): 

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

942 _ = (source, env) # Unused 

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

944 target_file.write(text) 

945 return 0 

946 

947 return Builder( 

948 action=Action( 

949 verilator_config_func, "Creating verilator config file." 

950 ), 

951 suffix=".vlt", 

952 )