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

307 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-25 02:31 +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 

12 

13from glob import glob 

14import sys 

15import os 

16import re 

17import json 

18import subprocess 

19from dataclasses import dataclass 

20from pathlib import Path 

21from typing import List, Dict, Optional, Union, Callable 

22from rich.table import Table 

23from rich import box 

24from SCons import Scanner 

25from SCons.Builder import Builder 

26from SCons.Action import FunctionAction, Action 

27from SCons.Node.FS import File 

28from SCons.Script.SConscript import SConsEnvironment 

29from SCons.Node import NodeList 

30from SCons.Node.Alias import Alias 

31from apio.scons.apio_env import ApioEnv 

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

33from apio.common.common_util import ( 

34 PROJECT_BUILD_PATH, 

35 has_testbench_name, 

36 is_source_file, 

37) 

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

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

40from apio.scons import gtkwave_util 

41 

42 

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

44 

45 

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

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

48 of arguments. The functon does the following: 

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

50 2. Drops empty or white space only items. 

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

52 placeholder {}. 

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

54 

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

56 """ 

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

58 # warning. 

59 if params is None: 

60 params = [] 

61 

62 # Convert params to stripped strings. 

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

64 

65 # Drop the empty params and map the rest. 

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

67 

68 # Join using a single space. 

69 return " ".join(mapped_params) 

70 

71 

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

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

74 

75 env is the sconstruction environment. 

76 

77 file_ext is a string with the constrained file extension. 

78 E.g. ".pcf" for ice40. 

79 

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

81 """ 

82 

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

84 user_specified = apio_env.params.apio_env_params.constraint_file 

85 

86 if user_specified: 

87 path = Path(user_specified) 

88 # -- Path should be relative. 

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

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

91 sys.exit(1) 

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

93 if path.suffix != file_ext: 

94 cerror( 

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

96 f"{user_specified}." 

97 ) 

98 sys.exit(1) 

99 # -- File should not be under _build 

100 if PROJECT_BUILD_PATH in path.parents: 

101 cerror( 

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

103 f"{user_specified}." 

104 ) 

105 sys.exit(1) 

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

107 # -- project and coming back. 

108 for part in path.parts: 

109 if part == "..": 

110 cerror( 

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

112 f"{user_specified}." 

113 ) 

114 sys.exit(1) 

115 

116 # -- Constrain file looks good. 

117 return user_specified 

118 

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

120 # -- in the project tree. 

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

122 

123 # -- Exclude files that are under _build 

124 filtered_files: List[str] = [ 

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

126 ] 

127 

128 # -- Handle by file count. 

129 n = len(filtered_files) 

130 

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

132 if n == 0: 

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

134 sys.exit(1) 

135 

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

137 if n == 1: 

138 result = str(filtered_files[0]) 

139 return result 

140 

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

142 cerror( 

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

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

145 ) 

146 cout( 

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

148 style=INFO, 

149 ) 

150 sys.exit(1) 

151 

152 

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

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

155 files for dependencies. 

156 """ 

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

158 # Example: 

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

160 # Captures: 'v771499.list' 

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

162 

163 # A regex to match a verilog include directive. 

164 # Example 

165 # Text: `include "apio_testing.vh" 

166 # Capture: 'apio_testing.vh' 

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

168 

169 # A regex for inclusion via $readmemh() 

170 # Example 

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

172 # Capture: 'my_data.hex' 

173 readmemh_reference_re = re.compile( 

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

175 ) 

176 

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

178 # -- changed. 

179 core_dependencies = [ 

180 "apio.ini", 

181 "boards.jsonc", 

182 "fpgas.jsonc", 

183 "programmers.jsonc", 

184 ] 

185 

186 def verilog_src_scanner_func( 

187 file_node: File, env: SConsEnvironment, ignored_path 

188 ) -> List[str]: 

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

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

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

192 anyway all the source files in the project. 

193 

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

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

196 commented out code to break scons dependencies. 

197 """ 

198 _ = env # Unused 

199 

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

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

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

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

204 sys.exit(1) 

205 

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

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

208 # file is in the project root. 

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

210 

211 # Prepare an empty set of dependencies. 

212 candidates_raw_set = set() 

213 

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

215 file_content = file_node.get_text_contents() 

216 

217 # Get verilog includes references. 

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

219 

220 # Get $readmemh() function references. 

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

222 

223 # Get IceStudio references. 

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

225 

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

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

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

229 # rebuilds. 

230 candidates_set = candidates_raw_set.copy() 

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

232 # to the project dir. 

233 if file_dir != ".": 

234 for raw_candidate in candidates_raw_set: 

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

236 candidates_set.add(candidate) 

237 

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

239 # root. 

240 candidates_set.update(core_dependencies) 

241 

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

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

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

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

246 dependencies = [] 

247 for dependency in candidates_set: 

248 if Path(dependency).exists(): 

249 dependencies.append(dependency) 

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

251 cout( 

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

253 "dropping." 

254 ) 

255 

256 # Sort the strings for determinism. 

257 dependencies = sorted(list(dependencies)) 

258 

259 # Debug info. 

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

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

262 for dependency in dependencies: 

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

264 

265 # All done 

266 return apio_env.scons_env.File(dependencies) 

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, 

275 lib_dirs: List[Path] = None, 

276 lib_files: List[Path] = None, 

277) -> List[ 

278 Callable[ 

279 [ 

280 List[File], 

281 List[Alias], 

282 SConsEnvironment, 

283 ], 

284 None, 

285 ] 

286 | str, 

287]: 

288 """Construct an verilator scons action. 

289 * extra_params: Optional additional arguments. 

290 * libs_dirs: Optional directories for include search. 

291 * lib_files: Optional additional files to include. 

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

293 and a string command. 

294 """ 

295 

296 # -- Sanity checks 

297 assert apio_env.targeting_one_of("lint") 

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

299 

300 # -- Keep short references. 

301 params = apio_env.params 

302 lint_params = params.target.lint 

303 

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

305 lint_whole_project = not lint_params.file_names 

306 

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

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

309 using_vlt = lint_whole_project and (not lint_params.novlt) 

310 

311 # -- Determine the top module. 

312 if lint_params.top_module: 

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

314 top_module = lint_params.top_module 

315 elif lint_whole_project: 

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

317 top_module = params.apio_env_params.top_module 

318 else: 

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

320 top_module = None 

321 

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

323 # -- Construct the action 

324 action = ( 

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

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

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

328 ).format( 

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

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

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

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

333 get_define_flags(apio_env), 

334 map_params(extra_params, "{}"), 

335 map_params(lib_dirs, '-I"{}"') if lint_whole_project else "", 

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

337 map_params(lib_files, '"{}"') if lint_whole_project else "", 

338 ) 

339 

340 return [source_files_issue_scanner_action(), action] 

341 

342 

343@dataclass(frozen=True) 

344class TestbenchInfo: 

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

346 commands.""" 

347 

348 testbench_path: str # The relative testbench file path. 

349 build_testbench_name: str # testbench_name prefixed by build dir. 

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

351 

352 @property 

353 def testbench_name(self) -> str: 

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

355 return basename(self.testbench_path) 

356 

357 

358def detached_action(api_env: ApioEnv, cmd: List[str]) -> Action: 

359 """ 

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

361 (non blocking) mode. 

362 """ 

363 

364 def action_func( 

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

366 ): 

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

368 

369 # -- Make the linter happy 

370 # pylint: disable=consider-using-with 

371 _ = (target, source, env) 

372 

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

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

375 # -- commands. 

376 

377 # -- Handle the case of Window. 

378 if api_env.is_windows: 

379 creationflags = ( 

380 subprocess.DETACHED_PROCESS 

381 | subprocess.CREATE_NEW_PROCESS_GROUP 

382 ) 

383 subprocess.Popen( 

384 cmd, 

385 creationflags=creationflags, 

386 stdout=subprocess.DEVNULL, 

387 stderr=subprocess.DEVNULL, 

388 close_fds=True, 

389 shell=False, 

390 ) 

391 return 0 

392 

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

394 subprocess.Popen( 

395 cmd, 

396 stdout=subprocess.DEVNULL, 

397 stderr=subprocess.DEVNULL, 

398 close_fds=True, 

399 start_new_session=True, 

400 shell=False, 

401 ) 

402 return 0 

403 

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

405 cmd_str: str = subprocess.list2cmdline(cmd) 

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

407 

408 # -- Create the action and return. 

409 action = Action(action_func, display_str) 

410 return action 

411 

412 

413def gtkwave_target( 

414 apio_env: ApioEnv, 

415 target_name: str, # always 'sim' 

416 vcd_file_target: NodeList, 

417 testbench_info: TestbenchInfo, 

418 sim_params: SimParams, 

419 gtkwave_extra_options: Optional[List[str]], 

420) -> List[Alias]: 

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

422 vcd_file_target is the simulator target that generated the vcd file 

423 with the signals. Returns the new targets. 

424 """ 

425 

426 # pylint: disable=too-many-arguments 

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

428 

429 # -- Construct the list of actions. 

430 actions = [] 

431 

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

433 # -- signals are shown by default. 

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

435 vcd_path = str(vcd_file_target[0]) 

436 

437 def create_default_gtkw_file( 

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

439 ): 

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

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

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

443 gtkwave_util.create_gtkwave_file( 

444 testbench_info.testbench_path, vcd_path, gtkw_path 

445 ) 

446 

447 if gtkwave_util.is_user_gtkw_file(gtkw_path): 

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

449 else: 

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

451 

452 # -- Skip or execute gtkwave. 

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

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

455 # -- of the echo command itself. 

456 actions.append( 

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

458 ) 

459 

460 else: 

461 # -- Normal case, invoking gtkwave. 

462 

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

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

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

466 # -- The time penalty is negligible. 

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

468 # -- environment.bat script. 

469 if apio_env.is_windows: 

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

471 

472 # -- The actual wave viewer command. 

473 gtkwave_cmd = ["gtkwave"] 

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

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

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

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

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

479 if gtkwave_extra_options: 

480 gtkwave_cmd.extend(gtkwave_extra_options) 

481 gtkwave_cmd.extend([vcd_path, gtkw_path]) 

482 

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

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

485 if sim_params.detach_gtkwave: 

486 gtkwave_action = detached_action(apio_env, gtkwave_cmd) 

487 else: 

488 gtkwave_action = subprocess.list2cmdline(gtkwave_cmd) 

489 

490 actions.append(gtkwave_action) 

491 

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

493 target = apio_env.alias( 

494 target_name, 

495 source=vcd_file_target, 

496 action=actions, 

497 always_build=True, 

498 ) 

499 

500 return target 

501 

502 

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

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

505 and exit.""" 

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

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

508 cout(TESTBENCH_HINT, style=INFO) 

509 sys.exit(1) 

510 

511 

512def get_apio_sim_testbench_info( 

513 apio_env: ApioEnv, 

514 sim_params: SimParams, 

515 synth_srcs: List[str], 

516 test_srcs: List[str], 

517) -> TestbenchInfo: 

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

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

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

521 returned by get_project_source_files().""" 

522 

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

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

525 if sim_params.testbench_path: 

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

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

528 check_valid_testbench_name(sim_params.testbench_path) 

529 testbench = sim_params.testbench_path 

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

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

532 # -- were found in the project. 

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

534 cout(TESTBENCH_HINT, style=INFO) 

535 sys.exit(1) 

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

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

538 # -- one in the project. 

539 testbench = test_srcs[0] 

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

541 else: 

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

543 # -- testbench files in the project. 

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

545 cout( 

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

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

548 style=INFO, 

549 ) 

550 sys.exit(1) 

551 

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

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

554 

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

556 # -- testbench file. 

557 testbench_name = basename(testbench) 

558 build_testbench_name = str(apio_env.env_build_path / testbench_name) 

559 srcs = synth_srcs + [testbench] 

560 return TestbenchInfo(testbench, build_testbench_name, srcs) 

561 

562 

563def get_apio_test_testbenches_infos( 

564 apio_env: ApioEnv, 

565 test_params: ApioTestParams, 

566 synth_srcs: List[str], 

567 test_srcs: list[str], 

568) -> List[TestbenchInfo]: 

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

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

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

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

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

574 # List of testbenches to be tested. 

575 

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

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

578 if test_params.testbench_path: 

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

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

581 check_valid_testbench_name(test_params.testbench_path) 

582 testbenches = [test_params.testbench_path] 

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

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

585 # -- testbench files in the project. 

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

587 cout(TESTBENCH_HINT, style=INFO) 

588 sys.exit(1) 

589 elif test_params.default_option: 

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

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

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

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

594 testbenches = [test_srcs[0]] 

595 else: 

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

597 cout( 

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

599 + "testbench", 

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

601 + "option in apio.ini.", 

602 style=INFO, 

603 ) 

604 sys.exit(1) 

605 else: 

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

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

608 testbenches = test_srcs 

609 

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

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

612 

613 # Construct a config for each testbench. 

614 configs = [] 

615 for tb in testbenches: 

616 testbench_name = basename(tb) 

617 build_testbench_name = str(apio_env.env_build_path / testbench_name) 

618 srcs = synth_srcs + [tb] 

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

620 

621 return configs 

622 

623 

624def announce_testbench_action() -> FunctionAction: 

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

626 

627 def announce_testbench( 

628 target: List[Alias], 

629 source: List[File], 

630 env: SConsEnvironment, 

631 ): 

632 """The action function.""" 

633 _ = (target, env) # Unused 

634 

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

636 testbenches = [ 

637 file 

638 for file in source 

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

640 ] 

641 assert len(testbenches) == 1, testbenches 

642 

643 # -- Announce it. 

644 cout() 

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

646 

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

648 return Action(announce_testbench, strfunction=None) 

649 

650 

651def source_files_issue_scanner_action() -> FunctionAction: 

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

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

654 

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

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

657 

658 def report_source_files_issues( 

659 target: List[Alias], 

660 source: List[File], 

661 env: SConsEnvironment, 

662 ): 

663 """The scanner function.""" 

664 

665 _ = (target, env) # Unused 

666 

667 for file in source: 

668 

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

670 # -- otherwise. 

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

672 file.name 

673 ): 

674 continue 

675 

676 # -- Read the testbench file text. 

677 file_text = file.get_text_contents() 

678 

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

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

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

682 cerror( 

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

684 ) 

685 cout( 

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

687 "Let Apio configure automatically the proper locations of " 

688 + "the dump files.", 

689 style=INFO, 

690 ) 

691 sys.exit(1) 

692 

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

694 # -- ourselves in report_source_files_issues. 

695 return Action(report_source_files_issues, strfunction=None) 

696 

697 

698def _print_pnr_utilization_report(report: Dict[str, any]): 

699 table = Table( 

700 show_header=True, 

701 show_lines=False, 

702 box=box.SQUARE, 

703 border_style=BORDER, 

704 title="FPGA Resource Utilization", 

705 title_justify="left", 

706 padding=(0, 2), 

707 ) 

708 

709 # -- Add columns. 

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

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

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

713 table.add_column("UTIL.", no_wrap=True, justify="right") 

714 

715 # -- Add rows 

716 utilization = report["utilization"] 

717 for resource, vals in utilization.items(): 

718 used = vals["used"] 

719 used_str = f"{used} " if used else "" 

720 available = vals["available"] 

721 available_str = f"{available} " 

722 percents = int(100 * used / available) 

723 percents_str = f"{percents}% " if used else "" 

724 style = EMPH3 if used > 0 else None 

725 table.add_row( 

726 resource, used_str, available_str, percents_str, style=style 

727 ) 

728 

729 # -- Render the table 

730 cout() 

731 ctable(table) 

732 

733 

734def _maybe_print_pnr_clocks_report( 

735 report: Dict[str, any], clk_name_index: int 

736) -> bool: 

737 clocks = report["fmax"] 

738 if len(clocks) == 0: 

739 return False 

740 

741 table = Table( 

742 show_header=True, 

743 show_lines=True, 

744 box=box.SQUARE, 

745 border_style=BORDER, 

746 title="Clock Information", 

747 title_justify="left", 

748 padding=(0, 2), 

749 ) 

750 

751 # -- Add columns 

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

753 table.add_column( 

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

755 ) 

756 

757 # -- Add rows. 

758 clocks = report["fmax"] 

759 for clk_net, vals in clocks.items(): 

760 # -- Extract clock name from the net name. 

761 clk_signal = clk_net.split("$")[clk_name_index] 

762 # -- Remove trailing '_'. Otherwise, on alhambra-ii/pll example, the 

763 # -- internal clock 'sys_clk' is reported as 'sys_clk_'. 

764 clk_signal = clk_signal.rstrip("_") 

765 # -- Extract speed 

766 max_mhz = vals["achieved"] 

767 # -- Add row. 

768 table.add_row(clk_signal, f"{max_mhz:.2f}") 

769 

770 # -- Render the table 

771 cout() 

772 ctable(table) 

773 return True 

774 

775 

776def _print_pnr_report( 

777 json_txt: str, 

778 clk_name_index: int, 

779 verbose: bool, 

780) -> None: 

781 """Accepts the text of the pnr json report and prints it in 

782 a user friendly way. Used by the 'apio report' command.""" 

783 # -- Parse the json text into a tree of dicts. 

784 report: Dict[str, any] = json.loads(json_txt) 

785 

786 # -- Print the utilization table. 

787 _print_pnr_utilization_report(report) 

788 

789 # -- Print the optional clocks table. 

790 clock_report_printed = _maybe_print_pnr_clocks_report( 

791 report, clk_name_index 

792 ) 

793 

794 # -- Print summary. 

795 cout("") 

796 if not clock_report_printed: 

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

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

799 cout("Run 'apio report --verbose' for more details.", style=INFO) 

800 

801 

802def report_action(clk_name_index: int, verbose: bool) -> FunctionAction: 

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

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

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

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

807 

808 def print_pnr_report( 

809 target: List[Alias], 

810 source: List[File], 

811 env: SConsEnvironment, 

812 ): 

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

814 friendly way.""" 

815 _ = (target, env) # Unused 

816 json_file: File = source[0] 

817 print(f"{str(json_file)=}") 

818 json_txt: str = json_file.get_text_contents() 

819 _print_pnr_report(json_txt, clk_name_index, verbose) 

820 

821 return Action(print_pnr_report, "Formatting pnr report.") 

822 

823 

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

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

826 arg.""" 

827 

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

829 params = apio_env.params 

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

831 

832 # Get the programer command template arg. 

833 programmer_cmd = params.target.upload.programmer_cmd 

834 assert programmer_cmd, params 

835 

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

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

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

839 

840 return programmer_cmd 

841 

842 

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

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

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

846 flags: List[str] = [] 

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

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

849 

850 return " ".join(flags) 

851 

852 

853def iverilog_action( 

854 apio_env: ApioEnv, 

855 *, 

856 verbose: bool, 

857 vcd_output_name: str, 

858 is_interactive: bool, 

859 extra_params: List[str] = None, 

860 lib_dirs: List[Path] = None, 

861 lib_files: List[Path] = None, 

862) -> str: 

863 """Construct an iverilog scons action string. 

864 * env: Rhe scons environment. 

865 * verbose: IVerilog will show extra info. 

866 * vcd_output_name: Value for the macro VCD_OUTPUT. 

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

868 * extra_params: Optional list of additional IVerilog params. 

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

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

871 * 

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

873 """ 

874 

875 # pylint: disable=too-many-arguments 

876 

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

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

879 

880 # -- Construct the action string. 

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

882 action = ( 

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

884 ).format( 

885 "-v" if verbose else "", 

886 f"-DVCD_OUTPUT={escaped_vcd_output_name}", 

887 get_define_flags(apio_env), 

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

889 map_params(extra_params, "{}"), 

890 map_params(lib_dirs, '-I"{}"'), 

891 map_params(lib_files, '"{}"'), 

892 ) 

893 

894 return action 

895 

896 

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

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

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

900 return result 

901 

902 

903def make_verilator_config_builder( 

904 lib_path: Path, rules_to_supress: List[str] 

905) -> Builder: 

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

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

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

909 for the given lib_path. 

910 """ 

911 assert isinstance(lib_path, Path), lib_path 

912 

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

914 glob_path = str(lib_path / "*") 

915 

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

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

918 

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

920 # -- encountered in all the architectures. 

921 lines = ["`verilator_config"] 

922 for rule in rules_to_supress: 

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

924 

925 # -- Join the lines into text. 

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

927 

928 def verilator_config_func(target, source, env): 

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

930 _ = (source, env) # Unused 

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

932 target_file.write(text) 

933 return 0 

934 

935 return Builder( 

936 action=Action( 

937 verilator_config_func, "Creating verilator config file." 

938 ), 

939 suffix=".vlt", 

940 )