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

161 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-09-07 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 

10# Standard libraries 

11import datetime 

12import re 

13from abc import ABC, abstractmethod 

14from pathlib import Path 

15from typing import TYPE_CHECKING, Any, Optional 

16 

17# Third party libraries 

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, path_relative_to, read_file 

21 

22# First party libraries 

23from hdl_registers import __version__ as hdl_registers_version 

24 

25# Local folder libraries 

26from .register_code_generator_helpers import RegisterCodeGeneratorHelpers 

27from .reserved_keywords import RESERVED_KEYWORDS 

28 

29if TYPE_CHECKING: 

30 # First party libraries 

31 from hdl_registers.register_list import RegisterList 

32 

33 

34class RegisterCodeGenerator(ABC, RegisterCodeGeneratorHelpers): 

35 """ 

36 Abstract interface and common functions for generating register code. 

37 Should be inherited by all custom code generators. 

38 

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

40 meaning some useful methods are available in subclasses. 

41 """ 

42 

43 @property 

44 @abstractmethod 

45 def SHORT_DESCRIPTION(self) -> str: # pylint: disable=invalid-name 

46 """ 

47 A short description of what this generator produces. 

48 Will be used when printing status messages. 

49 

50 Overload in subclass by setting e.g. 

51 

52 .. code-block:: python 

53 

54 SHORT_DESCRIPTION = "C++ header" 

55 

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

57 """ 

58 

59 @property 

60 @abstractmethod # type: ignore[override] 

61 def COMMENT_START(self) -> str: # pylint: disable=invalid-name 

62 """ 

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

64 generating code for. 

65 

66 Overload in subclass by setting e.g. 

67 

68 .. code-block:: python 

69 

70 COMMENT_START = "#" 

71 

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

73 """ 

74 

75 @property 

76 @abstractmethod 

77 def output_file(self) -> Path: 

78 """ 

79 Result will be placed in this file. 

80 

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

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

83 For example: 

84 

85 .. code-block:: python 

86 

87 @property 

88 def output_file(self) -> Path: 

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

90 """ 

91 

92 @abstractmethod 

93 def get_code(self, **kwargs: Any) -> str: # pylint: disable=unused-argument 

94 """ 

95 Get the generated code as a string. 

96 

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

98 

99 Arguments: 

100 kwargs: Further optional parameters that can be used. 

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

102 of any custom generators that inherit this class. 

103 """ 

104 

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

106 # subclass for your code generator. 

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

108 DEFAULT_INDENTATION_LEVEL = 0 

109 

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

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

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

113 COMMENT_END = "" 

114 

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

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

117 # generated artifacts. 

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

119 # is called. 

120 __version__ = "0.0.1" 

121 

122 def __init__(self, register_list: "RegisterList", output_folder: Path): 

123 """ 

124 Arguments: 

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

126 in the generated artifacts. 

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

128 """ 

129 self.register_list = register_list 

130 self.output_folder = output_folder 

131 

132 self.name = register_list.name 

133 

134 def create(self, **kwargs: Any) -> Path: 

135 """ 

136 Generate the result artifact. 

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

138 

139 Arguments: 

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

141 :meth:`.get_code` method. 

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

143 

144 Return: 

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

146 """ 

147 output_file = self.output_file 

148 

149 try: 

150 path_to_print = path_relative_to(path=output_file, other=Path(".")) 

151 except ValueError: 

152 # Fails on Windows if CWD and the file are on different drives. 

153 path_to_print = output_file 

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

155 

156 self._sanity_check() 

157 

158 code = self.get_code(**kwargs) 

159 

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

161 create_file(file=output_file, contents=code) 

162 

163 return output_file 

164 

165 def create_if_needed(self, **kwargs: Any) -> tuple[bool, Path]: 

166 """ 

167 Generate the result file if needed. 

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

169 

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

171 such as before a user simulation run. 

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

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

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

175 This increased speed gives a much nicer user experience. 

176 

177 Arguments: 

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

179 :meth:`.get_code` method. 

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

181 

182 Return: 

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

184 artifact that may or may not have been created. 

185 

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

187 

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

189 not created. 

190 """ 

191 if self.should_create: 

192 create_path = self.create(**kwargs) 

193 return True, create_path 

194 

195 return False, self.output_file 

196 

197 @property 

198 def should_create(self) -> bool: 

199 """ 

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

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

202 

203 * Output file does not exist. 

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

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

206 register list. 

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

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

209 

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

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

212 """ 

213 output_file = self.output_file 

214 

215 if not output_file.exists(): 

216 return True 

217 

218 if ( 

219 hdl_registers_version, 

220 self.__version__, 

221 self.register_list.object_hash, 

222 ) != self._find_versions_and_hash_of_existing_file(file_path=output_file): 

223 return True 

224 

225 return False 

226 

227 def _find_versions_and_hash_of_existing_file( 

228 self, file_path: Path 

229 ) -> tuple[Optional[str], Optional[str], Optional[str]]: 

230 """ 

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

232 """ 

233 existing_file_content = read_file(file=file_path) 

234 

235 result_package_version = None 

236 result_generator_version = None 

237 result_hash = None 

238 

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

240 package_version_re = re.compile( 

241 ( 

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

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

244 ) 

245 ) 

246 package_version_match = package_version_re.search(existing_file_content) 

247 if package_version_match: 

248 result_package_version = package_version_match.group(2) 

249 

250 generator_version_re = re.compile( 

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

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

253 ) 

254 generator_version_match = generator_version_re.search(existing_file_content) 

255 if generator_version_match: 

256 result_generator_version = generator_version_match.group(1) 

257 

258 hash_re = re.compile( 

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

260 ) 

261 hash_match = hash_re.search(existing_file_content) 

262 if hash_match: 

263 result_hash = hash_match.group(1) 

264 

265 return result_package_version, result_generator_version, result_hash 

266 

267 @property 

268 def header(self) -> str: 

269 """ 

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

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

272 """ 

273 return self.comment_block(text=self.generated_source_info, indent=0) 

274 

275 @property 

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

277 """ 

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

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

280 """ 

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

282 directory = Path(".") 

283 

284 time_info = datetime.datetime.now().strftime("%Y-%m-%d %H:%M") 

285 

286 file_info = "" 

287 if self.register_list.source_definition_file is not None: 

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

289 directory = self.register_list.source_definition_file.parent 

290 file_info = f" from file {self.register_list.source_definition_file.name}" 

291 

292 commit_info = "" 

293 if git_commands_are_available(directory=directory): 

294 commit_info = f" at commit {get_git_commit(directory=directory)}" 

295 elif svn_commands_are_available(cwd=directory): 

296 commit_info = f" at revision {get_svn_revision_information(cwd=directory)}" 

297 

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

299 

300 return [ 

301 ( 

302 "This file is automatically generated by hdl-registers " 

303 f"version {hdl_registers_version}." 

304 ), 

305 f"Code generator {self.__class__.__name__} version {self.__version__}.", 

306 info, 

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

308 ] 

309 

310 def _sanity_check(self) -> None: 

311 """ 

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

313 Will raise exception if there is any error. 

314 

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

316 compiled/used since it will probably crash. 

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

318 a 1 hour FPGA build. 

319 

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

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

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

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

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

325 

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

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

328 real time. 

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

330 """ 

331 self._check_reserved_keywords() 

332 self._check_for_name_clashes() 

333 

334 def _check_reserved_keywords(self) -> None: 

335 """ 

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

337 generator languages. 

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

339 """ 

340 

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

342 if name.lower() in RESERVED_KEYWORDS: 

343 message = ( 

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

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

346 ) 

347 raise ValueError(message) 

348 

349 for constant in self.register_list.constants: 

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

351 

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

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

354 

355 for field in register.fields: 

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

357 

358 for register_array in self.iterate_register_arrays(): 

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

360 

361 def _check_for_name_clashes(self) -> None: 

362 """ 

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

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

365 """ 

366 self._check_for_constant_name_clashes() 

367 self._check_for_top_level_name_clashes() 

368 self._check_for_field_name_clashes() 

369 self._check_for_qualified_name_clashes() 

370 

371 def _check_for_constant_name_clashes(self) -> None: 

372 """ 

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

374 """ 

375 constant_names = set() 

376 for constant in self.register_list.constants: 

377 if constant.name in constant_names: 

378 message = ( 

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

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

381 ) 

382 raise ValueError(message) 

383 

384 constant_names.add(constant.name) 

385 

386 def _check_for_top_level_name_clashes(self) -> None: 

387 """ 

388 Check that there are no 

389 * duplicate register names, 

390 * duplicate register array names, 

391 * register array names that clash with register names. 

392 """ 

393 plain_register_names = set() 

394 for register in self.iterate_plain_registers(): 

395 if register.name in plain_register_names: 

396 message = ( 

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

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

399 ) 

400 raise ValueError(message) 

401 

402 plain_register_names.add(register.name) 

403 

404 register_array_names = set() 

405 for register_array in self.iterate_register_arrays(): 

406 if register_array.name in register_array_names: 

407 message = ( 

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

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

410 ) 

411 raise ValueError(message) 

412 

413 if register_array.name in plain_register_names: 

414 message = ( 

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

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

417 ) 

418 raise ValueError(message) 

419 

420 register_array_names.add(register_array.name) 

421 

422 def _check_for_field_name_clashes(self) -> None: 

423 """ 

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

425 """ 

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

427 field_names = set() 

428 

429 for field in register.fields: 

430 if field.name in field_names: 

431 register_description = ( 

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

433 if register_array 

434 else register.name 

435 ) 

436 message = ( 

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

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

439 ) 

440 raise ValueError(message) 

441 

442 field_names.add(field.name) 

443 

444 def _check_for_qualified_name_clashes(self) -> None: 

445 """ 

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

447 

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

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

450 Hence we need to check for these conflicts. 

451 """ 

452 qualified_names = set() 

453 

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

455 register_name = self.qualified_register_name( 

456 register=register, register_array=register_array 

457 ) 

458 register_description = ( 

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

460 ) 

461 

462 if register_name in qualified_names: 

463 message = ( 

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

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

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

467 ) 

468 raise ValueError(message) 

469 

470 qualified_names.add(register_name) 

471 

472 for field in register.fields: 

473 field_name = self.qualified_field_name( 

474 register=register, register_array=register_array, field=field 

475 ) 

476 

477 if field_name in qualified_names: 

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

479 message = ( 

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

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

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

483 ) 

484 raise ValueError(message) 

485 

486 qualified_names.add(field_name)