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

170 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-03-12 11:11 +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 code = self.get_code(**kwargs) 

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

170 

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

172 create_file(file=output_file, contents=result) 

173 

174 return output_file 

175 

176 def create_if_needed( 

177 self, 

178 **kwargs: Any, # noqa: ANN401 

179 ) -> tuple[bool, Path]: 

180 """ 

181 Generate the result file if needed. 

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

183 

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

185 such as before a user simulation run. 

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

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

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

189 This increased speed gives a much nicer user experience. 

190 

191 Arguments: 

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

193 :meth:`.get_code` method. 

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

195 

196 Return: 

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

198 artifact that may or may not have been created. 

199 

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

201 

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

203 not created. 

204 """ 

205 if self.should_create: 

206 create_path = self.create(**kwargs) 

207 return True, create_path 

208 

209 return False, self.output_file 

210 

211 @property 

212 def should_create(self) -> bool: 

213 """ 

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

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

216 

217 * Output file does not exist. 

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

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

220 register list. 

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

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

223 

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

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

226 """ 

227 output_file = self.output_file 

228 

229 if not output_file.exists(): 

230 return True 

231 

232 return self._find_versions_and_hash_of_existing_file(file_path=output_file) != ( 

233 hdl_registers_version, 

234 self.__version__, 

235 self.register_list.object_hash, 

236 ) 

237 

238 def _find_versions_and_hash_of_existing_file( 

239 self, file_path: Path 

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

241 """ 

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

243 """ 

244 existing_file_content = read_file(file=file_path) 

245 

246 result_package_version = None 

247 result_generator_version = None 

248 result_hash = None 

249 

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

251 package_version_re = re.compile( 

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

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

254 ) 

255 package_version_match = package_version_re.search(existing_file_content) 

256 if package_version_match: 

257 result_package_version = package_version_match.group(2) 

258 

259 generator_version_re = re.compile( 

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

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

262 ) 

263 generator_version_match = generator_version_re.search(existing_file_content) 

264 if generator_version_match: 

265 result_generator_version = generator_version_match.group(1) 

266 

267 hash_re = re.compile( 

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

269 ) 

270 hash_match = hash_re.search(existing_file_content) 

271 if hash_match: 

272 result_hash = hash_match.group(1) 

273 

274 return result_package_version, result_generator_version, result_hash 

275 

276 @property 

277 def header(self) -> str: 

278 """ 

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

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

281 """ 

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

283 separator_line = self.get_separator_line(indent=0) 

284 return f"{separator_line}{generated_source_info}{separator_line}" 

285 

286 @property 

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

288 """ 

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

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

291 """ 

292 return self._get_generated_source_info(use_rst_annotation=False) 

293 

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

295 """ 

296 Lines with info about the automatically generated file. 

297 """ 

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

299 directory = Path.cwd() 

300 

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

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

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

304 # It is just there for extra info. 

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

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

307 

308 annotation = "``" if use_rst_annotation else "" 

309 

310 file_info = "" 

311 if self.register_list.source_definition_file is not None: 

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

313 directory = self.register_list.source_definition_file.parent 

314 

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

316 file_info = f" from file {file_name}" 

317 

318 commit_info = "" 

319 if git_commands_are_available(directory=directory): 

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

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

322 elif svn_commands_are_available(cwd=directory): 

323 svn_revision = get_svn_revision_information( 

324 cwd=directory, use_rst_annotation=use_rst_annotation 

325 ) 

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

327 

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

329 

330 name_link = ( 

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

332 if use_rst_annotation 

333 else "hdl-registers" 

334 ) 

335 

336 return [ 

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

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

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

340 info, 

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

342 ] 

343 

344 def _sanity_check(self) -> None: 

345 """ 

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

347 Will raise exception if there is any error. 

348 

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

350 compiled/used since it will probably crash. 

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

352 after a 1-hour FPGA build. 

353 

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

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

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

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

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

359 

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

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

362 real time. 

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

364 """ 

365 self._check_reserved_keywords() 

366 self._check_for_name_clashes() 

367 

368 def _check_reserved_keywords(self) -> None: 

369 """ 

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

371 generator languages. 

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

373 """ 

374 

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

376 if name.lower() in RESERVED_KEYWORDS: 

377 message = ( 

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

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

380 ) 

381 raise ValueError(message) 

382 

383 for constant in self.register_list.constants: 

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

385 

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

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

388 

389 for field in register.fields: 

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

391 

392 for register_array in self.iterate_register_arrays(): 

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

394 

395 def _check_for_name_clashes(self) -> None: 

396 """ 

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

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

399 """ 

400 self._check_for_constant_name_clashes() 

401 self._check_for_top_level_name_clashes() 

402 self._check_for_field_name_clashes() 

403 self._check_for_qualified_name_clashes() 

404 

405 def _check_for_constant_name_clashes(self) -> None: 

406 """ 

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

408 """ 

409 constant_names = set() 

410 for constant in self.register_list.constants: 

411 if constant.name in constant_names: 

412 message = ( 

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

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

415 ) 

416 raise ValueError(message) 

417 

418 constant_names.add(constant.name) 

419 

420 def _check_for_top_level_name_clashes(self) -> None: 

421 """ 

422 Check that there are no 

423 * duplicate register names, 

424 * duplicate register array names, 

425 * register array names that clash with register names. 

426 """ 

427 plain_register_names = set() 

428 for register in self.iterate_plain_registers(): 

429 if register.name in plain_register_names: 

430 message = ( 

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

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

433 ) 

434 raise ValueError(message) 

435 

436 plain_register_names.add(register.name) 

437 

438 register_array_names = set() 

439 for register_array in self.iterate_register_arrays(): 

440 if register_array.name in register_array_names: 

441 message = ( 

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

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

444 ) 

445 raise ValueError(message) 

446 

447 if register_array.name in plain_register_names: 

448 message = ( 

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

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

451 ) 

452 raise ValueError(message) 

453 

454 register_array_names.add(register_array.name) 

455 

456 def _check_for_field_name_clashes(self) -> None: 

457 """ 

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

459 """ 

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

461 field_names = set() 

462 

463 for field in register.fields: 

464 if field.name in field_names: 

465 register_description = ( 

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

467 if register_array 

468 else register.name 

469 ) 

470 message = ( 

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

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

473 ) 

474 raise ValueError(message) 

475 

476 field_names.add(field.name) 

477 

478 def _check_for_qualified_name_clashes(self) -> None: 

479 """ 

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

481 

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

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

484 Hence we need to check for these conflicts. 

485 """ 

486 qualified_names = set() 

487 

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

489 register_name = self.qualified_register_name( 

490 register=register, register_array=register_array 

491 ) 

492 register_description = ( 

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

494 ) 

495 

496 if register_name in qualified_names: 

497 message = ( 

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

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

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

501 ) 

502 raise ValueError(message) 

503 

504 qualified_names.add(register_name) 

505 

506 for field in register.fields: 

507 field_name = self.qualified_field_name( 

508 register=register, register_array=register_array, field=field 

509 ) 

510 

511 if field_name in qualified_names: 

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

513 message = ( 

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

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

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

517 ) 

518 raise ValueError(message) 

519 

520 qualified_names.add(field_name)