Coverage for hdl_registers/generator/register_code_generator.py: 99%

172 statements  

« prev     ^ index     » next       coverage.py v7.7.0, created at 2025-03-18 20:51 +0000

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

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

3# 

4# This file is part of the hdl-registers project, an HDL register generator fast enough to run 

5# in real time. 

6# https://hdl-registers.com 

7# https://github.com/hdl-registers/hdl-registers 

8# -------------------------------------------------------------------------------------------------- 

9 

10from __future__ import annotations 

11 

12import datetime 

13import re 

14from abc import ABC, abstractmethod 

15from pathlib import Path 

16from typing import TYPE_CHECKING, Any 

17 

18from tsfpga.git_utils import get_git_commit, git_commands_are_available 

19from tsfpga.svn_utils import get_svn_revision_information, svn_commands_are_available 

20from tsfpga.system_utils import create_file, read_file 

21 

22from hdl_registers import __version__ as hdl_registers_version 

23 

24from .register_code_generator_helpers import RegisterCodeGeneratorHelpers 

25from .reserved_keywords import RESERVED_KEYWORDS 

26 

27if TYPE_CHECKING: 

28 from hdl_registers.register_list import RegisterList 

29 

30 

31class RegisterCodeGenerator(ABC, RegisterCodeGeneratorHelpers): 

32 """ 

33 Abstract interface and common functions for generating register code. 

34 Should be inherited by all custom code generators. 

35 

36 Note that this base class also inherits from :class:`.RegisterCodeGeneratorHelpers`, 

37 meaning some useful methods are available in subclasses. 

38 """ 

39 

40 @property 

41 @abstractmethod 

42 def SHORT_DESCRIPTION(self) -> str: # noqa: N802 

43 """ 

44 A short description of what this generator produces. 

45 Will be used when printing status messages. 

46 

47 Overload in subclass by setting e.g. 

48 

49 .. code-block:: python 

50 

51 class MyCoolGenerator(RegisterCodeGenerator): 

52 

53 SHORT_DESCRIPTION = "C++ header" 

54 

55 as a static class member at the top of the class. 

56 """ 

57 

58 @property 

59 @abstractmethod 

60 def COMMENT_START(self) -> str: # noqa: N802 

61 """ 

62 The character(s) that start a comment line in the programming language that we are 

63 generating code for. 

64 

65 Overload in subclass by setting e.g. 

66 

67 .. code-block:: python 

68 

69 class MyCoolGenerator(RegisterCodeGenerator): 

70 

71 COMMENT_START = "#" 

72 

73 as a static class member at the top of the class. 

74 Note that for some languages you might have to set :attr:`.COMMENT_END` as well. 

75 """ 

76 

77 @property 

78 @abstractmethod 

79 def output_file(self) -> Path: 

80 """ 

81 Result will be placed in this file. 

82 

83 Overload in a subclass to give the proper name to the code artifact. 

84 Probably using a combination of ``self.output_folder`` and ``self.name``. 

85 For example: 

86 

87 .. code-block:: python 

88 

89 @property 

90 def output_file(self) -> Path: 

91 return self.output_folder / f"{self.name}_regs.html" 

92 """ 

93 

94 @abstractmethod 

95 def get_code( 

96 self, 

97 **kwargs: Any, # noqa: ANN401 

98 ) -> str: 

99 """ 

100 Get the generated code as a string. 

101 

102 Overload in a subclass where the code generation is implemented. 

103 

104 Arguments: 

105 kwargs: Further optional parameters that can be used. 

106 Can send any number of named arguments, per the requirements of :meth:`.get_code` 

107 of any custom generators that inherit this class. 

108 """ 

109 

110 # Optionally set a non-zero default indentation level, expressed in number of spaces, in a 

111 # subclass for your code generator. 

112 # Will be used by e.g. the 'self.comment' method. 

113 DEFAULT_INDENTATION_LEVEL = 0 

114 

115 # For some languages, comment lines have to be ended with special characters. 

116 # Optionally set this to a non-null string in a subclass. 

117 # For best formatting, leave a space character at the start of this string. 

118 COMMENT_END = "" 

119 

120 # For a custom code generator, overload this value in a subclass. 

121 # This version number of the custom code generator class is added to the file header of 

122 # generated artifacts. 

123 # Changing the number will trigger a re-generate of all artifacts when 'create_if_needed' 

124 # is called. 

125 __version__ = "0.0.1" 

126 

127 def __init__(self, register_list: RegisterList, output_folder: Path) -> None: 

128 """ 

129 Arguments: 

130 register_list: Registers and constants from this register list will be included 

131 in the generated artifacts. 

132 output_folder: Result file will be placed in this folder. 

133 """ 

134 self.register_list = register_list 

135 self.output_folder = output_folder 

136 

137 self.name = register_list.name 

138 

139 def create( 

140 self, 

141 **kwargs: Any, # noqa: ANN401 

142 ) -> Path: 

143 """ 

144 Generate the result artifact. 

145 I.e. create the :meth:`.output_file` containing the result from :meth:`.get_code` method. 

146 

147 Arguments: 

148 kwargs: Further optional parameters that will be sent on to the 

149 :meth:`.get_code` method. 

150 See :meth:`.get_code` for details. 

151 

152 Return: 

153 The path to the created file, i.e. :meth:`.output_file`. 

154 """ 

155 output_file = self.output_file 

156 

157 current_working_directory = Path.cwd() 

158 if current_working_directory in output_file.parents: 

159 # Print a shorter path if the output is inside the CWD. 

160 path_to_print = output_file.relative_to(current_working_directory) 

161 else: 

162 # But don't print a long string of ../../ if it is outside, but instead the full path. 

163 path_to_print = output_file 

164 print(f"Creating {self.SHORT_DESCRIPTION} file: {path_to_print}") 

165 

166 self._sanity_check() 

167 

168 # Constructing the code and creating the file is in a separate method that can 

169 # be overridden. 

170 # Everything above should be common though. 

171 return self._create_artifact(output_file=output_file, **kwargs) 

172 

173 def _create_artifact( 

174 self, 

175 output_file: Path, 

176 **kwargs: Any, # noqa: ANN401 

177 ) -> Path: 

178 """ 

179 The last stage of creating the file: Constructs the code and creates the file. 

180 This method is called by :meth:`.create` and should not be called directly. 

181 It is separated out to make it easier to override some specific behaviors in subclasses. 

182 """ 

183 code = self.get_code(**kwargs) 

184 result = f"{self.header}\n{code}" 

185 

186 # Will create the containing folder unless it already exists. 

187 return create_file(file=output_file, contents=result) 

188 

189 def create_if_needed( 

190 self, 

191 **kwargs: Any, # noqa: ANN401 

192 ) -> tuple[bool, Path]: 

193 """ 

194 Generate the result file if needed. 

195 I.e. call :meth:`.create` if :meth:`.should_create` is ``True``. 

196 

197 This method is recommended rather than :meth:`.create` in time-critical scenarios, 

198 such as before a user simulation run. 

199 The :meth:`.should_create` check is very fast (0.5 ms on a decent computer with a typical 

200 register list), while a typical register generator is quite slow by comparison. 

201 Hence it makes sense to run this method in order to save execution time. 

202 This increased speed gives a much nicer user experience. 

203 

204 Arguments: 

205 kwargs: Further optional parameters that will be sent on to the 

206 :meth:`.get_code` method. 

207 See :meth:`.get_code` for details. 

208 

209 Return: 

210 Tuple, where first element is a boolean status, and second element is the path to the 

211 artifact that may or may not have been created. 

212 

213 The boolean is the return value of :meth:`.should_create`. 

214 

215 The path is the :meth:`.output_file` and is set always, even if the file was 

216 not created. 

217 """ 

218 if self.should_create: 

219 create_path = self.create(**kwargs) 

220 return True, create_path 

221 

222 return False, self.output_file 

223 

224 @property 

225 def should_create(self) -> bool: 

226 """ 

227 Indicates if a (re-)create of artifacts is needed. 

228 Will be True if any of these conditions are true: 

229 

230 * Output file does not exist. 

231 * Generator version of artifact does not match current code version. 

232 * Artifact hash does not match :meth:`.RegisterList.object_hash` of the current 

233 register list. 

234 I.e. something in the register list has changed since the previous file was generated 

235 (e.g. a new register added). 

236 

237 The version and hash checks above are dependent on the artifact file having a header 

238 as given by :meth:`.header`. 

239 """ 

240 return self._should_create(file_path=self.output_file) 

241 

242 def _should_create(self, file_path: Path) -> bool: 

243 """ 

244 Check if a (re-)create of this specific artifact is needed. 

245 """ 

246 if not file_path.exists(): 

247 return True 

248 

249 return self._find_versions_and_hash_of_existing_file(file_path=file_path) != ( 

250 hdl_registers_version, 

251 self.__version__, 

252 self.register_list.object_hash, 

253 ) 

254 

255 def _find_versions_and_hash_of_existing_file( 

256 self, file_path: Path 

257 ) -> tuple[str | None, str | None, str | None]: 

258 """ 

259 Returns the matching strings in a tuple. Either field can be ``None`` if nothing found. 

260 """ 

261 existing_file_content = read_file(file=file_path) 

262 

263 result_package_version = None 

264 result_generator_version = None 

265 result_hash = None 

266 

267 # This is either the very first line of the file, or starting on a new line. 

268 package_version_re = re.compile( 

269 rf"(^|\n){self.COMMENT_START} This file is automatically generated by " 

270 rf"hdl-registers version (\S+)\.{self.COMMENT_END}\n" 

271 ) 

272 package_version_match = package_version_re.search(existing_file_content) 

273 if package_version_match: 

274 result_package_version = package_version_match.group(2) 

275 

276 generator_version_re = re.compile( 

277 rf"\n{self.COMMENT_START} Code generator {self.__class__.__name__} " 

278 rf"version (\S+).{self.COMMENT_END}\n", 

279 ) 

280 generator_version_match = generator_version_re.search(existing_file_content) 

281 if generator_version_match: 

282 result_generator_version = generator_version_match.group(1) 

283 

284 hash_re = re.compile( 

285 rf"\n{self.COMMENT_START} Register hash ([0-9a-f]+)\.{self.COMMENT_END}\n" 

286 ) 

287 hash_match = hash_re.search(existing_file_content) 

288 if hash_match: 

289 result_hash = hash_match.group(1) 

290 

291 return result_package_version, result_generator_version, result_hash 

292 

293 @property 

294 def header(self) -> str: 

295 """ 

296 Get file header informing the user that the file is automatically generated. 

297 Basically the information from :meth:`.generated_source_info` formatted as a comment block. 

298 """ 

299 generated_source_info = self.comment_block(text=self.generated_source_info, indent=0) 

300 separator_line = self.get_separator_line(indent=0) 

301 return f"{separator_line}{generated_source_info}{separator_line}" 

302 

303 @property 

304 def generated_source_info(self) -> list[str]: 

305 """ 

306 Return lines informing the user that the file is automatically generated. 

307 Containing info about the source of the generated register information. 

308 """ 

309 return self._get_generated_source_info(use_rst_annotation=False) 

310 

311 def _get_generated_source_info(self, use_rst_annotation: bool) -> list[str]: 

312 """ 

313 Lines with info about the automatically generated file. 

314 """ 

315 # Default: Get git SHA from the user's current working directory. 

316 directory = Path.cwd() 

317 

318 # This call will more or less guess the user's timezone. 

319 # In a general use case this is not reliable, hence the rule, but in our case 

320 # the date information is not critical in any way. 

321 # It is just there for extra info. 

322 # https://docs.astral.sh/ruff/rules/call-datetime-now-without-tzinfo/ 

323 time_info = datetime.datetime.now().strftime("%Y-%m-%d %H:%M") # noqa: DTZ005 

324 

325 annotation = "``" if use_rst_annotation else "" 

326 

327 file_info = "" 

328 if self.register_list.source_definition_file is not None: 

329 # If the source definition file does exist, get git SHA from that directory instead. 

330 directory = self.register_list.source_definition_file.parent 

331 

332 file_name = f"{annotation}{self.register_list.source_definition_file.name}{annotation}" 

333 file_info = f" from file {file_name}" 

334 

335 commit_info = "" 

336 if git_commands_are_available(directory=directory): 

337 git_commit = get_git_commit(directory=directory, use_rst_annotation=use_rst_annotation) 

338 commit_info = f" at Git commit {git_commit}" 

339 elif svn_commands_are_available(cwd=directory): 

340 svn_revision = get_svn_revision_information( 

341 cwd=directory, use_rst_annotation=use_rst_annotation 

342 ) 

343 commit_info = f" at SVN revision {svn_revision}" 

344 

345 info = f"Generated {time_info}{file_info}{commit_info}." 

346 

347 name_link = ( 

348 "`hdl-registers <https://hdl-registers.com>`_" 

349 if use_rst_annotation 

350 else "hdl-registers" 

351 ) 

352 

353 return [ 

354 f"This file is automatically generated by {name_link} version {hdl_registers_version}.", 

355 f"Code generator {annotation}{self.__class__.__name__}{annotation} " 

356 f"version {self.__version__}.", 

357 info, 

358 f"Register hash {self.register_list.object_hash}.", 

359 ] 

360 

361 def _sanity_check(self) -> None: 

362 """ 

363 Do some basic checks that no naming errors are present. 

364 Will raise exception if there is any error. 

365 

366 In general, the user will know if these errors are present when the generated code is 

367 compiled/used since it will probably crash. 

368 But it is better to warn early, rather than the user finding out when compiling headers 

369 after a 1-hour FPGA build. 

370 

371 We run this check at creation time, always and for every single generator. 

372 Hence the user will hopefully get warned when they generate e.g. a VHDL package at the start 

373 of the FPGA build that a register uses a reserved C++ name. 

374 This check takes roughly 140 us on a decent computer with a typical register list. 

375 Hence it is not a big deal that it might be run more than once for each register list. 

376 

377 It is better to have it here in the generator rather than in the parser: 

378 1. Here it runs only when necessary, not adding time to parsing which is often done in 

379 real time. 

380 2. We also catch things that were added with the Python API. 

381 """ 

382 self._check_reserved_keywords() 

383 self._check_for_name_clashes() 

384 

385 def _check_reserved_keywords(self) -> None: 

386 """ 

387 Check that no item in the register list matches a reserved keyword in any of the targeted 

388 generator languages. 

389 To minimize the risk that a generated artifact does not compile. 

390 """ 

391 

392 def check(name: str, description: str) -> None: 

393 if name.lower() in RESERVED_KEYWORDS: 

394 message = ( 

395 f'Error in register list "{self.name}": ' 

396 f'{description} name "{name}" is a reserved keyword.' 

397 ) 

398 raise ValueError(message) 

399 

400 for constant in self.register_list.constants: 

401 check(name=constant.name, description="Constant") 

402 

403 for register, _ in self.iterate_registers(): 

404 check(name=register.name, description="Register") 

405 

406 for field in register.fields: 

407 check(name=field.name, description="Field") 

408 

409 for register_array in self.iterate_register_arrays(): 

410 check(name=register_array.name, description="Register array") 

411 

412 def _check_for_name_clashes(self) -> None: 

413 """ 

414 Check that there are no name clashes between items in the register list. 

415 To minimize the risk that a generated artifact does not compile. 

416 """ 

417 self._check_for_constant_name_clashes() 

418 self._check_for_top_level_name_clashes() 

419 self._check_for_field_name_clashes() 

420 self._check_for_qualified_name_clashes() 

421 

422 def _check_for_constant_name_clashes(self) -> None: 

423 """ 

424 Check that there are no constants with the same name. 

425 """ 

426 constant_names = set() 

427 for constant in self.register_list.constants: 

428 if constant.name in constant_names: 

429 message = ( 

430 f'Error in register list "{self.name}": ' 

431 f'Duplicate constant name "{constant.name}".' 

432 ) 

433 raise ValueError(message) 

434 

435 constant_names.add(constant.name) 

436 

437 def _check_for_top_level_name_clashes(self) -> None: 

438 """ 

439 Check that there are no 

440 * duplicate register names, 

441 * duplicate register array names, 

442 * register array names that clash with register names. 

443 """ 

444 plain_register_names = set() 

445 for register in self.iterate_plain_registers(): 

446 if register.name in plain_register_names: 

447 message = ( 

448 f'Error in register list "{self.name}": ' 

449 f'Duplicate plain register name "{register.name}".' 

450 ) 

451 raise ValueError(message) 

452 

453 plain_register_names.add(register.name) 

454 

455 register_array_names = set() 

456 for register_array in self.iterate_register_arrays(): 

457 if register_array.name in register_array_names: 

458 message = ( 

459 f'Error in register list "{self.name}": ' 

460 f'Duplicate register array name "{register_array.name}".' 

461 ) 

462 raise ValueError(message) 

463 

464 if register_array.name in plain_register_names: 

465 message = ( 

466 f'Error in register list "{self.name}": ' 

467 f'Register array "{register_array.name}" may not have same name as register.' 

468 ) 

469 raise ValueError(message) 

470 

471 register_array_names.add(register_array.name) 

472 

473 def _check_for_field_name_clashes(self) -> None: 

474 """ 

475 Check that no register contains fields with the same name. 

476 """ 

477 for register, register_array in self.iterate_registers(): 

478 field_names = set() 

479 

480 for field in register.fields: 

481 if field.name in field_names: 

482 register_description = ( 

483 f"{register_array.name}.{register.name}" 

484 if register_array 

485 else register.name 

486 ) 

487 message = ( 

488 f'Error in register list "{self.name}": ' 

489 f'Duplicate field name "{field.name}" in register "{register_description}".' 

490 ) 

491 raise ValueError(message) 

492 

493 field_names.add(field.name) 

494 

495 def _check_for_qualified_name_clashes(self) -> None: 

496 """ 

497 Check that there are no name clashes when names of registers and fields are qualified. 

498 

499 The register 'apa_hest' will give a conflict with the field 'apa.hest' since both will get 

500 e.g. a VHDL simulation method 'read_apa_hest'. 

501 Hence we need to check for these conflicts. 

502 """ 

503 qualified_names = set() 

504 

505 for register, register_array in self.iterate_registers(): 

506 register_name = self.qualified_register_name( 

507 register=register, register_array=register_array 

508 ) 

509 register_description = ( 

510 f"{register_array.name}.{register.name}" if register_array else register.name 

511 ) 

512 

513 if register_name in qualified_names: 

514 message = ( 

515 f'Error in register list "{self.name}": ' 

516 f'Qualified name of register "{register_description}" ' 

517 f'("{register_name}") clashes with another item.' 

518 ) 

519 raise ValueError(message) 

520 

521 qualified_names.add(register_name) 

522 

523 for field in register.fields: 

524 field_name = self.qualified_field_name( 

525 register=register, register_array=register_array, field=field 

526 ) 

527 

528 if field_name in qualified_names: 

529 field_description = f"{register_description}.{field.name}" 

530 message = ( 

531 f'Error in register list "{self.name}": ' 

532 f'Qualified name of field "{field_description}" ' 

533 f'("{field_name}") clashes with another item.' 

534 ) 

535 raise ValueError(message) 

536 

537 qualified_names.add(field_name)