Coverage for tsfpga/build_project_list.py: 97%

176 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-11-29 20:01 +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://gitlab.com/tsfpga/tsfpga 

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

8 

9# Standard libraries 

10import fnmatch 

11import time 

12from pathlib import Path 

13from threading import Lock 

14 

15# Third party libraries 

16from vunit.color_printer import COLOR_PRINTER, NO_COLOR_PRINTER 

17from vunit.test.list import TestList 

18from vunit.test.report import TestReport, TestResult 

19from vunit.test.runner import TestRunner 

20 

21# First party libraries 

22from tsfpga.system_utils import create_directory, read_last_lines_of_file 

23 

24 

25class BuildProjectList: 

26 

27 """ 

28 Interface to handle a list of FPGA build projects. 

29 Enables building many projects in parallel. 

30 """ 

31 

32 def __init__( 

33 self, modules, project_filters=None, include_netlist_not_top_builds=False, no_color=False 

34 ): 

35 """ 

36 Arguments: 

37 modules (list(BaseModule)): Module objects that can define build projects. 

38 project_filters (list(str)): Project name filters. Can use wildcards (*). 

39 Leave empty for all. 

40 include_netlist_not_top_builds (bool): Set True to get only netlist builds, 

41 instead of only top level builds. 

42 no_color (bool): Disable color in printouts. 

43 """ 

44 self._modules = modules 

45 self._no_color = no_color 

46 self.projects = list( 

47 self._iterate_projects(project_filters, include_netlist_not_top_builds) 

48 ) 

49 

50 if not self.projects: 

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

52 

53 def __str__(self): 

54 """ 

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

56 """ 

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

58 result += "\n" 

59 result += "\n" 

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

61 return result 

62 

63 def create(self, projects_path, num_parallel_builds, **kwargs): 

64 """ 

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

66 

67 Arguments: 

68 projects_path (pathlib.Path): The projects will be placed here. 

69 num_parallel_builds (int): The number of projects that will be created in 

70 parallel. 

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

72 

73 .. Note:: 

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

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

76 

77 Returns: 

78 bool: True if everything went well. 

79 """ 

80 build_wrappers = [] 

81 for project in self.projects: 

82 build_wrapper = BuildProjectCreateWrapper(project, **kwargs) 

83 build_wrappers.append(build_wrapper) 

84 

85 return self._run_build_wrappers( 

86 projects_path=projects_path, 

87 build_wrappers=build_wrappers, 

88 num_parallel_builds=num_parallel_builds, 

89 ) 

90 

91 def create_unless_exists(self, projects_path, num_parallel_builds, **kwargs): 

92 """ 

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

94 exists. 

95 

96 Arguments: 

97 projects_path (pathlib.Path): The projects will be placed here. 

98 num_parallel_builds (int): The number of projects that will be created in 

99 parallel. 

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

101 

102 .. Note:: 

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

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

105 

106 Returns: 

107 bool: True if everything went well. 

108 """ 

109 build_wrappers = [] 

110 for project in self.projects: 

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

112 build_wrapper = BuildProjectCreateWrapper(project, **kwargs) 

113 build_wrappers.append(build_wrapper) 

114 

115 if not build_wrappers: 

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

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

118 return True 

119 

120 return self._run_build_wrappers( 

121 projects_path=projects_path, 

122 build_wrappers=build_wrappers, 

123 num_parallel_builds=num_parallel_builds, 

124 ) 

125 

126 def build( 

127 self, 

128 projects_path, 

129 num_parallel_builds, 

130 num_threads_per_build, 

131 output_path=None, 

132 collect_artifacts=None, 

133 **kwargs, 

134 ): 

135 """ 

136 Build all the projects in the list. 

137 

138 Arguments: 

139 projects_path (pathlib.Path): The projects will be placed here. 

140 num_parallel_builds (int): The number of projects that will be built in 

141 parallel. 

142 num_threads_per_build (int): The number threads that will be used for each 

143 parallel build process. 

144 output_path (pathlib.Path): Where the artifacts should be placed. 

145 collect_artifacts (`function`): Callback to collect artifacts. Takes two named 

146 arguments: 

147 

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

149 

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

151 

152 

153 | Must return True. 

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

155 

156 .. Note:: 

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

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

159 

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

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

162 confusion with regards to ``num_parallel_builds``. 

163 

164 

165 Returns: 

166 bool: True if everything went well. 

167 """ 

168 if collect_artifacts: 

169 thread_safe_collect_artifacts = ThreadSafeCollectArtifacts( 

170 collect_artifacts 

171 ).collect_artifacts 

172 else: 

173 thread_safe_collect_artifacts = None 

174 

175 build_wrappers = [] 

176 for project in self.projects: 

177 if output_path: 

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

179 else: 

180 this_projects_output_path = projects_path / project.name 

181 

182 build_wrapper = BuildProjectBuildWrapper( 

183 project, 

184 output_path=this_projects_output_path, 

185 collect_artifacts=thread_safe_collect_artifacts, 

186 num_threads=num_threads_per_build, 

187 **kwargs, 

188 ) 

189 build_wrappers.append(build_wrapper) 

190 

191 return self._run_build_wrappers( 

192 projects_path=projects_path, 

193 build_wrappers=build_wrappers, 

194 num_parallel_builds=num_parallel_builds, 

195 ) 

196 

197 def open(self, projects_path): 

198 """ 

199 Open the projects in EDA GUI. 

200 

201 Arguments: 

202 projects_path (pathlib.Path): The projects are placed here. 

203 

204 Returns: 

205 bool: True if everything went well. 

206 """ 

207 build_wrappers = [] 

208 for project in self.projects: 

209 build_wrapper = BuildProjectOpenWrapper(project) 

210 build_wrappers.append(build_wrapper) 

211 

212 return self._run_build_wrappers( 

213 projects_path=projects_path, 

214 build_wrappers=build_wrappers, 

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

216 num_parallel_builds=20, 

217 ) 

218 

219 def _run_build_wrappers(self, projects_path, build_wrappers, num_parallel_builds): 

220 if not build_wrappers: 

221 # Return straight away if no builds are supplied 

222 return True 

223 

224 start_time = time.time() 

225 

226 color_printer = NO_COLOR_PRINTER if self._no_color else COLOR_PRINTER 

227 report = BuildReport(printer=color_printer) 

228 

229 test_list = TestList() 

230 for build_wrapper in build_wrappers: 

231 test_list.add_test(build_wrapper) 

232 

233 verbosity = BuildRunner.VERBOSITY_QUIET 

234 test_runner = BuildRunner( 

235 report=report, 

236 output_path=projects_path, 

237 verbosity=verbosity, 

238 num_threads=num_parallel_builds, 

239 ) 

240 test_runner.run(test_list) 

241 

242 all_builds_ok = report.all_ok() 

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

244 

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

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

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

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

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

250 if builds_are_build_step: 

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

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

253 report.set_report_length(build_wrappers[0].build_result_report_length) 

254 if builds_are_build_step or not all_builds_ok: 

255 report.print_str() 

256 

257 return all_builds_ok 

258 

259 def _iterate_projects(self, project_filters, include_netlist_not_top_builds): 

260 available_projects = [] 

261 for module in self._modules: 

262 available_projects += module.get_build_projects() 

263 

264 for project in available_projects: 

265 if project.is_netlist_build == include_netlist_not_top_builds: 

266 if not project_filters: 

267 yield project 

268 else: 

269 for project_filter in project_filters: 

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

271 yield project 

272 

273 

274class BuildProjectCreateWrapper: 

275 

276 """ 

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

278 Mimics a VUnit test object. 

279 """ 

280 

281 def __init__(self, project, **kwargs): 

282 self.name = project.name 

283 self._project = project 

284 self._create_arguments = kwargs 

285 

286 # pylint: disable=unused-argument 

287 def run(self, output_path, read_output): 

288 """ 

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

290 """ 

291 this_projects_path = Path(output_path) / "project" 

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

293 

294 

295class BuildProjectBuildWrapper: 

296 

297 """ 

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

299 Mimics a VUnit test object. 

300 """ 

301 

302 def __init__(self, project, collect_artifacts, **kwargs): 

303 self.name = project.name 

304 self._project = project 

305 self._collect_artifacts = collect_artifacts 

306 self._build_arguments = kwargs 

307 

308 # pylint: disable=unused-argument 

309 def run(self, output_path, read_output): 

310 """ 

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

312 """ 

313 this_projects_path = Path(output_path) / "project" 

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

315 

316 if not build_result.success: 

317 self._print_build_result(build_result) 

318 return build_result.success 

319 

320 # Proceed to artifact collection only if build succeeded. 

321 if self._collect_artifacts is not None: 

322 if not self._collect_artifacts( 

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

324 ): 

325 build_result.success = False 

326 

327 # Print size at the absolute end 

328 self._print_build_result(build_result) 

329 return build_result.success 

330 

331 @staticmethod 

332 def _print_build_result(build_result): 

333 build_report = build_result.report() 

334 if build_report: 

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

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

337 print() 

338 print(build_report) 

339 

340 @property 

341 def build_result_report_length(self): 

342 """ 

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

344 """ 

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

346 # string with one line for each utilization category. 

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

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

349 # extra (URAM). 

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

351 # 

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

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

354 # 

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

356 length_of_size_report = 3 + 8 + 1 

357 

358 if self._project.is_netlist_build: 

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

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

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

362 length_of_logic_level_report = 5 + 1 

363 return length_of_size_report + length_of_logic_level_report 

364 

365 return length_of_size_report 

366 

367 

368class BuildProjectOpenWrapper: 

369 

370 """ 

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

372 Mimics a VUnit test object. 

373 """ 

374 

375 def __init__(self, project): 

376 self.name = project.name 

377 self._project = project 

378 

379 # pylint: disable=unused-argument 

380 def run(self, output_path, read_output): 

381 """ 

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

383 """ 

384 this_projects_path = Path(output_path) / "project" 

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

386 

387 

388class BuildRunner(TestRunner): 

389 

390 """ 

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

392 base class, but some behavior is overridden. 

393 """ 

394 

395 def _create_test_mapping_file(self, test_suites): 

396 """ 

397 Do not create this file. 

398 

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

400 """ 

401 

402 def _get_output_path(self, test_suite_name): 

403 """ 

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

405 

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

407 We do not want that necessarily. 

408 """ 

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

410 

411 @staticmethod 

412 def _prepare_test_suite_output_path(output_path): 

413 """ 

414 Create the directory unless it already exists. 

415 

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

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

418 that the user wants to keep. 

419 """ 

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

421 

422 

423class ThreadSafeCollectArtifacts: 

424 

425 """ 

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

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

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

429 

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

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

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

433 """ 

434 

435 def __init__(self, collect_artifacts): 

436 self._collect_artifacts = collect_artifacts 

437 self._lock = Lock() 

438 

439 def collect_artifacts(self, project, output_path): 

440 with self._lock: 

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

442 

443 

444class BuildReport(TestReport): 

445 def add_result(self, *args, **kwargs): 

446 """ 

447 Add a a test result. 

448 

449 Inherited and adapted from the VUnit function. Uses a different Result class. 

450 """ 

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

452 self._test_results[result.name] = result 

453 self._test_names_in_order.append(result.name) 

454 

455 def set_report_length(self, report_length_lines): 

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

457 test_result.set_report_length(report_length_lines) 

458 

459 def print_latest_status(self, total_tests): 

460 """ 

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

462 but other builds may not be finished yet. 

463 

464 Inherited and adapted from the VUnit function: 

465 * Removed support for the "skipped" result. 

466 * Do not use abbreviations in the printout. 

467 * Use f-strings. 

468 """ 

469 result = self._last_test_result() 

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

471 

472 if result.passed: 

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

474 elif result.failed: 

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

476 else: 

477 assert False 

478 

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

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

481 

482 

483class BuildResult(TestResult): 

484 

485 report_length_lines = None 

486 

487 def _print_output(self, printer, num_lines): 

488 """ 

489 Print the last lines from the output file. 

490 """ 

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

492 printer.write(output_tail) 

493 

494 def set_report_length(self, report_length_lines): 

495 self.report_length_lines = report_length_lines 

496 

497 def print_status(self, printer, padding=0): 

498 """ 

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

500 the end when all builds have finished. 

501 

502 Inherited and adapted from the VUnit function. 

503 """ 

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

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

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

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

508 else: 

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

510 # 1. IDE build failure 

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

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

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

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

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

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

517 # than eight size checkers. 

518 self._print_output(printer, num_lines=25) 

519 

520 # Print the regular output from the VUnit class. 

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

522 super().print_status(printer, padding + 2) 

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

524 printer.write("\n")