Coverage for src/pyTRLCConverter/rst_converter.py: 98%
240 statements
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 10:59 +0000
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 10:59 +0000
1"""Converter to reStructuredText format.
3 Author: Gabryel Reyes (gabryel.reyes@newtec.de)
4"""
6# pyTRLCConverter - A tool to convert TRLC files to specific formats.
7# Copyright (c) 2024 - 2025 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/>.
22# Imports **********************************************************************
23import os
24from typing import List, Optional
25from trlc.ast import Implicit_Null, Record_Object, Record_Reference
26from pyTRLCConverter.base_converter import BaseConverter
27from pyTRLCConverter.ret import Ret
28from pyTRLCConverter.trlc_helper import TrlcAstWalker
29from pyTRLCConverter.logger import log_verbose, log_error
31# Variables ********************************************************************
33# Classes **********************************************************************
35class RstConverter(BaseConverter):
36 """
37 RstConverter provides functionality for converting to a reStructuredText format.
38 """
39 OUTPUT_FILE_NAME_DEFAULT = "output.rst"
40 TOP_LEVEL_DEFAULT = "Specification"
42 def __init__(self, args: any) -> None:
43 # lobster-trace: SwRequirements.sw_req_rst
44 """
45 Initializes the converter.
47 Args:
48 args (any): The parsed program arguments.
49 """
50 super().__init__(args)
52 # The path to the given output folder.
53 self._out_path = args.out
55 # The excluded paths in normalized form.
56 self._excluded_paths = []
58 if args.exclude is not None:
59 self._excluded_paths = [os.path.normpath(path) for path in args.exclude]
61 # The file descriptor for the output file.
62 self._fd = None
64 # The base level for the headings. Its the minimum level for the headings which depends
65 # on the single/multiple document mode.
66 self._base_level = 1
68 # For proper reStructuredText formatting, the first written part shall not have an empty line before.
69 # But all following parts (heading, table, paragraph, image, etc.) shall have an empty line before.
70 # And at the document bottom, there shall be just one empty line.
71 self._empty_line_required = False
73 @staticmethod
74 def get_subcommand() -> str:
75 # lobster-trace: SwRequirements.sw_req_rst
76 """
77 Return subcommand token for this converter.
79 Returns:
80 str: Parser subcommand token
81 """
82 return "rst"
84 @staticmethod
85 def get_description() -> str:
86 # lobster-trace: SwRequirements.sw_req_rst
87 """
88 Return converter description.
90 Returns:
91 str: Converter description
92 """
93 return "Convert into reStructuredText format."
95 @classmethod
96 def register(cls, args_parser: any) -> None:
97 # lobster-trace: SwRequirements.sw_req_rst_multiple_doc_mode
98 # lobster-trace: SwRequirements.sw_req_rst_single_doc_mode
99 # lobster-trace: SwRequirements.sw_req_rst_sd_top_level_default
100 # lobster-trace: SwRequirements.sw_req_rst_sd_top_level_custom
101 # lobster-trace: SwRequirements.sw_req_rst_out_file_name_default
102 # lobster-trace: SwRequirements.sw_req_rst_out_file_name_custom
103 """
104 Register converter specific argument parser.
106 Args:
107 args_parser (any): Argument parser
108 """
109 super().register(args_parser)
111 BaseConverter._parser.add_argument(
112 "-e",
113 "--empty",
114 type=str,
115 default=BaseConverter.EMPTY_ATTRIBUTE_DEFAULT,
116 required=False,
117 help="Every attribute value which is empty will output the string " \
118 f"(default = {BaseConverter.EMPTY_ATTRIBUTE_DEFAULT})."
119 )
121 BaseConverter._parser.add_argument(
122 "-n",
123 "--name",
124 type=str,
125 default=RstConverter.OUTPUT_FILE_NAME_DEFAULT,
126 required=False,
127 help="Name of the generated output file inside the output folder " \
128 f"(default = {RstConverter.OUTPUT_FILE_NAME_DEFAULT}) in " \
129 "case a single document is generated."
130 )
132 BaseConverter._parser.add_argument(
133 "-sd",
134 "--single-document",
135 action="store_true",
136 required=False,
137 default=False,
138 help="Generate a single document instead of multiple files. The default is to generate multiple files."
139 )
141 BaseConverter._parser.add_argument(
142 "-tl",
143 "--top-level",
144 type=str,
145 default=RstConverter.TOP_LEVEL_DEFAULT,
146 required=False,
147 help="Name of the top level heading, required in single document mode " \
148 f"(default = {RstConverter.TOP_LEVEL_DEFAULT})."
149 )
151 def begin(self) -> Ret:
152 # lobster-trace: SwRequirements.sw_req_rst_single_doc_mode
153 # lobster-trace: SwRequirements.sw_req_rst_sd_top_level
154 """
155 Begin the conversion process.
157 Returns:
158 Ret: Status
159 """
160 assert self._fd is None
162 # Call the base converter to initialize the common stuff.
163 result = BaseConverter.begin(self)
165 if result == Ret.OK:
167 # Single document mode?
168 if self._args.single_document is True:
169 log_verbose("Single document mode.")
170 else:
171 log_verbose("Multiple document mode.")
173 # Set the value for empty attributes.
174 self._empty_attribute_value = self._args.empty
176 log_verbose(f"Empty attribute value: {self._empty_attribute_value}")
178 # Single document mode?
179 if self._args.single_document is True:
180 result = self._generate_out_file(self._args.name)
182 if self._fd is not None:
183 self._write_empty_line_on_demand()
184 self._fd.write(RstConverter.rst_create_heading(self._args.top_level, 1, self._args.name))
186 # All headings will be shifted by one level.
187 self._base_level = self._base_level + 1
189 return result
191 def enter_file(self, file_name: str) -> Ret:
192 # lobster-trace: SwRequirements.sw_req_rst_multiple_doc_mode
193 """
194 Enter a file.
196 Args:
197 file_name (str): File name
199 Returns:
200 Ret: Status
201 """
202 result = Ret.OK
204 # Multiple document mode?
205 if self._args.single_document is False:
206 assert self._fd is None
208 file_name_rst = self._file_name_trlc_to_rst(file_name)
209 result = self._generate_out_file(file_name_rst)
211 # The very first written reStructuredText part shall not have an empty line before.
212 self._empty_line_required = False
214 return result
216 def leave_file(self, file_name: str) -> Ret:
217 # lobster-trace: SwRequirements.sw_req_rst_multiple_doc_mode
218 """
219 Leave a file.
221 Args:
222 file_name (str): File name
224 Returns:
225 Ret: Status
226 """
228 # Multiple document mode?
229 if self._args.single_document is False:
230 assert self._fd is not None
231 self._fd.close()
232 self._fd = None
234 return Ret.OK
236 def convert_section(self, section: str, level: int) -> Ret:
237 # lobster-trace: SwRequirements.sw_req_rst_section
238 """
239 Process the given section item.
240 It will create a reStructuredText heading with the given section name and level.
242 Args:
243 section (str): The section name
244 level (int): The section indentation level
246 Returns:
247 Ret: Status
248 """
249 assert len(section) > 0
250 assert self._fd is not None
252 self._write_empty_line_on_demand()
253 rst_heading = self.rst_create_heading(section,
254 self._get_rst_heading_level(level),
255 os.path.basename(self._fd.name))
256 self._fd.write(rst_heading)
258 return Ret.OK
260 def convert_record_object_generic(self, record: Record_Object, level: int, translation: Optional[dict]) -> Ret:
261 # lobster-trace: SwRequirements.sw_req_rst_record
262 """
263 Process the given record object in a generic way.
265 The handler is called by the base converter if no specific handler is
266 defined for the record type.
268 Args:
269 record (Record_Object): The record object.
270 level (int): The record level.
271 translation (Optional[dict]): Translation dictionary for the record object.
272 If None, no translation is applied.
274 Returns:
275 Ret: Status
276 """
277 assert self._fd is not None
279 self._write_empty_line_on_demand()
281 return self._convert_record_object(record, level, translation)
283 def finish(self):
284 # lobster-trace: SwRequirements.sw_req_rst_single_doc_mode
285 """
286 Finish the conversion process.
287 """
289 # Single document mode?
290 if self._args.single_document is True:
291 assert self._fd is not None
292 self._fd.close()
293 self._fd = None
295 return Ret.OK
297 def _write_empty_line_on_demand(self) -> None:
298 # lobster-trace: SwRequirements.sw_req_rst
299 """
300 Write an empty line if necessary.
302 For proper reStructuredText formatting, the first written part shall not have an empty
303 line before. But all following parts (heading, table, paragraph, image, etc.) shall
304 have an empty line before. And at the document bottom, there shall be just one empty
305 line.
306 """
307 if self._empty_line_required is False:
308 self._empty_line_required = True
309 else:
310 self._fd.write("\n")
312 def _get_rst_heading_level(self, level: int) -> int:
313 # lobster-trace: SwRequirements.sw_req_rst_section
314 """
315 Get the reStructuredText heading level from the TRLC object level.
316 Its mandatory to use this method to calculate the reStructuredText heading level.
317 Otherwise in single document mode the top level heading will be wrong.
319 Args:
320 level (int): The TRLC object level.
322 Returns:
323 int: reStructuredText heading level
324 """
325 return self._base_level + level
327 def _file_name_trlc_to_rst(self, file_name_trlc: str) -> str:
328 # lobster-trace: SwRequirements.sw_req_rst_multiple_doc_mode
329 """
330 Convert a TRLC file name to a reStructuredText file name.
332 Args:
333 file_name_trlc (str): TRLC file name
335 Returns:
336 str: reStructuredText file name
337 """
338 file_name = os.path.basename(file_name_trlc)
339 file_name = os.path.splitext(file_name)[0] + ".rst"
341 return file_name
343 def _generate_out_file(self, file_name: str) -> Ret:
344 # lobster-trace: SwRequirements.sw_req_rst_out_folder
345 """
346 Generate the output file.
348 Args:
349 file_name (str): The output file name without path.
350 item_list ([Element]): List of elements.
352 Returns:
353 Ret: Status
354 """
355 result = Ret.OK
356 file_name_with_path = file_name
358 # Add path to the output file name.
359 if 0 < len(self._out_path):
360 file_name_with_path = os.path.join(self._out_path, file_name)
362 try:
363 self._fd = open(file_name_with_path, "w", encoding="utf-8") #pylint: disable=consider-using-with
364 except IOError as e:
365 log_error(f"Failed to open file {file_name_with_path}: {e}")
366 result = Ret.ERROR
368 return result
370 def _on_implict_null(self, _: Implicit_Null) -> str:
371 # lobster-trace: SwRequirements.sw_req_rst_record
372 """
373 Process the given implicit null value.
375 Returns:
376 str: The implicit null value
377 """
378 return self.rst_escape(self._empty_attribute_value)
380 def _on_record_reference(self, record_reference: Record_Reference) -> str:
381 # lobster-trace: SwRequirements.sw_req_rst_record
382 """
383 Process the given record reference value and return a reStructuredText link.
385 Args:
386 record_reference (Record_Reference): The record reference value.
388 Returns:
389 str: reStructuredText link to the record reference.
390 """
391 return self._create_rst_link_from_record_object_reference(record_reference)
393 def _create_rst_link_from_record_object_reference(self, record_reference: Record_Reference) -> str:
394 # lobster-trace: SwRequirements.sw_req_rst_link
395 """
396 Create a reStructuredText cross-reference from a record reference.
397 It considers the file name, the package name, and the record name.
399 Args:
400 record_reference (Record_Reference): Record reference
402 Returns:
403 str: reStructuredText cross-reference
404 """
405 file_name = ""
407 # Single document mode?
408 if self._args.single_document is True:
409 file_name = self._args.name
411 # Is the link to a excluded file?
412 for excluded_path in self._excluded_paths:
414 if os.path.commonpath([excluded_path, record_reference.target.location.file_name]) == excluded_path:
415 file_name = self._file_name_trlc_to_rst(record_reference.target.location.file_name)
416 break
418 # Multiple document mode
419 else:
420 file_name = self._file_name_trlc_to_rst(record_reference.target.location.file_name)
422 record_name = record_reference.target.name
424 # Create a target ID for the record
425 target_id = f"{file_name}-{record_name.lower().replace(' ', '-')}"
427 return RstConverter.rst_create_link(str(record_reference.to_python_object()), target_id)
429 def _get_trlc_ast_walker(self) -> TrlcAstWalker:
430 # lobster-trace: SwRequirements.sw_req_rst_record
431 """
432 If a record object contains a record reference, the record reference will be converted to
433 a Markdown link.
434 If a record object contains an array of record references, the array will be converted to
435 a reStructuredText list of links.
436 Otherwise the record object fields attribute values will be written to the reStructuredText table.
438 Returns:
439 TrlcAstWalker: The TRLC AST walker.
440 """
441 trlc_ast_walker = TrlcAstWalker()
442 trlc_ast_walker.add_dispatcher(
443 Implicit_Null,
444 None,
445 self._on_implict_null,
446 None
447 )
448 trlc_ast_walker.add_dispatcher(
449 Record_Reference,
450 None,
451 self._on_record_reference,
452 None
453 )
454 trlc_ast_walker.set_other_dispatcher(
455 lambda expression: RstConverter.rst_escape(str(expression.to_python_object()))
456 )
458 return trlc_ast_walker
460 # pylint: disable=too-many-locals, unused-argument
461 def _convert_record_object(self, record: Record_Object, level: int, translation: Optional[dict]) -> Ret:
462 # lobster-trace: SwRequirements.sw_req_rst_record
463 """
464 Process the given record object.
466 Args:
467 record (Record_Object): The record object.
468 level (int): The record level.
469 translation (Optional[dict]): Translation dictionary for the record object.
470 If None, no translation is applied.
472 Returns:
473 Ret: Status
474 """
475 assert self._fd is not None
477 # The record name will be the admonition.
478 file_name = os.path.basename(self._fd.name)
479 rst_heading = self.rst_create_admonition(record.name,
480 file_name)
481 self._fd.write(rst_heading)
483 self._write_empty_line_on_demand()
485 # The record fields will be written to a table.
486 column_titles = ["Attribute Name", "Attribute Value"]
488 # Build rows for the table.
489 # Its required to calculate the maximum width for each column, therefore the rows
490 # will be stored first in a list and then the maximum width will be calculated.
491 # The table will be written after the maximum width calculation.
492 rows = []
493 trlc_ast_walker = self._get_trlc_ast_walker()
494 for name, value in record.field.items():
495 attribute_name = name
496 if translation is not None and name in translation:
497 attribute_name = translation[name]
498 attribute_name = self.rst_escape(attribute_name)
500 # Retrieve the attribute value by processing the field value.
501 walker_result = trlc_ast_walker.walk(value)
503 attribute_value = ""
504 if isinstance(walker_result, list):
505 attribute_value = self.rst_create_list(walker_result, False)
506 else:
507 attribute_value = walker_result
509 rows.append([attribute_name, attribute_value])
511 # Calculate the maximum width of each column based on both headers and row values.
512 max_widths = [len(title) for title in column_titles]
513 for row in rows:
514 for idx, value in enumerate(row):
515 lines = value.split('\n')
516 for line in lines:
517 max_widths[idx] = max(max_widths[idx], len(line))
519 # Write the table head and rows.
520 rst_table_head = self.rst_create_table_head(column_titles, max_widths)
521 self._fd.write(rst_table_head)
523 for row in rows:
524 rst_table_row = self.rst_append_table_row(row, max_widths, False)
525 self._fd.write(rst_table_row)
527 return Ret.OK
529 @staticmethod
530 def rst_escape(text: str) -> str:
531 # lobster-trace: SwRequirements.sw_req_rst_escape
532 """
533 Escapes the text to be used in a reStructuredText document.
535 Args:
536 text (str): Text to escape
538 Returns:
539 str: Escaped text
540 """
541 characters = ["\\", "`", "*", "_", "{", "}", "[", "]", "<", ">", "(", ")", "#", "+", "-", ".", "!", "|"]
543 for character in characters:
544 text = text.replace(character, "\\" + character)
546 return text
548 @staticmethod
549 def rst_create_heading(text: str,
550 level: int,
551 file_name: str,
552 escape: bool = True) -> str:
553 # lobster-trace: SwRequirements.sw_req_rst_heading
554 """
555 Create a reStructuredText heading with a label.
556 The text will be automatically escaped for reStructuredText if necessary.
558 Args:
559 text (str): Heading text
560 level (int): Heading level [1; 7]
561 file_name (str): File name where the heading is found
562 escape (bool): Escape the text (default: True).
564 Returns:
565 str: reStructuredText heading with a label
566 """
567 result = ""
569 if 1 <= level <= 7:
570 text_raw = text
572 if escape is True:
573 text_raw = RstConverter.rst_escape(text)
575 label = f"{file_name}-{text_raw.lower().replace(' ', '-')}"
577 underline_char = ["=", "#", "~", "^", "\"", "+", "'"][level - 1]
578 underline = underline_char * len(text_raw)
580 result = f".. _{label}:\n\n{text_raw}\n{underline}\n"
582 else:
583 log_error(f"Invalid heading level {level} for {text}.")
585 return result
587 @staticmethod
588 def rst_create_admonition(text: str,
589 file_name: str,
590 escape: bool = True) -> str:
591 # lobster-trace: SwRequirements.sw_req_rst_admonition
592 """
593 Create a reStructuredText admonition with a label.
594 The text will be automatically escaped for reStructuredText if necessary.
596 Args:
597 text (str): Admonition text
598 file_name (str): File name where the heading is found
599 escape (bool): Escape the text (default: True).
601 Returns:
602 str: reStructuredText admonition with a label
603 """
604 text_raw = text
606 if escape is True:
607 text_raw = RstConverter.rst_escape(text)
609 label = f"{file_name}-{text_raw.lower().replace(' ', '-')}"
610 admonition_label = f".. admonition:: {text_raw}"
612 return f".. _{label}:\n\n{admonition_label}\n"
614 @staticmethod
615 def rst_create_table_head(column_titles: List[str], max_widths: List[int], escape: bool = True) -> str:
616 # lobster-trace: SwRequirements.sw_req_rst_table
617 """
618 Create the table head for a reStructuredText table in grid format.
619 The titles will be automatically escaped for reStructuredText if necessary.
621 Args:
622 column_titles ([str]): List of column titles.
623 max_widths ([int]): List of maximum widths for each column.
624 escape (bool): Escape the titles (default: True).
626 Returns:
627 str: Table head
628 """
629 if escape:
630 column_titles = [RstConverter.rst_escape(title) for title in column_titles]
632 # Create the top border of the table
633 table_head = " +" + "+".join(["-" * (width + 2) for width in max_widths]) + "+\n"
635 # Create the title row
636 table_head += " |"
637 table_head += "|".join([f" {title.ljust(max_widths[idx])} " for idx, title in enumerate(column_titles)]) + "|\n"
639 # Create the separator row
640 table_head += " +" + "+".join(["=" * (width + 2) for width in max_widths]) + "+\n"
642 return table_head
644 @staticmethod
645 def rst_append_table_row(row_values: List[str], max_widths: List[int], escape: bool = True) -> str:
646 # lobster-trace: SwRequirements.sw_req_rst_table
647 """
648 Append a row to a reStructuredText table in grid format.
649 The values will be automatically escaped for reStructuredText if necessary.
650 Supports multi-line cell values.
652 Args:
653 row_values ([str]): List of row values.
654 max_widths ([int]): List of maximum widths for each column.
655 escape (bool): Escapes every row value (default: True).
657 Returns:
658 str: Table row
659 """
660 if escape:
661 row_values = [RstConverter.rst_escape(value) for value in row_values]
663 # Split each cell value into lines.
664 split_values = [value.split('\n') for value in row_values]
665 max_lines = max(len(lines) for lines in split_values)
667 # Create the row with multi-line support.
668 table_row = ""
669 for line_idx in range(max_lines):
670 table_row += " |"
671 for col_idx, lines in enumerate(split_values):
672 if line_idx < len(lines):
673 table_row += f" {lines[line_idx].ljust(max_widths[col_idx])} "
674 else:
675 table_row += " " * (max_widths[col_idx] + 2)
676 table_row += "|"
677 table_row += "\n"
679 # Create the separator row.
680 separator_row = " +" + "+".join(["-" * (width + 2) for width in max_widths]) + "+\n"
682 return table_row + separator_row
684 @staticmethod
685 def rst_create_list(list_values: List[str], escape: bool = True) -> str:
686 # lobster-trace: SwRequirements.sw_req_rst_list
687 """Create a unordered reStructuredText list.
688 The values will be automatically escaped for reStructuredText if necessary.
690 Args:
691 list_values (List[str]): List of list values.
692 escape (bool): Escapes every list value (default: True).
694 Returns:
695 str: reStructuredText list
696 """
697 list_str = ""
699 for idx, value_raw in enumerate(list_values):
700 value = value_raw
702 if escape is True: # Escape the value if necessary.
703 value = RstConverter.rst_escape(value)
705 list_str += f"* {value}"
707 # The last list value must not have a newline at the end.
708 if idx < len(list_values) - 1:
709 list_str += "\n"
711 return list_str
713 @staticmethod
714 def rst_create_link(text: str, target: str, escape: bool = True) -> str:
715 # lobster-trace: SwRequirements.sw_req_rst_link
716 """
717 Create a reStructuredText cross-reference.
718 The text will be automatically escaped for reStructuredText if necessary.
719 There will be no newline appended at the end.
721 Args:
722 text (str): Link text
723 target (str): Cross-reference target
724 escape (bool): Escapes text (default: True).
726 Returns:
727 str: reStructuredText cross-reference
728 """
729 text_raw = text
731 if escape is True:
732 text_raw = RstConverter.rst_escape(text)
734 return f":ref:`{text_raw} <{target}>`"
736 @staticmethod
737 def rst_create_diagram_link(diagram_file_name: str, diagram_caption: str, escape: bool = True) -> str:
738 # lobster-trace: SwRequirements.sw_req_rst_image
739 """
740 Create a reStructuredText diagram link.
741 The caption will be automatically escaped for reStructuredText if necessary.
743 Args:
744 diagram_file_name (str): Diagram file name
745 diagram_caption (str): Diagram caption
746 escape (bool): Escapes caption (default: True).
748 Returns:
749 str: reStructuredText diagram link
750 """
751 diagram_caption_raw = diagram_caption
753 if escape is True:
754 diagram_caption_raw = RstConverter.rst_escape(diagram_caption)
756 # Allowed are absolute and relative to source paths.
757 diagram_file_name = os.path.normpath(diagram_file_name)
759 result = f".. figure:: {diagram_file_name}\n :alt: {diagram_caption_raw}\n"
761 if diagram_caption_raw:
762 result += f"\n {diagram_caption_raw}\n"
764 return result
766 @staticmethod
767 def rst_role(text: str, role: str, escape: bool = True) -> str:
768 # lobster-trace: SwRequirements.sw_req_rst_role
769 """
770 Create role text in reStructuredText.
771 The text will be automatically escaped for reStructuredText if necessary.
772 There will be no newline appended at the end.
774 Args:
775 text (str): Text
776 color (str): Role
777 escape (bool): Escapes text (default: True).
779 Returns:
780 str: Text with role
781 """
782 text_raw = text
784 if escape is True:
785 text_raw = RstConverter.rst_escape(text)
787 return f":{role}:`{text_raw}`"
789# Functions ********************************************************************
791# Main *************************************************************************