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
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-26 12:41 +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 - 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/>.
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
39# Variables ********************************************************************
41# Classes **********************************************************************
43# pylint: disable-next=too-many-instance-attributes
44class RstConverter(BaseConverter):
45 """
46 RstConverter provides functionality for converting to a reStructuredText format.
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"
56 def __init__(self, args: Any) -> None:
57 # lobster-trace: SwRequirements.sw_req_rst
58 """
59 Initializes the converter.
61 Args:
62 args (Any): The parsed program arguments.
63 """
64 super().__init__(args)
66 # The path to the given output folder.
67 self._out_path = args.out
69 # The excluded paths in normalized form.
70 self._excluded_paths = []
72 if args.exclude is not None:
73 self._excluded_paths = [os.path.normpath(path) for path in args.exclude]
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
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 = ""
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
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
91 self._plantuml_tmp_dir: Optional[tempfile.TemporaryDirectory] = None
92 self._external_files: list = []
94 @staticmethod
95 def get_subcommand() -> str:
96 # lobster-trace: SwRequirements.sw_req_rst
97 """
98 Return subcommand token for this converter.
100 Returns:
101 str: Parser subcommand token
102 """
103 return "rst"
105 @staticmethod
106 def get_description() -> str:
107 # lobster-trace: SwRequirements.sw_req_rst
108 """
109 Return converter description.
111 Returns:
112 str: Converter description
113 """
114 return "Convert into reStructuredText format."
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.
127 Args:
128 args_parser (Any): Argument parser
129 """
130 super().register(args_parser)
132 assert BaseConverter._parser is not None
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 )
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 )
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 )
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 )
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.
180 Returns:
181 Ret: Status
182 """
183 assert self._document is None
185 # Call the base converter to initialize the common stuff.
186 result = BaseConverter.begin(self)
188 if result == Ret.OK:
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.")
196 # Set the value for empty attributes.
197 self._empty_attribute_value = self._args.empty
199 log_verbose(f"Empty attribute value: {self._empty_attribute_value}")
201 # pylint: disable-next=consider-using-with
202 self._plantuml_tmp_dir = tempfile.TemporaryDirectory(prefix="pyTRLCConverter_rst_")
204 # Single document mode?
205 if self._args.single_document is True:
206 self._document = RstDocument()
207 self._current_file_name = self._args.name
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))
212 # All headings will be shifted by one level.
213 self._base_level = self._base_level + 1
215 return result
217 def enter_file(self, file_name: str) -> Ret:
218 # lobster-trace: SwRequirements.sw_req_rst_multiple_doc_mode
219 """
220 Enter a file.
222 Args:
223 file_name (str): File name
225 Returns:
226 Ret: Status
227 """
228 # Multiple document mode?
229 if self._args.single_document is False:
230 assert self._document is None
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)
237 return Ret.OK
239 def leave_file(self, file_name: str) -> Ret:
240 # lobster-trace: SwRequirements.sw_req_rst_multiple_doc_mode
241 """
242 Leave a file.
244 Args:
245 file_name (str): File name
247 Returns:
248 Ret: Status
249 """
250 result = Ret.OK
252 # Multiple document mode?
253 if self._args.single_document is False:
254 assert self._document is not None
256 result = self._write_document(self._current_file_name)
258 self._copy_external_files(self._out_path)
259 self._external_files = []
260 self._document = None
262 return result
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.
270 Args:
271 section (str): The section name
272 level (int): The section indentation level
274 Returns:
275 Ret: Status
276 """
277 assert len(section) > 0
278 assert self._document is not None
280 self._document.add(RstHeading(section, self._get_rst_heading_level(level), self._current_file_name))
282 return Ret.OK
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.
289 The handler is called by the base converter if no specific handler is
290 defined for the record type.
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.
298 Returns:
299 Ret: Status
300 """
301 assert self._document is not None
303 return self._convert_record_object(record, level, translation)
305 def finish(self):
306 # lobster-trace: SwRequirements.sw_req_rst_single_doc_mode
307 """
308 Finish the conversion process.
310 Returns:
311 Ret: Status
312 """
313 result = Ret.OK
315 # Single document mode?
316 if self._args.single_document is True:
317 assert self._document is not None
319 result = self._write_document(self._current_file_name)
321 self._copy_external_files(self._out_path)
322 self._external_files = []
323 self._document = None
325 if self._plantuml_tmp_dir is not None:
326 self._plantuml_tmp_dir.cleanup()
327 self._plantuml_tmp_dir = None
329 return result
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.
338 Args:
339 level (int): The TRLC object level.
341 Returns:
342 int: reStructuredText heading level
343 """
344 return self._base_level + level
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.
351 Args:
352 file_name_trlc (str): TRLC file name
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"
360 return file_name
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.
367 Args:
368 file_name (str): The output file name without path.
370 Returns:
371 Ret: Status
372 """
373 assert self._document is not None
375 result = Ret.OK
376 file_name_with_path = file_name
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)
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
389 return result
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.
396 Returns:
397 str: The implicit null value.
398 """
399 return RstText.escape(self._empty_attribute_value)
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.
406 Args:
407 record_reference (Record_Reference): The record reference value.
409 Returns:
410 str: reStructuredText link to the record reference.
411 """
412 return self._create_rst_link_from_record_object_reference(record_reference)
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.
421 Args:
422 string_literal (String_Literal): The string literal value.
424 Returns:
425 str: The string literal value.
426 """
427 result = string_literal.to_string()
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", "")
434 result = self._render(package_name, type_name, attribute_name, result)
436 return result
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.
444 Args:
445 record_reference (Record_Reference): Record reference
447 Returns:
448 str: reStructuredText cross-reference
449 """
450 assert record_reference.target is not None
452 file_name = ""
454 # Single document mode?
455 if self._args.single_document is True:
456 file_name = self._args.name
458 # Is the link to a excluded file?
459 for excluded_path in self._excluded_paths:
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
465 # Multiple document mode
466 else:
467 file_name = self._file_name_trlc_to_rst(record_reference.target.location.file_name)
469 record_name = record_reference.target.name
471 # Create a target ID for the record
472 target_id = f"{file_name}-{record_name.lower().replace(' ', '-')}"
474 return RstText.link(str(record_reference.to_python_object()), target_id)
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.
482 Args:
483 expression (Expression): The expression to process.
485 Returns:
486 str: The processed expression.
487 """
488 return RstText.escape(expression.to_string())
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.
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)
525 return trlc_ast_walker
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.
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.
540 Returns:
541 str: The rendered attribute value.
542 """
543 result = attribute_value
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:
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)
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)
564 # Otherwise escape the text for reStructuredText.
565 else:
566 result = RstText.escape(attribute_value)
568 return result
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.
574 Args:
575 dest_dir (str): Destination directory path.
576 """
577 copied_sources = set()
579 for source_path, local_name in self._external_files:
580 if source_path in copied_sources:
581 continue
583 dest_path = os.path.join(dest_dir, local_name)
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)
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.
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.
603 Returns:
604 Ret: Status
605 """
606 assert self._document is not None
608 # The record name will be the admonition.
609 self._document.add(RstAdmonition(record.name, self._current_file_name))
611 # The record fields will be written to a table.
612 column_titles = ["Attribute Name", "Attribute Value"]
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)
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)
635 attribute_value = ""
636 if isinstance(walker_result, list):
637 attribute_value = RstBulletList(walker_result, False).render()
638 else:
639 attribute_value = walker_result
641 rows.append([attribute_name, attribute_value])
643 self._document.add(RstTable(column_titles, rows))
645 return Ret.OK
647# Functions ********************************************************************
649# Main *************************************************************************