Coverage for tsfpga/build_project_list.py: 97%

176 statements  

« prev     ^ index     » next       coverage.py v6.4, created at 2022-05-28 04:01 +0000

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

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

3# 

4# This file is part of the tsfpga project. 

5# https://tsfpga.com 

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

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

8 

9import fnmatch 

10import time 

11from pathlib import Path 

12from threading import Lock 

13 

14from vunit.test.list import TestList 

15from vunit.test.runner import TestRunner 

16from vunit.test.report import TestReport, TestResult 

17from vunit.color_printer import COLOR_PRINTER, NO_COLOR_PRINTER 

18 

19from tsfpga.system_utils import create_directory, read_last_lines_of_file 

20 

21 

22class BuildProjectList: 

23 

24 """ 

25 Interface to handle a list of FPGA build projects. 

26 Enables building many projects in parallel. 

27 """ 

28 

29 def __init__( 

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

31 ): 

32 """ 

33 Arguments: 

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

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

36 Leave empty for all. 

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

38 instead of only top level builds. 

39 no_color (bool): Disable color in printouts. 

40 """ 

41 self._modules = modules 

42 self._no_color = no_color 

43 self.projects = list( 

44 self._iterate_projects(project_filters, include_netlist_not_top_builds) 

45 ) 

46 

47 if not self.projects: 

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

49 

50 def __str__(self): 

51 """ 

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

53 """ 

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

55 result += "\n" 

56 result += "\n" 

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

58 return result 

59 

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

61 """ 

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

63 

64 Arguments: 

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

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

67 parallel. 

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

69 

70 .. Note:: 

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

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

73 

74 Returns: 

75 bool: True if everything went well. 

76 """ 

77 build_wrappers = [] 

78 for project in self.projects: 

79 build_wrapper = BuildProjectCreateWrapper(project, **kwargs) 

80 build_wrappers.append(build_wrapper) 

81 

82 return self._run_build_wrappers( 

83 projects_path=projects_path, 

84 build_wrappers=build_wrappers, 

85 num_parallel_builds=num_parallel_builds, 

86 ) 

87 

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

89 """ 

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

91 exists. 

92 

93 Arguments: 

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

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

96 parallel. 

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

98 

99 .. Note:: 

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

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

102 

103 Returns: 

104 bool: True if everything went well. 

105 """ 

106 build_wrappers = [] 

107 for project in self.projects: 

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

109 build_wrapper = BuildProjectCreateWrapper(project, **kwargs) 

110 build_wrappers.append(build_wrapper) 

111 

112 if not build_wrappers: 

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

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

115 return True 

116 

117 return self._run_build_wrappers( 

118 projects_path=projects_path, 

119 build_wrappers=build_wrappers, 

120 num_parallel_builds=num_parallel_builds, 

121 ) 

122 

123 def build( 

124 self, 

125 projects_path, 

126 num_parallel_builds, 

127 num_threads_per_build, 

128 output_path=None, 

129 collect_artifacts=None, 

130 **kwargs, 

131 ): 

132 """ 

133 Build all the projects in the list. 

134 

135 Arguments: 

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

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

138 parallel. 

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

140 parallel build process. 

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

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

143 arguments: 

144 

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

146 

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

148 

149 

150 | Must return True. 

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

152 

153 .. Note:: 

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

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

156 

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

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

159 confusion with regards to ``num_parallel_builds``. 

160 

161 

162 Returns: 

163 bool: True if everything went well. 

164 """ 

165 if collect_artifacts: 

166 thread_safe_collect_artifacts = ThreadSafeCollectArtifacts( 

167 collect_artifacts 

168 ).collect_artifacts 

169 else: 

170 thread_safe_collect_artifacts = None 

171 

172 build_wrappers = [] 

173 for project in self.projects: 

174 if output_path: 

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

176 else: 

177 this_projects_output_path = projects_path / project.name 

178 

179 build_wrapper = BuildProjectBuildWrapper( 

180 project, 

181 output_path=this_projects_output_path, 

182 collect_artifacts=thread_safe_collect_artifacts, 

183 num_threads=num_threads_per_build, 

184 **kwargs, 

185 ) 

186 build_wrappers.append(build_wrapper) 

187 

188 return self._run_build_wrappers( 

189 projects_path=projects_path, 

190 build_wrappers=build_wrappers, 

191 num_parallel_builds=num_parallel_builds, 

192 ) 

193 

194 def open(self, projects_path): 

195 """ 

196 Open the projects in EDA GUI. 

197 

198 Arguments: 

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

200 

201 Returns: 

202 bool: True if everything went well. 

203 """ 

204 build_wrappers = [] 

205 for project in self.projects: 

206 build_wrapper = BuildProjectOpenWrapper(project) 

207 build_wrappers.append(build_wrapper) 

208 

209 return self._run_build_wrappers( 

210 projects_path=projects_path, 

211 build_wrappers=build_wrappers, 

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

213 num_parallel_builds=20, 

214 ) 

215 

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

217 if not build_wrappers: 

218 # Return straight away if no builds are supplied 

219 return True 

220 

221 start_time = time.time() 

222 

223 color_printer = NO_COLOR_PRINTER if self._no_color else COLOR_PRINTER 

224 report = BuildReport(printer=color_printer) 

225 

226 test_list = TestList() 

227 for build_wrapper in build_wrappers: 

228 test_list.add_test(build_wrapper) 

229 

230 verbosity = BuildRunner.VERBOSITY_QUIET 

231 test_runner = BuildRunner( 

232 report=report, 

233 output_path=projects_path, 

234 verbosity=verbosity, 

235 num_threads=num_parallel_builds, 

236 ) 

237 test_runner.run(test_list) 

238 

239 all_builds_ok = report.all_ok() 

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

241 

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

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

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

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

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

247 if builds_are_build_step: 

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

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

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

251 if builds_are_build_step or not all_builds_ok: 

252 report.print_str() 

253 

254 return all_builds_ok 

255 

256 def _iterate_projects(self, project_filters, include_netlist_not_top_builds): 

257 available_projects = [] 

258 for module in self._modules: 

259 available_projects += module.get_build_projects() 

260 

261 for project in available_projects: 

262 if project.is_netlist_build == include_netlist_not_top_builds: 

263 if not project_filters: 

264 yield project 

265 else: 

266 for project_filter in project_filters: 

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

268 yield project 

269 

270 

271class BuildProjectCreateWrapper: 

272 

273 """ 

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

275 Mimics a VUnit test object. 

276 """ 

277 

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

279 self.name = project.name 

280 self._project = project 

281 self._create_arguments = kwargs 

282 

283 # pylint: disable=unused-argument 

284 def run(self, output_path, read_output): 

285 """ 

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

287 """ 

288 this_projects_path = Path(output_path) / "project" 

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

290 

291 

292class BuildProjectBuildWrapper: 

293 

294 """ 

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

296 Mimics a VUnit test object. 

297 """ 

298 

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

300 self.name = project.name 

301 self._project = project 

302 self._collect_artifacts = collect_artifacts 

303 self._build_arguments = kwargs 

304 

305 # pylint: disable=unused-argument 

306 def run(self, output_path, read_output): 

307 """ 

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

309 """ 

310 this_projects_path = Path(output_path) / "project" 

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

312 

313 if not build_result.success: 

314 self._print_build_result(build_result) 

315 return build_result.success 

316 

317 # Proceed to artifact collection only if build succeeded. 

318 if self._collect_artifacts is not None: 

319 if not self._collect_artifacts( 

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

321 ): 

322 build_result.success = False 

323 

324 # Print size at the absolute end 

325 self._print_build_result(build_result) 

326 return build_result.success 

327 

328 @staticmethod 

329 def _print_build_result(build_result): 

330 build_report = build_result.report() 

331 if build_report: 

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

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

334 print() 

335 print(build_report) 

336 

337 @property 

338 def build_result_report_length(self): 

339 """ 

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

341 """ 

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

343 # string with one line for each utilization category. 

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

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

346 # extra (URAM). 

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

348 # 

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

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

351 # 

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

353 length_of_size_report = 3 + 8 + 1 

354 

355 if self._project.is_netlist_build: 

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

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

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

359 length_of_logic_level_report = 5 + 1 

360 return length_of_size_report + length_of_logic_level_report 

361 

362 return length_of_size_report 

363 

364 

365class BuildProjectOpenWrapper: 

366 

367 """ 

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

369 Mimics a VUnit test object. 

370 """ 

371 

372 def __init__(self, project): 

373 self.name = project.name 

374 self._project = project 

375 

376 # pylint: disable=unused-argument 

377 def run(self, output_path, read_output): 

378 """ 

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

380 """ 

381 this_projects_path = Path(output_path) / "project" 

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

383 

384 

385class BuildRunner(TestRunner): 

386 

387 """ 

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

389 base class, but some behavior is overridden. 

390 """ 

391 

392 def _create_test_mapping_file(self, test_suites): 

393 """ 

394 Do not create this file. 

395 

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

397 """ 

398 

399 def _get_output_path(self, test_suite_name): 

400 """ 

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

402 

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

404 We do not want that necessarily. 

405 """ 

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

407 

408 @staticmethod 

409 def _prepare_test_suite_output_path(output_path): 

410 """ 

411 Create the directory unless it already exists. 

412 

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

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

415 that the user wants to keep. 

416 """ 

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

418 

419 

420class ThreadSafeCollectArtifacts: 

421 

422 """ 

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

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

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

426 

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

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

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

430 """ 

431 

432 def __init__(self, collect_artifacts): 

433 self._collect_artifacts = collect_artifacts 

434 self._lock = Lock() 

435 

436 def collect_artifacts(self, project, output_path): 

437 with self._lock: 

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

439 

440 

441class BuildReport(TestReport): 

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

443 """ 

444 Add a a test result. 

445 

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

447 """ 

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

449 self._test_results[result.name] = result 

450 self._test_names_in_order.append(result.name) 

451 

452 def set_report_length(self, report_length_lines): 

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

454 test_result.set_report_length(report_length_lines) 

455 

456 def print_latest_status(self, total_tests): 

457 """ 

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

459 but other builds may not be finished yet. 

460 

461 Inherited and adapted from the VUnit function: 

462 * Removed support for the "skipped" result. 

463 * Do not use abbreviations in the printout. 

464 * Use f-strings. 

465 """ 

466 result = self._last_test_result() 

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

468 

469 if result.passed: 

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

471 elif result.failed: 

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

473 else: 

474 assert False 

475 

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

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

478 

479 

480class BuildResult(TestResult): 

481 

482 report_length_lines = None 

483 

484 def _print_output(self, printer, num_lines): 

485 """ 

486 Print the last lines from the output file. 

487 """ 

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

489 printer.write(output_tail) 

490 

491 def set_report_length(self, report_length_lines): 

492 self.report_length_lines = report_length_lines 

493 

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

495 """ 

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

497 the end when all builds have finished. 

498 

499 Inherited and adapted from the VUnit function. 

500 """ 

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

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

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

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

505 else: 

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

507 # 1. IDE build failure 

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

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

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

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

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

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

514 # than eight size checkers. 

515 self._print_output(printer, num_lines=25) 

516 

517 # Print the regular output from the VUnit class. 

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

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

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

521 printer.write("\n")