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

283 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-23 03:53 +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 os 

14import re 

15import subprocess 

16from dataclasses import dataclass 

17from pathlib import Path 

18from rich.table import Table 

19from rich import box 

20from SCons import Scanner 

21from SCons.Builder import Builder 

22from SCons.Action import FunctionAction, Action 

23from SCons.Node.FS import File 

24from SCons.Script.SConscript import SConsEnvironment 

25from SCons.Node import NodeList 

26from SCons.Node.Alias import Alias 

27from apio.scons.apio_env import ApioEnv 

28from apio.common.proto.apio_scons_pb2 import SimParams, ApioTestParams 

29from apio.common.common_util import ( 

30 PROJECT_BUILD_PATH, 

31 has_testbench_name, 

32 is_source_file, 

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(api_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 # -- Handle the case of Window. 

388 if api_env.is_windows: 

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

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

391 creationflags = detached_flag | group_flag 

392 

393 subprocess.Popen( 

394 cmd, 

395 creationflags=creationflags, 

396 stdout=subprocess.DEVNULL, 

397 stderr=subprocess.DEVNULL, 

398 close_fds=True, 

399 shell=False, 

400 ) 

401 return 0 

402 

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

404 subprocess.Popen( 

405 cmd, 

406 stdout=subprocess.DEVNULL, 

407 stderr=subprocess.DEVNULL, 

408 close_fds=True, 

409 start_new_session=True, 

410 shell=False, 

411 ) 

412 return 0 

413 

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

415 cmd_str: str = subprocess.list2cmdline(cmd) 

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

417 

418 # -- Create the action and return 

419 action = Action(action_func, display_str) 

420 return action 

421 

422 

423def gtkwave_target( 

424 apio_env: ApioEnv, 

425 target_name: str, # always 'sim' 

426 vcd_file_target: NodeList, 

427 testbench_info: TestbenchInfo, 

428 sim_params: SimParams, 

429 gtkwave_extra_options: list[str] | None, 

430) -> list[Alias]: 

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

432 vcd_file_target is the simulator target that generated the vcd file 

433 with the signals. Returns the new targets. 

434 """ 

435 

436 # pylint: disable=too-many-arguments 

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

438 

439 # -- Construct the list of actions. 

440 actions = [] 

441 

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

443 # -- signals are shown by default. 

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

445 vcd_path = str(vcd_file_target[0]) 

446 

447 def create_default_gtkw_file( 

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

449 ): 

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

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

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

453 gtkwave_util.create_gtkwave_file( 

454 testbench_info.testbench_path, vcd_path, gtkw_path 

455 ) 

456 

457 if gtkwave_util.is_user_gtkw_file(gtkw_path): 

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

459 else: 

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

461 

462 # -- Skip or execute gtkwave. 

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

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

465 # -- of the echo command itself. 

466 actions.append( 

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

468 ) 

469 

470 else: 

471 # -- Normal case, invoking gtkwave. 

472 

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

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

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

476 # -- The time penalty is negligible. 

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

478 # -- environment.bat script. 

479 if apio_env.is_windows: 

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

481 

482 # -- The actual wave viewer command. 

483 gtkwave_cmd = ["gtkwave"] 

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

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

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

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

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

489 if gtkwave_extra_options: 

490 gtkwave_cmd.extend(gtkwave_extra_options) 

491 gtkwave_cmd.extend([vcd_path, gtkw_path]) 

492 

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

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

495 if sim_params.detach_gtkwave: 

496 gtkwave_action = detached_action(apio_env, gtkwave_cmd) 

497 else: 

498 gtkwave_action = subprocess.list2cmdline(gtkwave_cmd) 

499 

500 actions.append(gtkwave_action) 

501 

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

503 target = apio_env.add_alias( 

504 target_name, 

505 source=vcd_file_target, 

506 action=actions, 

507 always_build=True, 

508 ) 

509 

510 return target 

511 

512 

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

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

515 and exit.""" 

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

517 fatal_error( 

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

519 info=TESTBENCH_HINT, 

520 ) 

521 

522 

523def get_apio_sim_testbench_info( 

524 apio_env: ApioEnv, 

525 sim_params: SimParams, 

526 synth_srcs: list[str], 

527 test_srcs: list[str], 

528) -> TestbenchInfo: 

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

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

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

532 returned by get_project_source_files().""" 

533 

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

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

536 if sim_params.testbench_path: 

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

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

539 check_valid_testbench_name(sim_params.testbench_path) 

540 testbench = sim_params.testbench_path 

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

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

543 # -- were found in the project. 

544 fatal_error( 

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

546 info=TESTBENCH_HINT, 

547 ) 

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

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

550 # -- one in the project. 

551 testbench = test_srcs[0] 

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

553 else: 

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

555 # -- testbench files in the project. 

556 fatal_error( 

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

558 info=[ 

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

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

561 ], 

562 ) 

563 

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

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

566 

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

568 # -- testbench file. 

569 testbench_name = basename(testbench) 

570 build_testbench_name = str(apio_env.env_build_path / testbench_name) 

571 srcs = synth_srcs + [testbench] 

572 return TestbenchInfo(testbench, build_testbench_name, srcs) 

573 

574 

575def get_apio_test_testbenches_infos( 

576 apio_env: ApioEnv, 

577 test_params: ApioTestParams, 

578 synth_srcs: list[str], 

579 test_srcs: list[str], 

580) -> list[TestbenchInfo]: 

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

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

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

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

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

586 # List of testbenches to be tested. 

587 

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

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

590 if test_params.testbench_path: 

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

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

593 check_valid_testbench_name(test_params.testbench_path) 

594 testbenches = [test_params.testbench_path] 

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

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

597 # -- testbench files in the project. 

598 fatal_error( 

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

600 info=TESTBENCH_HINT, 

601 ) 

602 elif test_params.default_option: 

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

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

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

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

607 testbenches = [test_srcs[0]] 

608 else: 

609 fatal_error( 

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

611 info=[ 

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

613 + "with the testbench", 

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

615 + "option in apio.ini.", 

616 ], 

617 ) 

618 else: 

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

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

621 testbenches = test_srcs 

622 

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

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

625 

626 # Construct a config for each testbench. 

627 configs = [] 

628 for tb in testbenches: 

629 testbench_name = basename(tb) 

630 build_testbench_name = str(apio_env.env_build_path / testbench_name) 

631 srcs = synth_srcs + [tb] 

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

633 

634 return configs 

635 

636 

637def announce_testbench_action() -> FunctionAction: 

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

639 

640 def announce_testbench( 

641 target: list[Alias], 

642 source: list[File], 

643 env: SConsEnvironment, 

644 ): 

645 """The action function.""" 

646 _ = (target, env) # Unused 

647 

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

649 testbenches = [ 

650 file 

651 for file in source 

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

653 ] 

654 assert len(testbenches) == 1, testbenches 

655 

656 # -- Announce it. 

657 cout() 

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

659 

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

661 return Action( 

662 announce_testbench, 

663 strfunction=None, 

664 ) 

665 

666 

667def source_files_issue_scanner_action() -> FunctionAction: 

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

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

670 

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

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

673 

674 def report_source_files_issues( 

675 target: list[Alias], 

676 source: list[File], 

677 env: SConsEnvironment, 

678 ): 

679 """The scanner function.""" 

680 

681 _ = (target, env) # Unused 

682 

683 for file in source: 

684 

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

686 # -- otherwise. 

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

688 file.name 

689 ): 

690 continue 

691 

692 # -- Read the testbench file text. 

693 file_text = file.get_text_contents() 

694 

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

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

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

698 fatal_error( 

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

700 info=[ 

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

702 "Let Apio configure automatically the proper " 

703 + "locations of the dump files.", 

704 ], 

705 ) 

706 

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

708 # -- ourselves in report_source_files_issues. 

709 return Action( 

710 report_source_files_issues, 

711 strfunction=None, 

712 ) 

713 

714 

715def _print_pnr_report( 

716 build_report: BuildReport, 

717 verbose: bool, 

718) -> None: 

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

720 

721 # -- Determine table title. 

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

723 

724 # -- Utilization table 

725 table = Table( 

726 show_header=True, 

727 show_lines=False, 

728 box=box.SQUARE, 

729 border_style=BORDER, 

730 title=title, 

731 title_justify="left", 

732 padding=(0, 2), 

733 ) 

734 

735 # -- Add columns. 

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

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

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

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

740 

741 # -- Add rows 

742 for res in build_report.resources: 

743 # -- By default we skip unused resources 

744 if not res.used and not verbose: 

745 continue 

746 

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

748 available_str = f"{res.available} " 

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

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

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

752 table.add_row( 

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

754 ) 

755 

756 # -- Render the utilization table table 

757 cout() 

758 ctable(table) 

759 

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

761 if len(build_report.clocks) > 0: 

762 

763 table = Table( 

764 show_header=True, 

765 show_lines=True, 

766 box=box.SQUARE, 

767 border_style=BORDER, 

768 title="Clock Information", 

769 title_justify="left", 

770 padding=(0, 2), 

771 ) 

772 

773 # -- Add columns 

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

775 table.add_column( 

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

777 ) 

778 

779 # -- Add rows. 

780 for clk in build_report.clocks: 

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

782 

783 # -- Render the clocks table 

784 cout() 

785 ctable(table) 

786 

787 # -- Print hints. 

788 cout("") 

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

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

791 

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

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

794 

795 

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

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

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

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

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

801 

802 def print_pnr_report( 

803 target: list[Alias], 

804 source: list[File], 

805 env: SConsEnvironment, 

806 ): 

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

808 friendly way.""" 

809 _ = (target, env) # Unused 

810 pnr_json_file: File = source[0] 

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

812 build_report: BuildReport = read_build_report(pnr_json_path) 

813 _print_pnr_report(build_report, verbose) 

814 

815 return Action( 

816 print_pnr_report, 

817 "Formatting pnr report.", 

818 ) 

819 

820 

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

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

823 arg.""" 

824 

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

826 params = apio_env.params 

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

828 

829 # Get the programer command template arg. 

830 programmer_cmd = params.target.upload.programmer_cmd 

831 assert programmer_cmd, params 

832 

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

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

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

836 

837 return programmer_cmd 

838 

839 

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

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

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

843 flags: list[str] = [] 

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

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

846 

847 return " ".join(flags) 

848 

849 

850def iverilog_action( 

851 apio_env: ApioEnv, 

852 *, 

853 verbose: bool, 

854 is_interactive: bool, 

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

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

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

858) -> str: 

859 """Construct an iverilog scons action string. 

860 * env: Rhe scons environment. 

861 * verbose: IVerilog will show extra info. 

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

863 * extra_params: Optional list of additional IVerilog params. 

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

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

866 * 

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

868 """ 

869 

870 # pylint: disable=too-many-arguments 

871 

872 # -- Construct the action string. 

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

874 action = ( 

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

876 ).format( 

877 "-v" if verbose else "", 

878 get_define_flags(apio_env), 

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

880 map_str_params(extra_params, "{}"), 

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

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

883 ) 

884 

885 return action 

886 

887 

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

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

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

891 return result 

892 

893 

894def make_verilator_config_builder( 

895 lib_path: Path, rules_to_suppress: list[str] 

896) -> Builder: 

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

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

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

900 for the given lib_path. 

901 """ 

902 assert isinstance(lib_path, Path), lib_path 

903 

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

905 glob_path = str(lib_path / "*") 

906 

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

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

909 

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

911 # -- encountered in all the architectures. 

912 lines = ["`verilator_config"] 

913 for rule in rules_to_suppress: 

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

915 

916 # -- Join the lines into text. 

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

918 

919 def verilator_config_func(target, source, env): 

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

921 _ = (source, env) # Unused 

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

923 target_file.write(text) 

924 return 0 

925 

926 return Builder( 

927 action=Action( 

928 verilator_config_func, "Creating verilator config file." 

929 ), 

930 suffix=".vlt", 

931 )