Coverage for src/pyTRLCConverter/rst_converter.py: 96%

194 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-06-26 12:41 +0000

1"""Converter to reStructuredText format. 

2 

3 Author: Gabryel Reyes (gabryel.reyes@newtec.de) 

4""" 

5 

6# pyTRLCConverter - A tool to convert TRLC files to specific formats. 

7# Copyright (c) 2024 - 2026 NewTec GmbH 

8# 

9# This file is part of pyTRLCConverter program. 

10# 

11# The pyTRLCConverter program is free software: you can redistribute it and/or modify it under 

12# the terms of the GNU General Public License as published by the Free Software Foundation, 

13# either version 3 of the License, or (at your option) any later version. 

14# 

15# The pyTRLCConverter program is distributed in the hope that it will be useful, but 

16# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 

17# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License along with pyTRLCConverter. 

20# If not, see <https://www.gnu.org/licenses/>. 

21 

22# Imports ********************************************************************** 

23import os 

24import shutil 

25import tempfile 

26from typing import Optional, Any 

27from marko import Markdown 

28from trlc.ast import Implicit_Null, Record_Object, Record_Reference, String_Literal, Expression 

29from pyTRLCConverter.base_converter import BaseConverter 

30from pyTRLCConverter.rst.document import RstDocument 

31from pyTRLCConverter.rst.element import RstHeading, RstAdmonition, RstTable, RstBulletList 

32from pyTRLCConverter.rst.text import RstText 

33from pyTRLCConverter.ret import Ret 

34from pyTRLCConverter.trlc_helper import TrlcAstWalker 

35from pyTRLCConverter.logger import log_verbose, log_error 

36from pyTRLCConverter.marko.md2rst_renderer import Md2RstRenderer 

37from pyTRLCConverter.marko.gfm2rst_renderer import Gfm2RstRenderer 

38 

39# Variables ******************************************************************** 

40 

41# Classes ********************************************************************** 

42 

43# pylint: disable-next=too-many-instance-attributes 

44class RstConverter(BaseConverter): 

45 """ 

46 RstConverter provides functionality for converting to a reStructuredText format. 

47 

48 The converter builds a reStructuredText AST (RstDocument made of block 

49 elements) while walking the TRLC symbols and writes the output file(s) only 

50 once the document is complete (in leave_file() for multiple-document mode and 

51 in finish() for single-document mode). 

52 """ 

53 OUTPUT_FILE_NAME_DEFAULT = "output.rst" 

54 TOP_LEVEL_DEFAULT = "Specification" 

55 

56 def __init__(self, args: Any) -> None: 

57 # lobster-trace: SwRequirements.sw_req_rst 

58 """ 

59 Initializes the converter. 

60 

61 Args: 

62 args (Any): The parsed program arguments. 

63 """ 

64 super().__init__(args) 

65 

66 # The path to the given output folder. 

67 self._out_path = args.out 

68 

69 # The excluded paths in normalized form. 

70 self._excluded_paths = [] 

71 

72 if args.exclude is not None: 

73 self._excluded_paths = [os.path.normpath(path) for path in args.exclude] 

74 

75 # The reStructuredText document currently being built. In multiple-document mode a new 

76 # document is created per file, in single-document mode one document is shared. 

77 self._document: Optional[RstDocument] = None 

78 

79 # The base name of the output file currently being built. It is used to build the 

80 # labels of headings and admonitions. 

81 self._current_file_name = "" 

82 

83 # The base level for the headings. Its the minimum level for the headings which depends 

84 # on the single/multiple document mode. 

85 self._base_level = 1 

86 

87 # The AST walker meta data for processing the record object fields. 

88 # This will hold the information about the current package, type and attribute being processed. 

89 self._ast_meta_data = None 

90 

91 self._plantuml_tmp_dir: Optional[tempfile.TemporaryDirectory] = None 

92 self._external_files: list = [] 

93 

94 @staticmethod 

95 def get_subcommand() -> str: 

96 # lobster-trace: SwRequirements.sw_req_rst 

97 """ 

98 Return subcommand token for this converter. 

99 

100 Returns: 

101 str: Parser subcommand token 

102 """ 

103 return "rst" 

104 

105 @staticmethod 

106 def get_description() -> str: 

107 # lobster-trace: SwRequirements.sw_req_rst 

108 """ 

109 Return converter description. 

110 

111 Returns: 

112 str: Converter description 

113 """ 

114 return "Convert into reStructuredText format." 

115 

116 @classmethod 

117 def register(cls, args_parser: Any) -> None: 

118 # lobster-trace: SwRequirements.sw_req_rst_multiple_doc_mode 

119 # lobster-trace: SwRequirements.sw_req_rst_single_doc_mode 

120 # lobster-trace: SwRequirements.sw_req_rst_sd_top_level_default 

121 # lobster-trace: SwRequirements.sw_req_rst_sd_top_level_custom 

122 # lobster-trace: SwRequirements.sw_req_rst_out_file_name_default 

123 # lobster-trace: SwRequirements.sw_req_rst_out_file_name_custom 

124 """ 

125 Register converter specific argument parser. 

126 

127 Args: 

128 args_parser (Any): Argument parser 

129 """ 

130 super().register(args_parser) 

131 

132 assert BaseConverter._parser is not None 

133 

134 BaseConverter._parser.add_argument( 

135 "-e", 

136 "--empty", 

137 type=str, 

138 default=BaseConverter.EMPTY_ATTRIBUTE_DEFAULT, 

139 required=False, 

140 help="Every attribute value which is empty will output the string " \ 

141 f"(default = {BaseConverter.EMPTY_ATTRIBUTE_DEFAULT})." 

142 ) 

143 

144 BaseConverter._parser.add_argument( 

145 "-n", 

146 "--name", 

147 type=str, 

148 default=RstConverter.OUTPUT_FILE_NAME_DEFAULT, 

149 required=False, 

150 help="Name of the generated output file inside the output folder " \ 

151 f"(default = {RstConverter.OUTPUT_FILE_NAME_DEFAULT}) in " \ 

152 "case a single document is generated." 

153 ) 

154 

155 BaseConverter._parser.add_argument( 

156 "-sd", 

157 "--single-document", 

158 action="store_true", 

159 required=False, 

160 default=False, 

161 help="Generate a single document instead of multiple files. The default is to generate multiple files." 

162 ) 

163 

164 BaseConverter._parser.add_argument( 

165 "-tl", 

166 "--top-level", 

167 type=str, 

168 default=RstConverter.TOP_LEVEL_DEFAULT, 

169 required=False, 

170 help="Name of the top level heading, required in single document mode " \ 

171 f"(default = {RstConverter.TOP_LEVEL_DEFAULT})." 

172 ) 

173 

174 def begin(self) -> Ret: 

175 # lobster-trace: SwRequirements.sw_req_rst_single_doc_mode 

176 # lobster-trace: SwRequirements.sw_req_rst_sd_top_level 

177 """ 

178 Begin the conversion process. 

179 

180 Returns: 

181 Ret: Status 

182 """ 

183 assert self._document is None 

184 

185 # Call the base converter to initialize the common stuff. 

186 result = BaseConverter.begin(self) 

187 

188 if result == Ret.OK: 

189 

190 # Single document mode? 

191 if self._args.single_document is True: 

192 log_verbose("Single document mode.") 

193 else: 

194 log_verbose("Multiple document mode.") 

195 

196 # Set the value for empty attributes. 

197 self._empty_attribute_value = self._args.empty 

198 

199 log_verbose(f"Empty attribute value: {self._empty_attribute_value}") 

200 

201 # pylint: disable-next=consider-using-with 

202 self._plantuml_tmp_dir = tempfile.TemporaryDirectory(prefix="pyTRLCConverter_rst_") 

203 

204 # Single document mode? 

205 if self._args.single_document is True: 

206 self._document = RstDocument() 

207 self._current_file_name = self._args.name 

208 

209 # The top level heading is always required in single document mode. 

210 self._document.add(RstHeading(self._args.top_level, 1, self._current_file_name)) 

211 

212 # All headings will be shifted by one level. 

213 self._base_level = self._base_level + 1 

214 

215 return result 

216 

217 def enter_file(self, file_name: str) -> Ret: 

218 # lobster-trace: SwRequirements.sw_req_rst_multiple_doc_mode 

219 """ 

220 Enter a file. 

221 

222 Args: 

223 file_name (str): File name 

224 

225 Returns: 

226 Ret: Status 

227 """ 

228 # Multiple document mode? 

229 if self._args.single_document is False: 

230 assert self._document is None 

231 

232 # A new document is built for each file. The very first written part shall not have 

233 # an empty line before, which the document handles implicitly. 

234 self._document = RstDocument() 

235 self._current_file_name = self._file_name_trlc_to_rst(file_name) 

236 

237 return Ret.OK 

238 

239 def leave_file(self, file_name: str) -> Ret: 

240 # lobster-trace: SwRequirements.sw_req_rst_multiple_doc_mode 

241 """ 

242 Leave a file. 

243 

244 Args: 

245 file_name (str): File name 

246 

247 Returns: 

248 Ret: Status 

249 """ 

250 result = Ret.OK 

251 

252 # Multiple document mode? 

253 if self._args.single_document is False: 

254 assert self._document is not None 

255 

256 result = self._write_document(self._current_file_name) 

257 

258 self._copy_external_files(self._out_path) 

259 self._external_files = [] 

260 self._document = None 

261 

262 return result 

263 

264 def convert_section(self, section: str, level: int) -> Ret: 

265 # lobster-trace: SwRequirements.sw_req_rst_section 

266 """ 

267 Process the given section item. 

268 It will create a reStructuredText heading with the given section name and level. 

269 

270 Args: 

271 section (str): The section name 

272 level (int): The section indentation level 

273 

274 Returns: 

275 Ret: Status 

276 """ 

277 assert len(section) > 0 

278 assert self._document is not None 

279 

280 self._document.add(RstHeading(section, self._get_rst_heading_level(level), self._current_file_name)) 

281 

282 return Ret.OK 

283 

284 def convert_record_object_generic(self, record: Record_Object, level: int, translation: Optional[dict]) -> Ret: 

285 # lobster-trace: SwRequirements.sw_req_rst_record 

286 """ 

287 Process the given record object in a generic way. 

288 

289 The handler is called by the base converter if no specific handler is 

290 defined for the record type. 

291 

292 Args: 

293 record (Record_Object): The record object. 

294 level (int): The record level. 

295 translation (Optional[dict]): Translation dictionary for the record object. 

296 If None, no translation is applied. 

297 

298 Returns: 

299 Ret: Status 

300 """ 

301 assert self._document is not None 

302 

303 return self._convert_record_object(record, level, translation) 

304 

305 def finish(self): 

306 # lobster-trace: SwRequirements.sw_req_rst_single_doc_mode 

307 """ 

308 Finish the conversion process. 

309 

310 Returns: 

311 Ret: Status 

312 """ 

313 result = Ret.OK 

314 

315 # Single document mode? 

316 if self._args.single_document is True: 

317 assert self._document is not None 

318 

319 result = self._write_document(self._current_file_name) 

320 

321 self._copy_external_files(self._out_path) 

322 self._external_files = [] 

323 self._document = None 

324 

325 if self._plantuml_tmp_dir is not None: 

326 self._plantuml_tmp_dir.cleanup() 

327 self._plantuml_tmp_dir = None 

328 

329 return result 

330 

331 def _get_rst_heading_level(self, level: int) -> int: 

332 # lobster-trace: SwRequirements.sw_req_rst_section 

333 """ 

334 Get the reStructuredText heading level from the TRLC object level. 

335 Its mandatory to use this method to calculate the reStructuredText heading level. 

336 Otherwise in single document mode the top level heading will be wrong. 

337 

338 Args: 

339 level (int): The TRLC object level. 

340 

341 Returns: 

342 int: reStructuredText heading level 

343 """ 

344 return self._base_level + level 

345 

346 def _file_name_trlc_to_rst(self, file_name_trlc: str) -> str: 

347 # lobster-trace: SwRequirements.sw_req_rst_multiple_doc_mode 

348 """ 

349 Convert a TRLC file name to a reStructuredText file name. 

350 

351 Args: 

352 file_name_trlc (str): TRLC file name 

353 

354 Returns: 

355 str: reStructuredText file name 

356 """ 

357 file_name = os.path.basename(file_name_trlc) 

358 file_name = os.path.splitext(file_name)[0] + ".rst" 

359 

360 return file_name 

361 

362 def _write_document(self, file_name: str) -> Ret: 

363 # lobster-trace: SwRequirements.sw_req_rst_out_folder 

364 """ 

365 Write the current reStructuredText document to the output file. 

366 

367 Args: 

368 file_name (str): The output file name without path. 

369 

370 Returns: 

371 Ret: Status 

372 """ 

373 assert self._document is not None 

374 

375 result = Ret.OK 

376 file_name_with_path = file_name 

377 

378 # Add path to the output file name. 

379 if 0 < len(self._out_path): 

380 file_name_with_path = os.path.join(self._out_path, file_name) 

381 

382 try: 

383 with open(file_name_with_path, "w", encoding="utf-8") as out_file: 

384 out_file.write(self._document.render()) 

385 except IOError as e: 

386 log_error(f"Failed to open file {file_name_with_path}: {e}") 

387 result = Ret.ERROR 

388 

389 return result 

390 

391 def _on_implicit_null(self, _: Implicit_Null) -> str: 

392 # lobster-trace: SwRequirements.sw_req_rst_record 

393 """ 

394 Process the given implicit null value. 

395 

396 Returns: 

397 str: The implicit null value. 

398 """ 

399 return RstText.escape(self._empty_attribute_value) 

400 

401 def _on_record_reference(self, record_reference: Record_Reference) -> str: 

402 # lobster-trace: SwRequirements.sw_req_rst_record 

403 """ 

404 Process the given record reference value and return a reStructuredText link. 

405 

406 Args: 

407 record_reference (Record_Reference): The record reference value. 

408 

409 Returns: 

410 str: reStructuredText link to the record reference. 

411 """ 

412 return self._create_rst_link_from_record_object_reference(record_reference) 

413 

414 def _on_string_literal(self, string_literal: String_Literal) -> str: 

415 # lobster-trace: SwRequirements.sw_req_rst_string_format 

416 # lobster-trace: SwRequirements.sw_req_rst_render_md 

417 # lobster-trace: SwRequirements.sw_req_rst_render_gfm 

418 """ 

419 Process the given string literal value. 

420 

421 Args: 

422 string_literal (String_Literal): The string literal value. 

423 

424 Returns: 

425 str: The string literal value. 

426 """ 

427 result = string_literal.to_string() 

428 

429 if self._ast_meta_data is not None: 

430 package_name = self._ast_meta_data.get("package_name", "") 

431 type_name = self._ast_meta_data.get("type_name", "") 

432 attribute_name = self._ast_meta_data.get("attribute_name", "") 

433 

434 result = self._render(package_name, type_name, attribute_name, result) 

435 

436 return result 

437 

438 def _create_rst_link_from_record_object_reference(self, record_reference: Record_Reference) -> str: 

439 # lobster-trace: SwRequirements.sw_req_rst_link 

440 """ 

441 Create a reStructuredText cross-reference from a record reference. 

442 It considers the file name, the package name, and the record name. 

443 

444 Args: 

445 record_reference (Record_Reference): Record reference 

446 

447 Returns: 

448 str: reStructuredText cross-reference 

449 """ 

450 assert record_reference.target is not None 

451 

452 file_name = "" 

453 

454 # Single document mode? 

455 if self._args.single_document is True: 

456 file_name = self._args.name 

457 

458 # Is the link to a excluded file? 

459 for excluded_path in self._excluded_paths: 

460 

461 if os.path.commonpath([excluded_path, record_reference.target.location.file_name]) == excluded_path: 

462 file_name = self._file_name_trlc_to_rst(record_reference.target.location.file_name) 

463 break 

464 

465 # Multiple document mode 

466 else: 

467 file_name = self._file_name_trlc_to_rst(record_reference.target.location.file_name) 

468 

469 record_name = record_reference.target.name 

470 

471 # Create a target ID for the record 

472 target_id = f"{file_name}-{record_name.lower().replace(' ', '-')}" 

473 

474 return RstText.link(str(record_reference.to_python_object()), target_id) 

475 

476 def _other_dispatcher(self, expression: Expression) -> str: 

477 # lobster-trace: SwRequirements.sw_req_rst_record 

478 # lobster-trace: SwRequirements.sw_req_rst_escape 

479 """ 

480 Dispatcher for all other expressions. 

481 

482 Args: 

483 expression (Expression): The expression to process. 

484 

485 Returns: 

486 str: The processed expression. 

487 """ 

488 return RstText.escape(expression.to_string()) 

489 

490 def _get_trlc_ast_walker(self) -> TrlcAstWalker: 

491 # lobster-trace: SwRequirements.sw_req_rst_record 

492 # lobster-trace: SwRequirements.sw_req_rst_escape 

493 # lobster-trace: SwRequirements.sw_req_rst_string_format 

494 """ 

495 If a record object contains a record reference, the record reference will be converted to 

496 a Markdown link. 

497 If a record object contains an array of record references, the array will be converted to 

498 a reStructuredText list of links. 

499 Otherwise the record object fields attribute values will be written to the reStructuredText table. 

500 

501 Returns: 

502 TrlcAstWalker: The TRLC AST walker. 

503 """ 

504 trlc_ast_walker = TrlcAstWalker() 

505 trlc_ast_walker.add_dispatcher( 

506 Implicit_Null, 

507 None, 

508 self._on_implicit_null, 

509 None 

510 ) 

511 trlc_ast_walker.add_dispatcher( 

512 Record_Reference, 

513 None, 

514 self._on_record_reference, 

515 None 

516 ) 

517 trlc_ast_walker.add_dispatcher( 

518 String_Literal, 

519 None, 

520 self._on_string_literal, 

521 None 

522 ) 

523 trlc_ast_walker.set_other_dispatcher(self._other_dispatcher) 

524 

525 return trlc_ast_walker 

526 

527 def _render(self, package_name: str, type_name: str, attribute_name: str, attribute_value: str) -> str: 

528 # lobster-trace: SwRequirements.sw_req_rst_string_format 

529 # lobster-trace: SwRequirements.sw_req_rst_render_md 

530 # lobster-trace: SwRequirements.sw_req_rst_render_gfm 

531 # lobster-trace: SwRequirements.sw_req_rst_render_plantuml 

532 """Render the attribute value depending on its format. 

533 

534 Args: 

535 package_name (str): The package name. 

536 type_name (str): The type name. 

537 attribute_name (str): The attribute name. 

538 attribute_value (str): The attribute value. 

539 

540 Returns: 

541 str: The rendered attribute value. 

542 """ 

543 result = attribute_value 

544 

545 # If the attribute value is not already in reStructuredText format, it will be escaped. 

546 if self._render_cfg.is_format_rst(package_name, type_name, attribute_name) is False: 

547 

548 # Is it CommonMark Markdown format? 

549 if self._render_cfg.is_format_md(package_name, type_name, attribute_name) is True: 

550 assert self._plantuml_tmp_dir is not None 

551 Md2RstRenderer.image_dir = self._plantuml_tmp_dir.name 

552 Md2RstRenderer.external_files = self._external_files 

553 markdown = Markdown(renderer=Md2RstRenderer) 

554 result = markdown.convert(attribute_value) 

555 

556 # Is it GitHub Flavored Markdown format? 

557 elif self._render_cfg.is_format_gfm(package_name, type_name, attribute_name) is True: 

558 assert self._plantuml_tmp_dir is not None 

559 Md2RstRenderer.image_dir = self._plantuml_tmp_dir.name 

560 Md2RstRenderer.external_files = self._external_files 

561 markdown = Markdown(renderer=Gfm2RstRenderer, extensions=['gfm']) 

562 result = markdown.convert(attribute_value) 

563 

564 # Otherwise escape the text for reStructuredText. 

565 else: 

566 result = RstText.escape(attribute_value) 

567 

568 return result 

569 

570 def _copy_external_files(self, dest_dir: str) -> None: 

571 # lobster-trace: SwRequirements.sw_req_rst_render_plantuml 

572 """Copy all collected external files to the given destination directory. 

573 

574 Args: 

575 dest_dir (str): Destination directory path. 

576 """ 

577 copied_sources = set() 

578 

579 for source_path, local_name in self._external_files: 

580 if source_path in copied_sources: 

581 continue 

582 

583 dest_path = os.path.join(dest_dir, local_name) 

584 

585 try: 

586 shutil.copy2(source_path, dest_path) 

587 copied_sources.add(source_path) 

588 except (OSError, IOError) as exc: 

589 log_error(f"Failed to copy external file '{source_path}': {exc}", False) 

590 

591 # pylint: disable-next=unused-argument 

592 def _convert_record_object(self, record: Record_Object, level: int, translation: Optional[dict]) -> Ret: 

593 # lobster-trace: SwRequirements.sw_req_rst_record 

594 """ 

595 Process the given record object. 

596 

597 Args: 

598 record (Record_Object): The record object. 

599 level (int): The record level. 

600 translation (Optional[dict]): Translation dictionary for the record object. 

601 If None, no translation is applied. 

602 

603 Returns: 

604 Ret: Status 

605 """ 

606 assert self._document is not None 

607 

608 # The record name will be the admonition. 

609 self._document.add(RstAdmonition(record.name, self._current_file_name)) 

610 

611 # The record fields will be written to a table. 

612 column_titles = ["Attribute Name", "Attribute Value"] 

613 

614 # Build rows for the table. 

615 rows = [] 

616 trlc_ast_walker = self._get_trlc_ast_walker() 

617 for name, value in record.field.items(): 

618 attribute_name = self._translate_attribute_name(translation, name) 

619 attribute_name = RstText.escape(attribute_name) 

620 

621 # Retrieve the attribute value by processing the field value. 

622 # The result will be a string representation of the value. 

623 # If the value is an array of record references, the result will be a Markdown list of links. 

624 # If the value is a single record reference, the result will be a Markdown link. 

625 # If the value is a string literal, the result will be the string literal value that considers 

626 # its formatting. 

627 # Otherwise the result will be the attribute value in a proper format. 

628 self._ast_meta_data = { 

629 "package_name": record.n_package.name, 

630 "type_name": record.n_typ.name, 

631 "attribute_name": name 

632 } 

633 walker_result = trlc_ast_walker.walk(value) 

634 

635 attribute_value = "" 

636 if isinstance(walker_result, list): 

637 attribute_value = RstBulletList(walker_result, False).render() 

638 else: 

639 attribute_value = walker_result 

640 

641 rows.append([attribute_name, attribute_value]) 

642 

643 self._document.add(RstTable(column_titles, rows)) 

644 

645 return Ret.OK 

646 

647# Functions ******************************************************************** 

648 

649# Main *************************************************************************