Coverage for src/pyTRLCConverter/markdown_converter.py: 97%
237 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 Markdown format.
3 Author: Andreas Merkle (andreas.merkle@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 **********************************************************************
35# pylint: disable=too-many-instance-attributes
36class MarkdownConverter(BaseConverter):
37 """
38 MarkdownConverter provides functionality for converting to a markdown format.
39 """
41 OUTPUT_FILE_NAME_DEFAULT = "output.md"
42 TOP_LEVEL_DEFAULT = "Specification"
44 def __init__(self, args: any) -> None:
45 # lobster-trace: SwRequirements.sw_req_no_prj_spec
46 # lobster-trace: SwRequirements.sw_req_markdown
47 """
48 Initializes the converter.
50 Args:
51 args (any): The parsed program arguments.
52 """
53 super().__init__(args)
55 # The path to the given output folder.
56 self._out_path = args.out
58 # The excluded files in normalized form.
59 self._excluded_files = []
61 if args.exclude is not None:
62 self._excluded_paths = [os.path.normpath(path) for path in args.exclude]
64 # The file descriptor for the output file.
65 self._fd = None
67 # The base level for the headings. Its the minimum level for the headings which depends
68 # on the single/multiple document mode.
69 self._base_level = 1
71 # For proper Markdown formatting, the first written Markdown part shall not have an empty line before.
72 # But all following parts (heading, table, paragraph, image, etc.) shall have an empty line before.
73 # And at the document bottom, there shall be just one empty line.
74 self._empty_line_required = False
76 # A top level heading is always required to generate a compliant Markdown document.
77 # In single document mode it will always be necessary.
78 # In multiple document mode only if there is no top level section.
79 self._is_top_level_heading_req = True
81 @staticmethod
82 def get_subcommand() -> str:
83 # lobster-trace: SwRequirements.sw_req_markdown
84 """
85 Return subcommand token for this converter.
87 Returns:
88 str: Parser subcommand token
89 """
90 return "markdown"
92 @staticmethod
93 def get_description() -> str:
94 # lobster-trace: SwRequirements.sw_req_markdown
95 """
96 Return converter description.
98 Returns:
99 str: Converter description
100 """
101 return "Convert into markdown format."
103 @classmethod
104 def register(cls, args_parser: any) -> None:
105 # lobster-trace: SwRequirements.sw_req_markdown_multiple_doc_mode
106 # lobster-trace: SwRequirements.sw_req_markdown_single_doc_mode
107 # lobster-trace: SwRequirements.sw_req_markdown_top_level_default
108 # lobster-trace: SwRequirements.sw_req_markdown_top_level_custom
109 # lobster-trace: SwRequirements.sw_req_markdown_out_file_name_default
110 # lobster-trace: SwRequirements.sw_req_markdown_out_file_name_custom
111 """
112 Register converter specific argument parser.
114 Args:
115 args_parser (any): Argument parser
116 """
117 super().register(args_parser)
119 BaseConverter._parser.add_argument(
120 "-e",
121 "--empty",
122 type=str,
123 default=BaseConverter.EMPTY_ATTRIBUTE_DEFAULT,
124 required=False,
125 help="Every attribute value which is empty will output the string " \
126 f"(default = {BaseConverter.EMPTY_ATTRIBUTE_DEFAULT})."
127 )
129 BaseConverter._parser.add_argument(
130 "-n",
131 "--name",
132 type=str,
133 default=MarkdownConverter.OUTPUT_FILE_NAME_DEFAULT,
134 required=False,
135 help="Name of the generated output file inside the output folder " \
136 f"(default = {MarkdownConverter.OUTPUT_FILE_NAME_DEFAULT}) in " \
137 "case a single document is generated."
138 )
140 BaseConverter._parser.add_argument(
141 "-sd",
142 "--single-document",
143 action="store_true",
144 required=False,
145 default=False,
146 help="Generate a single document instead of multiple files. The default is to generate multiple files."
147 )
149 BaseConverter._parser.add_argument(
150 "-tl",
151 "--top-level",
152 type=str,
153 default=MarkdownConverter.TOP_LEVEL_DEFAULT,
154 required=False,
155 help="Name of the top level heading, required in single document mode " \
156 f"(default = {MarkdownConverter.TOP_LEVEL_DEFAULT})."
157 )
159 def begin(self) -> Ret:
160 # lobster-trace: SwRequirements.sw_req_markdown_single_doc_mode
161 # lobster-trace: SwRequirements.sw_req_markdown_sd_top_level
162 """
163 Begin the conversion process.
165 Returns:
166 Ret: Status
167 """
168 assert self._fd is None
170 # Call the base converter to initialize the common stuff.
171 result = BaseConverter.begin(self)
173 if result == Ret.OK:
175 # Single document mode?
176 if self._args.single_document is True:
177 log_verbose("Single document mode.")
178 else:
179 log_verbose("Multiple document mode.")
181 # Set the value for empty attributes.
182 self._empty_attribute_value = self._args.empty
184 log_verbose(f"Empty attribute value: {self._empty_attribute_value}")
186 # Single document mode?
187 if self._args.single_document is True:
188 result = self._generate_out_file(self._args.name)
190 if self._fd is not None:
191 self._write_top_level_heading_on_demand()
193 # All headings will be shifted by one level.
194 self._base_level = self._base_level + 1
196 return result
198 def enter_file(self, file_name: str) -> Ret:
199 # lobster-trace: SwRequirements.sw_req_markdown_multiple_doc_mode
200 """
201 Enter a file.
203 Args:
204 file_name (str): File name
206 Returns:
207 Ret: Status
208 """
209 result = Ret.OK
211 # Multiple document mode?
212 if self._args.single_document is False:
213 assert self._fd is None
215 file_name_md = self._file_name_trlc_to_md(file_name)
216 result = self._generate_out_file(file_name_md)
218 # The very first written Markdown part shall not have a empty line before.
219 self._empty_line_required = False
221 return result
223 def leave_file(self, file_name: str) -> Ret:
224 # lobster-trace: SwRequirements.sw_req_markdown_multiple_doc_mode
225 """
226 Leave a file.
228 Args:
229 file_name (str): File name
231 Returns:
232 Ret: Status
233 """
235 # Multiple document mode?
236 if self._args.single_document is False:
237 assert self._fd is not None
238 self._fd.close()
239 self._fd = None
240 self._is_top_level_heading_req = True
242 return Ret.OK
244 def convert_section(self, section: str, level: int) -> Ret:
245 # lobster-trace: SwRequirements.sw_req_markdown_section
246 # lobster-trace: SwRequirements.sw_req_markdown_md_top_level
247 """
248 Process the given section item.
249 It will create a Markdown heading with the given section name and level.
251 Args:
252 section (str): The section name
253 level (int): The section indentation level
255 Returns:
256 Ret: Status
257 """
258 assert len(section) > 0
259 assert self._fd is not None
261 self._write_empty_line_on_demand()
262 markdown_heading = self.markdown_create_heading(section, self._get_markdown_heading_level(level))
263 self._fd.write(markdown_heading)
265 # If a section heading is written, there is no top level heading required anymore.
266 self._is_top_level_heading_req = False
268 return Ret.OK
270 def convert_record_object_generic(self, record: Record_Object, level: int, translation: Optional[dict]) -> Ret:
271 # lobster-trace: SwRequirements.sw_req_markdown_record
272 # lobster-trace: SwRequirements.sw_req_markdown_md_top_level
273 """
274 Process the given record object in a generic way.
276 The handler is called by the base converter if no specific handler is
277 defined for the record type.
279 Args:
280 record (Record_Object): The record object.
281 level (int): The record level.
282 translation (Optional[dict]): Translation dictionary for the record object.
283 If None, no translation is applied.
285 Returns:
286 Ret: Status
287 """
288 assert self._fd is not None
290 self._write_top_level_heading_on_demand()
291 self._write_empty_line_on_demand()
293 return self._convert_record_object(record, level, translation)
295 def finish(self):
296 # lobster-trace: SwRequirements.sw_req_markdown_single_doc_mode
297 """
298 Finish the conversion process.
299 """
301 # Single document mode?
302 if self._args.single_document is True:
303 assert self._fd is not None
304 self._fd.close()
305 self._fd = None
307 return Ret.OK
309 def _write_top_level_heading_on_demand(self) -> None:
310 # lobster-trace: SwRequirements.sw_req_markdown_md_top_level
311 # lobster-trace: SwRequirements.sw_req_markdown_sd_top_level
312 """Write the top level heading if necessary.
313 """
314 if self._is_top_level_heading_req is True:
315 self._fd.write(MarkdownConverter.markdown_create_heading(self._args.top_level, 1))
316 self._empty_line_required = True
317 self._is_top_level_heading_req = False
319 def _write_empty_line_on_demand(self) -> None:
320 # lobster-trace: SwRequirements.sw_req_markdown
321 """
322 Write an empty line if necessary.
324 For proper Markdown formatting, the first written part shall not have an empty
325 line before. But all following parts (heading, table, paragraph, image, etc.) shall
326 have an empty line before. And at the document bottom, there shall be just one empty
327 line.
328 """
329 if self._empty_line_required is False:
330 self._empty_line_required = True
331 else:
332 self._fd.write("\n")
334 def _get_markdown_heading_level(self, level: int) -> int:
335 # lobster-trace: SwRequirements.sw_req_markdown_record
336 """Get the Markdown heading level from the TRLC object level.
337 Its mandatory to use this method to calculate the Markdown heading level.
338 Otherwise in single document mode the top level heading will be wrong.
340 Args:
341 level (int): The TRLC object level.
343 Returns:
344 int: Markdown heading level
345 """
346 return self._base_level + level
348 def _file_name_trlc_to_md(self, file_name_trlc: str) -> str:
349 # lobster-trace: SwRequirements.sw_req_markdown_multiple_doc_mode
350 """
351 Convert a TRLC file name to a Markdown file name.
353 Args:
354 file_name_trlc (str): TRLC file name
356 Returns:
357 str: Markdown file name
358 """
359 file_name = os.path.basename(file_name_trlc)
360 file_name = os.path.splitext(file_name)[0] + ".md"
362 return file_name
364 def _generate_out_file(self, file_name: str) -> Ret:
365 # lobster-trace: SwRequirements.sw_req_markdown_out_folder
366 """
367 Generate the output file.
369 Args:
370 file_name (str): The output file name without path.
371 item_list ([Element]): List of elements.
373 Returns:
374 Ret: Status
375 """
376 result = Ret.OK
377 file_name_with_path = file_name
379 # Add path to the output file name.
380 if 0 < len(self._out_path):
381 file_name_with_path = os.path.join(self._out_path, file_name)
383 try:
384 self._fd = open(file_name_with_path, "w", encoding="utf-8") #pylint: disable=consider-using-with
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_implict_null(self, _: Implicit_Null) -> str:
392 # lobster-trace: SwRequirements.sw_req_markdown_record
393 """
394 Process the given implicit null value.
396 Returns:
397 str: The implicit null value
398 """
399 return self.markdown_escape(self._empty_attribute_value)
401 def _on_record_reference(self, record_reference: Record_Reference) -> str:
402 # lobster-trace: SwRequirements.sw_req_markdown_record
403 """
404 Process the given record reference value and return a markdown link.
406 Args:
407 record_reference (Record_Reference): The record reference value.
409 Returns:
410 str: Markdown link to the record reference.
411 """
412 return self._create_markdown_link_from_record_object_reference(record_reference)
414 # pylint: disable=line-too-long
415 def _create_markdown_link_from_record_object_reference(self, record_reference: Record_Reference) -> str:
416 # lobster-trace: SwRequirements.sw_req_markdown_record
417 """
418 Create a Markdown link from a record reference.
419 It considers the file name, the package name, and the record name.
421 Args:
422 record_reference (Record_Reference): Record reference
424 Returns:
425 str: Markdown link
426 """
427 file_name = ""
429 # Single document mode?
430 if self._args.single_document is True:
431 file_name = self._args.name
433 # Is the link to a excluded file?
434 for excluded_path in self._excluded_paths:
436 if os.path.commonpath([excluded_path, record_reference.target.location.file_name]) == excluded_path:
437 file_name = self._file_name_trlc_to_md(record_reference.target.location.file_name)
438 break
440 # Multiple document mode
441 else:
442 file_name = self._file_name_trlc_to_md(record_reference.target.location.file_name)
444 record_name = record_reference.target.name
446 anchor_tag = file_name + "#" + record_name.lower().replace(" ", "-")
448 return MarkdownConverter.markdown_create_link(str(record_reference.to_python_object()), anchor_tag)
450 def _get_trlc_ast_walker(self) -> TrlcAstWalker:
451 # lobster-trace: SwRequirements.sw_req_markdown_record
452 """
453 If a record object contains a record reference, the record reference will be converted to
454 a Markdown link.
455 If a record object contains an array of record references, the array will be converted to
456 a Markdown list of links.
457 Otherwise the record object fields attribute values will be written to the Markdown table.
459 Returns:
460 TrlcAstWalker: The TRLC AST walker.
461 """
462 trlc_ast_walker = TrlcAstWalker()
463 trlc_ast_walker.add_dispatcher(
464 Implicit_Null,
465 None,
466 self._on_implict_null,
467 None
468 )
469 trlc_ast_walker.add_dispatcher(
470 Record_Reference,
471 None,
472 self._on_record_reference,
473 None
474 )
475 trlc_ast_walker.set_other_dispatcher(
476 lambda expression: MarkdownConverter.markdown_escape(str(expression.to_python_object()))
477 )
479 return trlc_ast_walker
481 # pylint: disable=too-many-locals
482 def _convert_record_object(self, record: Record_Object, level: int, translation: Optional[dict]) -> Ret:
483 # lobster-trace: SwRequirements.sw_req_markdown_record
484 """
485 Process the given record object.
487 Args:
488 record (Record_Object): The record object.
489 level (int): The record level.
490 translation (Optional[dict]): Translation dictionary for the record object.
491 If None, no translation is applied.
493 Returns:
494 Ret: Status
495 """
496 assert self._fd is not None
498 # The record name will be the heading.
499 markdown_heading = self.markdown_create_heading(record.name, self._get_markdown_heading_level(level + 1))
500 self._fd.write(markdown_heading)
501 self._fd.write("\n")
503 # The record fields will be written to a table.
504 # First write the table head.
505 column_titles = ["Attribute Name", "Attribute Value"]
506 markdown_table_head = self.markdown_create_table_head(column_titles)
507 self._fd.write(markdown_table_head)
509 # Walk through the record object fields and write the table rows.
510 trlc_ast_walker = self._get_trlc_ast_walker()
512 for name, value in record.field.items():
513 # Translate the attribute name if available.
514 attribute_name = name
515 if translation is not None:
516 if name in translation:
517 attribute_name = translation[name]
519 attribute_name = self.markdown_escape(attribute_name)
521 # Retrieve the attribute value by processing the field value.
522 walker_result = trlc_ast_walker.walk(value)
524 attribute_value = ""
525 if isinstance(walker_result, list):
526 attribute_value = self.markdown_create_list(walker_result, True, False)
527 else:
528 attribute_value = walker_result
530 # Write the attribute name and value to the Markdown table as row.
531 markdown_table_row = self.markdown_append_table_row([attribute_name, attribute_value], False)
532 self._fd.write(markdown_table_row)
534 return Ret.OK
536 @staticmethod
537 def markdown_escape(text: str) -> str:
538 # lobster-trace: SwRequirements.sw_req_markdown_escape
539 """
540 Escapes the text to be used in a Markdown document.
542 Args:
543 text (str): Text to escape
545 Returns:
546 str: Escaped text
547 """
548 characters = ["\\", "`", "*", "_", "{", "}", "[", "]", "<", ">", "(", ")", "#", "+", "-", ".", "!", "|"]
550 for character in characters:
551 text = text.replace(character, "\\" + character)
553 return text
555 @staticmethod
556 def markdown_lf2soft_return(text: str) -> str:
557 # lobster-trace: SwRequirements.sw_req_markdown_soft_return
558 """
559 A single LF will be converted to backslash + LF.
560 Use it for paragraphs, but not for headings or tables.
562 Args:
563 text (str): Text
564 Returns:
565 str: Handled text
566 """
567 return text.replace("\n", "\\\n")
569 @staticmethod
570 def markdown_create_heading(text: str, level: int, escape: bool = True) -> str:
571 # lobster-trace: SwRequirements.sw_req_markdown_heading
572 """
573 Create a Markdown heading.
574 The text will be automatically escaped for Markdown if necessary.
576 Args:
577 text (str): Heading text
578 level (int): Heading level [1; inf]
579 escape (bool): Escape the text (default: True).
581 Returns:
582 str: Markdown heading
583 """
584 result = ""
586 if 1 <= level:
587 text_raw = text
589 if escape is True:
590 text_raw = MarkdownConverter.markdown_escape(text)
592 result = f"{'#' * level} {text_raw}\n"
594 else:
595 log_error(f"Invalid heading level {level} for {text}.")
597 return result
599 @staticmethod
600 def markdown_create_table_head(column_titles : List[str], escape: bool = True) -> str:
601 # lobster-trace: SwRequirements.sw_req_markdown_table
602 """
603 Create the table head for a Markdown table.
604 The titles will be automatically escaped for Markdown if necessary.
606 Args:
607 column_titles ([str]): List of column titles.
608 escape (bool): Escape the titles (default: True).
610 Returns:
611 str: Table head
612 """
613 table_head = "|"
615 for column_title in column_titles:
616 column_title_raw = column_title
618 if escape is True:
619 column_title_raw = MarkdownConverter.markdown_escape(column_title)
621 table_head += f" {column_title_raw} |"
623 table_head += "\n"
625 table_head += "|"
627 for column_title in column_titles:
628 column_title_raw = column_title
630 if escape is True:
631 column_title_raw = MarkdownConverter.markdown_escape(column_title)
633 table_head += " "
635 for _ in range(len(column_title_raw)):
636 table_head += "-"
638 table_head += " |"
640 table_head += "\n"
642 return table_head
644 @staticmethod
645 def markdown_append_table_row(row_values: List[str], escape: bool = True) -> str:
646 # lobster-trace: SwRequirements.sw_req_markdown_table
647 """
648 Append a row to a Markdown table.
649 The values will be automatically escaped for Markdown if necessary.
651 Args:
652 row_values ([str]): List of row values.
653 escape (bool): Escapes every row value (default: True).
655 Returns:
656 str: Table row
657 """
658 table_row = "|"
660 for row_value in row_values:
661 row_value_raw = row_value
663 if escape is True:
664 row_value_raw = MarkdownConverter.markdown_escape(row_value)
666 # Replace every LF with a HTML <br>.
667 row_value_raw = row_value_raw.replace("\n", "<br>")
669 table_row += f" {row_value_raw} |"
671 table_row += "\n"
673 return table_row
675 @staticmethod
676 def markdown_create_list(list_values: List[str], use_html: bool = False, escape: bool = True) -> str:
677 # lobster-trace: SwRequirements.sw_req_markdown_list
678 """Create a unordered Markdown list.
679 The values will be automatically escaped for Markdown if necessary.
681 Args:
682 list_values (List[str]): List of list values.
683 use_html (bool): Use HTML for the list (default: False).
684 escape (bool): Escapes every list value (default: True).
685 Returns:
686 str: Markdown list
687 """
688 list_str = ""
690 if use_html is True:
691 list_str += "<ul>"
693 for value_raw in list_values:
694 value = value_raw
696 if escape is True: # Escape the value if necessary.
697 value = MarkdownConverter.markdown_escape(value)
699 if use_html is True:
700 list_str += f"<li>{value}</li>" # No line feed here, because the HTML list is not a Markdown list.
701 else:
702 list_str += f"* {value}\n"
704 if use_html is True:
705 list_str += "</ul>" # No line feed here, because the HTML list is not a Markdown list.
707 return list_str
709 @staticmethod
710 def markdown_create_link(text: str, url: str, escape: bool = True) -> str:
711 # lobster-trace: SwRequirements.sw_req_markdown_link
712 """
713 Create a Markdown link.
714 The text will be automatically escaped for Markdown if necessary.
715 There will be no newline appended at the end.
717 Args:
718 text (str): Link text
719 url (str): Link URL
720 escape (bool): Escapes text (default: True).
722 Returns:
723 str: Markdown link
724 """
725 text_raw = text
727 if escape is True:
728 text_raw = MarkdownConverter.markdown_escape(text)
730 return f"[{text_raw}]({url})"
732 @staticmethod
733 def markdown_create_diagram_link(diagram_file_name: str, diagram_caption: str, escape: bool = True) -> str:
734 # lobster-trace: SwRequirements.sw_req_markdown_image
735 """
736 Create a Markdown diagram link.
737 The caption will be automatically escaped for Markdown if necessary.
739 Args:
740 diagram_file_name (str): Diagram file name
741 diagram_caption (str): Diagram caption
742 escape (bool): Escapes caption (default: True).
744 Returns:
745 str: Markdown diagram link
746 """
747 diagram_caption_raw = diagram_caption
749 if escape is True:
750 diagram_caption_raw = MarkdownConverter.markdown_escape(diagram_caption)
752 # Allowed are absolute and relative to source paths.
753 diagram_file_name = os.path.normpath(diagram_file_name)
755 return f"\n"
757 @staticmethod
758 def markdown_text_color(text: str, color: str, escape: bool = True) -> str:
759 # lobster-trace: SwRequirements.sw_req_markdown_text_color
760 """
761 Create colored text in Markdown.
762 The text will be automatically escaped for Markdown if necessary.
763 There will be no newline appended at the end.
765 Args:
766 text (str): Text
767 color (str): HTML color
768 escape (bool): Escapes text (default: True).
770 Returns:
771 str: Colored text
772 """
773 text_raw = text
775 if escape is True:
776 text_raw = MarkdownConverter.markdown_escape(text)
778 return f"<span style=\"{color}\">{text_raw}</span>"
780# Functions ********************************************************************
782# Main *************************************************************************