Coverage for tsfpga/build_project_list.py: 94%

187 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-12-10 20:51 +0000

1# -------------------------------------------------------------------------------------------------- 

2# Copyright (c) Lukas Vik. All rights reserved. 

3# 

4# This file is part of the tsfpga project, a project platform for modern FPGA development. 

5# https://tsfpga.com 

6# https://github.com/tsfpga/tsfpga 

7# -------------------------------------------------------------------------------------------------- 

8 

9# Standard libraries 

10import fnmatch 

11import time 

12from pathlib import Path 

13from threading import Lock 

14from typing import TYPE_CHECKING, Any, Callable, Iterable, Optional, Union 

15 

16# Third party libraries 

17from vunit.color_printer import COLOR_PRINTER, NO_COLOR_PRINTER 

18from vunit.test.list import TestList 

19from vunit.test.report import TestReport, TestResult 

20from vunit.test.runner import TestRunner 

21 

22# First party libraries 

23from tsfpga.system_utils import create_directory, read_last_lines_of_file 

24 

25if TYPE_CHECKING: 

26 # Local folder libraries 

27 from .module_list import ModuleList 

28 from .vivado import build_result 

29 from .vivado.project import VivadoProject 

30 

31 

32class BuildProjectList: 

33 

34 """ 

35 Interface to handle a list of FPGA build projects. 

36 Enables building many projects in parallel. 

37 """ 

38 

39 def __init__( 

40 self, 

41 modules: "ModuleList", 

42 project_filters: list[str], 

43 include_netlist_not_top_builds: bool = False, 

44 no_color: bool = False, 

45 ): 

46 """ 

47 Arguments: 

48 modules: Module objects that can define build projects. 

49 project_filters: Project name filters. Can use wildcards (*). Leave empty for all. 

50 include_netlist_not_top_builds: Set True to get only netlist builds, 

51 instead of only top level builds. 

52 no_color: Disable color in printouts. 

53 """ 

54 self._modules = modules 

55 self._no_color = no_color 

56 

57 self.projects = list( 

58 self._iterate_projects( 

59 project_filters=project_filters, 

60 include_netlist_not_top_builds=include_netlist_not_top_builds, 

61 ) 

62 ) 

63 

64 if not self.projects: 

65 print(f"No projects matched this filter: {' '.join(project_filters)}") 

66 

67 def __str__(self) -> str: 

68 """ 

69 Returns a string with a description list of the projects. 

70 

71 Will print some information about each project (name, generics, part, ...) so can become 

72 long if there are many projects present. 

73 An alternative in that case would be :meth:`.get_short_str`. 

74 """ 

75 result = "\n".join([str(project) for project in self.projects]) 

76 result += "\n" 

77 result += "\n" 

78 result += f"Listed {len(self.projects)} builds" 

79 

80 return result 

81 

82 def get_short_str(self) -> str: 

83 """ 

84 Returns a short string with a description list of the projects. 

85 

86 This is an alternative function that is more compact than ``__str__``. 

87 """ 

88 result = "\n".join([project.name for project in self.projects]) 

89 result += "\n" 

90 result += f"Listed {len(self.projects)} builds" 

91 

92 return result 

93 

94 def create(self, projects_path: Path, num_parallel_builds: int, **kwargs: Any) -> bool: 

95 """ 

96 Create build project on disk for all the projects in the list. 

97 

98 Arguments: 

99 projects_path: The projects will be placed here. 

100 num_parallel_builds: The number of projects that will be created in parallel. 

101 kwargs: Other arguments as accepted by :meth:`.VivadoProject.create`. 

102 

103 .. Note:: 

104 Argument ``project_path`` can not be set, it is set by this class 

105 based on the ``project_paths`` argument to this function. 

106 

107 Return: 

108 True if everything went well. 

109 """ 

110 build_wrappers = [] 

111 for project in self.projects: 

112 build_wrapper = BuildProjectCreateWrapper(project, **kwargs) 

113 build_wrappers.append(build_wrapper) 

114 

115 return self._run_build_wrappers( 

116 projects_path=projects_path, 

117 build_wrappers=build_wrappers, 

118 num_parallel_builds=num_parallel_builds, 

119 ) 

120 

121 def create_unless_exists( 

122 self, projects_path: Path, num_parallel_builds: int, **kwargs: Any 

123 ) -> bool: 

124 """ 

125 Create build project for all the projects in the list, unless the project already 

126 exists. 

127 

128 Arguments: 

129 projects_path: The projects will be placed here. 

130 num_parallel_builds: The number of projects that will be created in parallel. 

131 kwargs: Other arguments as accepted by :meth:`.VivadoProject.create`. 

132 

133 .. Note:: 

134 Argument ``project_path`` can not be set, it is set by this class 

135 based on the ``project_paths`` argument to this function. 

136 

137 Return: 

138 True if everything went well. 

139 """ 

140 build_wrappers = [] 

141 for project in self.projects: 

142 if not (projects_path / project.name / "project").exists(): 

143 build_wrapper = BuildProjectCreateWrapper(project, **kwargs) 

144 build_wrappers.append(build_wrapper) 

145 

146 if not build_wrappers: 

147 # Return straight away if no projects need to be created. To avoid extra 

148 # "No tests were run!" printout from creation step that is very misleading. 

149 return True 

150 

151 return self._run_build_wrappers( 

152 projects_path=projects_path, 

153 build_wrappers=build_wrappers, 

154 num_parallel_builds=num_parallel_builds, 

155 ) 

156 

157 def build( 

158 self, 

159 projects_path: Path, 

160 num_parallel_builds: int, 

161 num_threads_per_build: int, 

162 output_path: Optional[Path] = None, 

163 collect_artifacts: Optional[Callable[["VivadoProject", Path], bool]] = None, 

164 **kwargs: Any, 

165 ) -> bool: 

166 """ 

167 Build all the projects in the list. 

168 

169 Arguments: 

170 projects_path: The projects will be placed here. 

171 num_parallel_builds: The number of projects that will be built in parallel. 

172 num_threads_per_build: The number threads that will be used for each 

173 parallel build process. 

174 output_path: Where the artifacts should be placed. 

175 collect_artifacts: Callback to collect artifacts. 

176 Takes two named arguments: 

177 

178 | **project** (:class:`.VivadoProject`): The project that is being built. 

179 

180 | **output_path** (pathlib.Path): Where the artifacts should be placed. 

181 

182 

183 | Must return True. 

184 kwargs: Other arguments as accepted by :meth:`.VivadoProject.build`. 

185 

186 .. Note:: 

187 Argument ``project_path`` can not be set, it is set by this class 

188 based on the ``project_paths`` argument to this function. 

189 

190 Argument ``num_threads`` is set by the ``num_threads_per_build`` 

191 argument to this function. This naming difference is done to avoid 

192 confusion with regards to ``num_parallel_builds``. 

193 

194 

195 Return: 

196 True if everything went well. 

197 """ 

198 if collect_artifacts: 

199 thread_safe_collect_artifacts = ThreadSafeCollectArtifacts( 

200 collect_artifacts 

201 ).collect_artifacts 

202 else: 

203 thread_safe_collect_artifacts = None 

204 

205 build_wrappers = [] 

206 for project in self.projects: 

207 if output_path: 

208 this_projects_output_path = output_path.resolve() / project.name 

209 else: 

210 this_projects_output_path = projects_path / project.name 

211 

212 build_wrapper = BuildProjectBuildWrapper( 

213 project, 

214 output_path=this_projects_output_path, 

215 collect_artifacts=thread_safe_collect_artifacts, 

216 num_threads=num_threads_per_build, 

217 **kwargs, 

218 ) 

219 build_wrappers.append(build_wrapper) 

220 

221 return self._run_build_wrappers( 

222 projects_path=projects_path, 

223 build_wrappers=build_wrappers, 

224 num_parallel_builds=num_parallel_builds, 

225 ) 

226 

227 def open(self, projects_path: Path) -> bool: 

228 """ 

229 Open the projects in EDA GUI. 

230 

231 Arguments: 

232 projects_path: The projects are placed here. 

233 

234 Return: 

235 True if everything went well. 

236 """ 

237 build_wrappers = [] 

238 for project in self.projects: 

239 build_wrappers.append(BuildProjectOpenWrapper(project)) 

240 

241 return self._run_build_wrappers( 

242 projects_path=projects_path, 

243 build_wrappers=build_wrappers, 

244 # For open there is no performance limitation. Set a high value. 

245 num_parallel_builds=20, 

246 ) 

247 

248 def _run_build_wrappers( 

249 self, 

250 projects_path: Path, 

251 build_wrappers: Union[ 

252 list["BuildProjectCreateWrapper"], 

253 list["BuildProjectBuildWrapper"], 

254 list["BuildProjectOpenWrapper"], 

255 ], 

256 num_parallel_builds: int, 

257 ) -> bool: 

258 if not build_wrappers: 

259 # Return straight away if no builds are supplied 

260 return True 

261 

262 start_time = time.time() 

263 

264 color_printer = NO_COLOR_PRINTER if self._no_color else COLOR_PRINTER 

265 report = BuildReport(printer=color_printer) 

266 

267 test_list = TestList() 

268 for build_wrapper in build_wrappers: 

269 test_list.add_test(build_wrapper) 

270 

271 verbosity = BuildRunner.VERBOSITY_QUIET 

272 test_runner = BuildRunner( 

273 report=report, 

274 output_path=projects_path, 

275 verbosity=verbosity, 

276 num_threads=num_parallel_builds, 

277 ) 

278 test_runner.run(test_list) 

279 

280 all_builds_ok: bool = report.all_ok() 

281 report.set_real_total_time(time.time() - start_time) 

282 

283 # True if the builds are for the "build" step (not "create" or "open") 

284 builds_are_build_step = isinstance(build_wrappers[0], BuildProjectBuildWrapper) 

285 # If we are building, we should print the summary that is at the end of the console output. 

286 # (however if we are creating or opening a project we should not print anything extra). 

287 # However if anything has failed, we should also print. 

288 if builds_are_build_step: 

289 # The length of the build summary depends on if we are working with netlist builds or 

290 # regular ones, so set the length given by one of the project objects. 

291 # Ignore typing error that 'Create' or 'Open' build wrappers do not have 

292 # 'build_result_report_length'. We only end up here if it is a Build. 

293 report_length_lines = build_wrappers[ 

294 0 

295 ].build_result_report_length # type: ignore[union-attr] 

296 report.set_report_length(report_length_lines=report_length_lines) 

297 

298 # If all are OK then we should print the resource utilization numbers. 

299 # If not, then we print a few last lines of the log output. 

300 if builds_are_build_step or not all_builds_ok: 

301 report.print_str() 

302 

303 return all_builds_ok 

304 

305 def _iterate_projects( 

306 self, project_filters: list[str], include_netlist_not_top_builds: bool 

307 ) -> Iterable["VivadoProject"]: 

308 available_projects = [] 

309 for module in self._modules: 

310 available_projects += module.get_build_projects() 

311 

312 for project in available_projects: 

313 if project.is_netlist_build == include_netlist_not_top_builds: 

314 if not project_filters: 

315 yield project 

316 

317 else: 

318 for project_filter in project_filters: 

319 if fnmatch.filter([project.name], project_filter): 

320 yield project 

321 

322 # Do not continue with further filters if we have already matched this 

323 # project. 

324 # Multiple filters might match the same project, and multiple objects 

325 # of the same project will break build 

326 break 

327 

328 

329class BuildProjectCreateWrapper: 

330 

331 """ 

332 Wrapper to create a build project, for usage in the build runner. 

333 Mimics a VUnit test object. 

334 """ 

335 

336 def __init__(self, project: "VivadoProject", **kwargs: Any) -> None: 

337 self.name = project.name 

338 self._project = project 

339 self._create_arguments = kwargs 

340 

341 def run(self, output_path: Path, read_output: Any) -> bool: # pylint: disable=unused-argument 

342 """ 

343 VUnit test runner sends another argument "read_output" which we don't use. 

344 """ 

345 this_projects_path = Path(output_path) / "project" 

346 return self._project.create(project_path=this_projects_path, **self._create_arguments) 

347 

348 

349class BuildProjectBuildWrapper: 

350 

351 """ 

352 Wrapper to build a project, for usage in the build runner. 

353 Mimics a VUnit test object. 

354 """ 

355 

356 def __init__( 

357 self, 

358 project: "VivadoProject", 

359 collect_artifacts: Optional[Callable[..., bool]], 

360 **kwargs: Any, 

361 ) -> None: 

362 self.name = project.name 

363 self._project = project 

364 self._collect_artifacts = collect_artifacts 

365 self._build_arguments = kwargs 

366 

367 def run(self, output_path: Path, read_output: Any) -> bool: # pylint: disable=unused-argument 

368 """ 

369 VUnit test runner sends another argument "read_output" which we don't use. 

370 """ 

371 this_projects_path = Path(output_path) / "project" 

372 build_result = self._project.build(project_path=this_projects_path, **self._build_arguments) 

373 

374 if not build_result.success: 

375 self._print_build_result(build_result) 

376 return build_result.success 

377 

378 # Proceed to artifact collection only if build succeeded. 

379 if self._collect_artifacts: 

380 if not self._collect_artifacts( 

381 project=self._project, output_path=self._build_arguments["output_path"] 

382 ): 

383 build_result.success = False 

384 

385 # Print size at the absolute end 

386 self._print_build_result(build_result=build_result) 

387 return build_result.success 

388 

389 @staticmethod 

390 def _print_build_result(build_result: "build_result.BuildResult") -> None: 

391 build_report = build_result.report() 

392 if build_report: 

393 # Add an empty line before the build result report, to have margin in how many lines are 

394 # printed. See the comments in BuildResult for an explanation. 

395 print() 

396 print(build_report) 

397 

398 @property 

399 def build_result_report_length(self) -> int: 

400 """ 

401 The number of lines in the build_result report from this project. 

402 """ 

403 # The size summary, as returned by tsfpga.vivado.project.BuildResult is a JSON formatted 

404 # string with one line for each utilization category. 

405 # For Xilinx 7 series, there are 8 categories (Total LUTs, Logic LUTs, LUTRAMs, 

406 # SRLs, FFs, RAMB36, RAMB18, DSP Blocks). For UltraScale series there is one 

407 # extra (URAM). 

408 # Additionally, the size summary contains three extra lines for JSON braces and a title. 

409 # 

410 # This value is enough lines so the whole summary gets printed to console. 

411 # For 7 series, this will mean an extra blank line before the summary. 

412 # 

413 # This is a hack. Works for now, but is far from reliable. 

414 length_of_size_report = 3 + 8 + 1 

415 

416 if self._project.is_netlist_build: 

417 # The logic level distribution report is five lines, plus a title line. 

418 # This report is only printed for netlist builds, where there is no configured clock 

419 # present. If there were many clocks present in the build, the report would be longer. 

420 length_of_logic_level_report = 5 + 1 

421 return length_of_size_report + length_of_logic_level_report 

422 

423 return length_of_size_report 

424 

425 

426class BuildProjectOpenWrapper: 

427 

428 """ 

429 Wrapper to open a build project, for usage in the build runner. 

430 Mimics a VUnit test object. 

431 """ 

432 

433 def __init__(self, project: "VivadoProject") -> None: 

434 self.name = project.name 

435 self._project = project 

436 

437 def run(self, output_path: Path, read_output: Any) -> bool: # pylint: disable=unused-argument 

438 """ 

439 VUnit test runner sends another argument "read_output" which we don't use. 

440 """ 

441 this_projects_path = Path(output_path) / "project" 

442 return self._project.open(project_path=this_projects_path) 

443 

444 

445class BuildRunner(TestRunner): 

446 

447 """ 

448 Build runner that mimics a VUnit TestRunner. Most things are used as they are in the 

449 base class, but some behavior is overridden. 

450 """ 

451 

452 def _create_test_mapping_file(self, test_suites: Any) -> None: 

453 """ 

454 Overloaded from super class. 

455 

456 Do not create this file. 

457 

458 We do not need it since folder name is the same as project name. 

459 """ 

460 

461 def _get_output_path(self, test_suite_name: str) -> str: 

462 """ 

463 Overloaded from super class. 

464 

465 Output folder name is the same as the project name. 

466 

467 Original function adds a hash at the end of the folder name. 

468 We do not want that necessarily. 

469 """ 

470 return str(Path(self._output_path) / test_suite_name) 

471 

472 @staticmethod 

473 def _prepare_test_suite_output_path(output_path: str) -> None: 

474 """ 

475 Overloaded from super class. 

476 

477 Create the directory unless it already exists. 

478 

479 Original function wipes the path before running a test. We do not want to do that 

480 since e.g. a Vivado project takes a long time to create and might contain a state 

481 that the user wants to keep. 

482 """ 

483 create_directory(Path(output_path), empty=False) 

484 

485 

486class ThreadSafeCollectArtifacts: 

487 

488 """ 

489 A thread-safe wrapper around a user-supplied function that makes sure the function 

490 is not launched more than once at the same time. When two builds finish at the 

491 same time, race conditions can arise depending on what the function does. 

492 

493 Note that this is a VERY fringe case, since builds usually take >20 minutes, and the 

494 collection probably only takes a few seconds. But it happens sometimes with the tsfpga 

495 example projects which are identical and quite fast (roughly three minutes). 

496 """ 

497 

498 def __init__(self, collect_artifacts: Callable[..., bool]) -> None: 

499 self._collect_artifacts = collect_artifacts 

500 self._lock = Lock() 

501 

502 def collect_artifacts(self, project: "VivadoProject", output_path: Path) -> bool: 

503 with self._lock: 

504 return self._collect_artifacts(project=project, output_path=output_path) 

505 

506 

507class BuildReport(TestReport): 

508 def add_result(self, *args: Any, **kwargs: Any) -> None: 

509 """ 

510 Overloaded from super class. 

511 

512 Add a a test result. 

513 

514 Uses a different Result class than the super method. 

515 """ 

516 result = BuildResult(*args, **kwargs) 

517 self._test_results[result.name] = result 

518 self._test_names_in_order.append(result.name) 

519 

520 def set_report_length(self, report_length_lines: int) -> None: 

521 """ 

522 Set the report length for all test results that have been added to the report. 

523 """ 

524 for test_result in self._test_results.values(): 

525 test_result.set_report_length(report_length_lines) 

526 

527 def print_latest_status(self, total_tests: int) -> None: 

528 """ 

529 Overloaded from super class. 

530 

531 This method is called for each build when it should print its result just as it finished, 

532 but other builds may not be finished yet. 

533 

534 Inherited and adapted from the VUnit function: 

535 * Removed support for the "skipped" result. 

536 * Do not use abbreviations in the printout. 

537 * Use f-strings. 

538 """ 

539 result = self._last_test_result() 

540 passed, failed, _ = self._split() 

541 

542 if result.passed: 

543 self._printer.write("pass", fg="gi") 

544 elif result.failed: 

545 self._printer.write("fail", fg="ri") 

546 else: 

547 assert False 

548 

549 count_summary = f"pass={len(passed)} fail={len(failed)} total={total_tests}" 

550 self._printer.write(f" ({count_summary}) {result.name} ({result.time:.1f} seconds)\n") 

551 

552 

553class BuildResult(TestResult): 

554 report_length_lines = None 

555 

556 def _print_output(self, printer: Any, num_lines: int) -> None: 

557 """ 

558 Print the last lines from the output file. 

559 

560 The ``printer`` argument should of type ``ColorPrinter`` from VUnit. 

561 """ 

562 output_tail = read_last_lines_of_file(Path(self._output_file_name), num_lines=num_lines) 

563 printer.write(output_tail) 

564 

565 def set_report_length(self, report_length_lines: int) -> None: 

566 """ 

567 Set how many lines shall be printed when this result is printed. 

568 """ 

569 self.report_length_lines = report_length_lines 

570 

571 def print_status(self, printer: Any, padding: int = 0) -> None: 

572 """ 

573 Overloaded from super class. 

574 

575 The ``printer`` argument should of type ``ColorPrinter`` from VUnit. 

576 

577 This method is called for each build when it should print its result in the "Summary" at 

578 the end when all builds have finished. 

579 

580 Inherited and adapted from the VUnit function. 

581 """ 

582 if self.passed and self.report_length_lines is not None: 

583 # Build passed, print build summary of the specified length. The length is only 

584 # set if this is a "build" result (not "create" or "open"). 

585 self._print_output(printer=printer, num_lines=self.report_length_lines) 

586 else: 

587 # The build failed, which can either be caused by 

588 # 1. IDE build failure 

589 # 2. IDE build succeeded, but post build hook, or size checkers failed. 

590 # 3. Other python error (directory already exists, ...) 

591 # In the case of IDE build failed, we want a significant portion of the output, to be 

592 # able to see an indication of what failed. In the case of size checkers, we want to see 

593 # all the printouts from all checkers, to see which one failed. Since there are at most 

594 # eight resource categories, it is reasonable to assume that there will never be more 

595 # than eight size checkers. 

596 self._print_output(printer=printer, num_lines=25) 

597 

598 # Print the regular output from the VUnit class. 

599 # A little extra margin between build name and execution time makes the output more readable 

600 super().print_status(printer=printer, padding=padding + 2) 

601 # Add an empty line between each build, for readability. 

602 printer.write("\n")