Coverage for src/pyTRLCConverter/markdown_converter.py: 97%
213 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 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 - 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 hashlib
24import os
25import re
26import shutil
27import tempfile
28from typing import Optional, Any
29from trlc.ast import Implicit_Null, Record_Object, Record_Reference, String_Literal, Expression
30from pyTRLCConverter.base_converter import BaseConverter
31from pyTRLCConverter.markdown.document import MarkdownDocument
32from pyTRLCConverter.markdown.element import Heading, Table, BulletList
33from pyTRLCConverter.markdown.text import MarkdownText
34from pyTRLCConverter.plantuml import PlantUML
35from pyTRLCConverter.ret import Ret
36from pyTRLCConverter.trlc_helper import TrlcAstWalker
37from pyTRLCConverter.logger import log_verbose, log_error
39# Variables ********************************************************************
41# Classes **********************************************************************
43# pylint: disable-next=too-many-instance-attributes
44class MarkdownConverter(BaseConverter):
45 """
46 MarkdownConverter provides functionality for converting to a markdown format.
48 The converter builds a Markdown AST (MarkdownDocument made of block elements)
49 while walking the TRLC symbols and writes the output file(s) only once the
50 document is complete (in leave_file() for multiple-document mode and in
51 finish() for single-document mode).
52 """
54 OUTPUT_FILE_NAME_DEFAULT = "output.md"
55 TOP_LEVEL_DEFAULT = "Specification"
57 def __init__(self, args: Any) -> None:
58 # lobster-trace: SwRequirements.sw_req_no_prj_spec
59 # lobster-trace: SwRequirements.sw_req_markdown
60 """
61 Initializes the converter.
63 Args:
64 args (Any): The parsed program arguments.
65 """
66 super().__init__(args)
68 # The path to the given output folder.
69 self._out_path = args.out
71 # The excluded paths in normalized form.
72 self._excluded_paths = []
74 if args.exclude is not None:
75 self._excluded_paths = [os.path.normpath(path) for path in args.exclude]
77 # The Markdown document currently being built. In multiple-document mode a new
78 # document is created per file, in single-document mode one document is shared.
79 self._document: Optional[MarkdownDocument] = None
81 # The base level for the headings. Its the minimum level for the headings which depends
82 # on the single/multiple document mode.
83 self._base_level = 1
85 # A top level heading is always required to generate a compliant Markdown document.
86 # In single document mode it will always be necessary.
87 # In multiple document mode only if there is no top level section.
88 self._is_top_level_heading_req = True
90 # The AST walker meta data for processing the record object fields.
91 # This will hold the information about the current package, type and attribute being processed.
92 self._ast_meta_data = None
94 self._plantuml_tmp_dir: Optional[tempfile.TemporaryDirectory] = None
95 self._external_files: list = []
97 @staticmethod
98 def get_subcommand() -> str:
99 # lobster-trace: SwRequirements.sw_req_markdown
100 """
101 Return subcommand token for this converter.
103 Returns:
104 str: Parser subcommand token
105 """
106 return "markdown"
108 @staticmethod
109 def get_description() -> str:
110 # lobster-trace: SwRequirements.sw_req_markdown
111 """
112 Return converter description.
114 Returns:
115 str: Converter description
116 """
117 return "Convert into markdown format."
119 @classmethod
120 def register(cls, args_parser: Any) -> None:
121 # lobster-trace: SwRequirements.sw_req_markdown_multiple_doc_mode
122 # lobster-trace: SwRequirements.sw_req_markdown_single_doc_mode
123 # lobster-trace: SwRequirements.sw_req_markdown_top_level_default
124 # lobster-trace: SwRequirements.sw_req_markdown_top_level_custom
125 # lobster-trace: SwRequirements.sw_req_markdown_out_file_name_default
126 # lobster-trace: SwRequirements.sw_req_markdown_out_file_name_custom
127 # lobster-trace: SwRequirements.sw_req_markdown_render_plantuml
128 # lobster-trace: SwRequirements.sw_req_cli_render_plantuml
129 """
130 Register converter specific argument parser.
132 Args:
133 args_parser (Any): Argument parser
134 """
135 super().register(args_parser)
137 assert BaseConverter._parser is not None
139 BaseConverter._parser.add_argument(
140 "-e",
141 "--empty",
142 type=str,
143 default=BaseConverter.EMPTY_ATTRIBUTE_DEFAULT,
144 required=False,
145 help="Every attribute value which is empty will output the string " \
146 f"(default = {BaseConverter.EMPTY_ATTRIBUTE_DEFAULT})."
147 )
149 BaseConverter._parser.add_argument(
150 "-n",
151 "--name",
152 type=str,
153 default=MarkdownConverter.OUTPUT_FILE_NAME_DEFAULT,
154 required=False,
155 help="Name of the generated output file inside the output folder " \
156 f"(default = {MarkdownConverter.OUTPUT_FILE_NAME_DEFAULT}) in " \
157 "case a single document is generated."
158 )
160 BaseConverter._parser.add_argument(
161 "-sd",
162 "--single-document",
163 action="store_true",
164 required=False,
165 default=False,
166 help="Generate a single document instead of multiple files. The default is to generate multiple files."
167 )
169 BaseConverter._parser.add_argument(
170 "-tl",
171 "--top-level",
172 type=str,
173 default=MarkdownConverter.TOP_LEVEL_DEFAULT,
174 required=False,
175 help="Name of the top level heading, required in single document mode " \
176 f"(default = {MarkdownConverter.TOP_LEVEL_DEFAULT})."
177 )
179 BaseConverter._parser.add_argument(
180 "--render-plantuml",
181 action="store_true",
182 required=False,
183 default=False,
184 help="Render plantuml fenced code blocks as SVG image references. "
185 "Without this option plantuml blocks are passed through unchanged."
186 )
188 def begin(self) -> Ret:
189 # lobster-trace: SwRequirements.sw_req_markdown_single_doc_mode
190 # lobster-trace: SwRequirements.sw_req_markdown_sd_top_level
191 """
192 Begin the conversion process.
194 Returns:
195 Ret: Status
196 """
197 assert self._document is None
199 # Call the base converter to initialize the common stuff.
200 result = BaseConverter.begin(self)
202 if result == Ret.OK:
204 # Single document mode?
205 if self._args.single_document is True:
206 log_verbose("Single document mode.")
207 else:
208 log_verbose("Multiple document mode.")
210 # Set the value for empty attributes.
211 self._empty_attribute_value = self._args.empty
213 log_verbose(f"Empty attribute value: {self._empty_attribute_value}")
215 if self._args.render_plantuml is True:
216 # pylint: disable-next=consider-using-with
217 self._plantuml_tmp_dir = tempfile.TemporaryDirectory(
218 prefix="pyTRLCConverter_markdown_"
219 )
221 # Single document mode?
222 if self._args.single_document is True:
223 self._document = MarkdownDocument()
225 # The top level heading is always required in single document mode.
226 self._document.add(Heading(self._args.top_level, 1))
227 self._is_top_level_heading_req = False
229 # All headings will be shifted by one level.
230 self._base_level = self._base_level + 1
232 return result
234 def enter_file(self, file_name: str) -> Ret:
235 # lobster-trace: SwRequirements.sw_req_markdown_multiple_doc_mode
236 """
237 Enter a file.
239 Args:
240 file_name (str): File name
242 Returns:
243 Ret: Status
244 """
245 # Multiple document mode?
246 if self._args.single_document is False:
247 assert self._document is None
249 # A new document is built for each file. The very first written Markdown part
250 # shall not have an empty line before, which the document handles implicitly.
251 self._document = MarkdownDocument()
252 self._is_top_level_heading_req = True
254 return Ret.OK
256 def leave_file(self, file_name: str) -> Ret:
257 # lobster-trace: SwRequirements.sw_req_markdown_multiple_doc_mode
258 """
259 Leave a file.
261 Args:
262 file_name (str): File name
264 Returns:
265 Ret: Status
266 """
267 result = Ret.OK
269 # Multiple document mode?
270 if self._args.single_document is False:
271 assert self._document is not None
273 file_name_md = self._file_name_trlc_to_md(file_name)
274 result = self._write_document(file_name_md)
276 self._copy_external_files(self._out_path)
277 self._external_files = []
278 self._document = None
279 self._is_top_level_heading_req = True
281 return result
283 def convert_section(self, section: str, level: int) -> Ret:
284 # lobster-trace: SwRequirements.sw_req_markdown_section
285 # lobster-trace: SwRequirements.sw_req_markdown_md_top_level
286 """
287 Process the given section item.
288 It will create a Markdown heading with the given section name and level.
290 Args:
291 section (str): The section name
292 level (int): The section indentation level
294 Returns:
295 Ret: Status
296 """
297 assert len(section) > 0
298 assert self._document is not None
300 self._document.add(Heading(section, self._get_markdown_heading_level(level)))
302 # If a section heading is written, there is no top level heading required anymore.
303 self._is_top_level_heading_req = False
305 return Ret.OK
307 def convert_record_object_generic(self, record: Record_Object, level: int, translation: Optional[dict]) -> Ret:
308 # lobster-trace: SwRequirements.sw_req_markdown_record
309 # lobster-trace: SwRequirements.sw_req_markdown_md_top_level
310 """
311 Process the given record object in a generic way.
313 The handler is called by the base converter if no specific handler is
314 defined for the record type.
316 Args:
317 record (Record_Object): The record object.
318 level (int): The record level.
319 translation (Optional[dict]): Translation dictionary for the record object.
320 If None, no translation is applied.
322 Returns:
323 Ret: Status
324 """
325 assert self._document is not None
327 self._add_top_level_heading_on_demand()
329 return self._convert_record_object(record, level, translation)
331 def finish(self):
332 # lobster-trace: SwRequirements.sw_req_markdown_single_doc_mode
333 """
334 Finish the conversion process.
336 Returns:
337 Ret: Status
338 """
339 result = Ret.OK
341 # Single document mode?
342 if self._args.single_document is True:
343 assert self._document is not None
345 result = self._write_document(self._args.name)
347 self._copy_external_files(self._out_path)
348 self._external_files = []
349 self._document = None
351 if self._plantuml_tmp_dir is not None:
352 self._plantuml_tmp_dir.cleanup()
353 self._plantuml_tmp_dir = None
355 return result
357 def _add_top_level_heading_on_demand(self) -> None:
358 # lobster-trace: SwRequirements.sw_req_markdown_md_top_level
359 # lobster-trace: SwRequirements.sw_req_markdown_sd_top_level
360 """Add the top level heading to the document if necessary.
361 """
362 assert self._document is not None
364 if self._is_top_level_heading_req is True:
365 self._document.add(Heading(self._args.top_level, 1))
366 self._is_top_level_heading_req = False
368 def _get_markdown_heading_level(self, level: int) -> int:
369 # lobster-trace: SwRequirements.sw_req_markdown_record
370 """Get the Markdown heading level from the TRLC object level.
371 Its mandatory to use this method to calculate the Markdown heading level.
372 Otherwise in single document mode the top level heading will be wrong.
374 Args:
375 level (int): The TRLC object level.
377 Returns:
378 int: Markdown heading level
379 """
380 return self._base_level + level
382 def _file_name_trlc_to_md(self, file_name_trlc: str) -> str:
383 # lobster-trace: SwRequirements.sw_req_markdown_multiple_doc_mode
384 """
385 Convert a TRLC file name to a Markdown file name.
387 Args:
388 file_name_trlc (str): TRLC file name
390 Returns:
391 str: Markdown file name
392 """
393 file_name = os.path.basename(file_name_trlc)
394 file_name = os.path.splitext(file_name)[0] + ".md"
396 return file_name
398 def _write_document(self, file_name: str) -> Ret:
399 # lobster-trace: SwRequirements.sw_req_markdown_out_folder
400 """
401 Write the current Markdown document to the output file.
403 Args:
404 file_name (str): The output file name without path.
406 Returns:
407 Ret: Status
408 """
409 assert self._document is not None
411 result = Ret.OK
412 file_name_with_path = file_name
414 # Add path to the output file name.
415 if 0 < len(self._out_path):
416 file_name_with_path = os.path.join(self._out_path, file_name)
418 try:
419 with open(file_name_with_path, "w", encoding="utf-8") as out_file:
420 out_file.write(self._document.render())
421 except IOError as e:
422 log_error(f"Failed to open file {file_name_with_path}: {e}")
423 result = Ret.ERROR
425 return result
427 def _on_implict_null(self, _: Implicit_Null) -> str:
428 # lobster-trace: SwRequirements.sw_req_markdown_record
429 """
430 Process the given implicit null value.
432 Returns:
433 str: The implicit null value.
434 """
435 return MarkdownText.escape(self._empty_attribute_value)
437 def _on_record_reference(self, record_reference: Record_Reference) -> str:
438 # lobster-trace: SwRequirements.sw_req_markdown_record
439 """
440 Process the given record reference value and return a markdown link.
442 Args:
443 record_reference (Record_Reference): The record reference value.
445 Returns:
446 str: Markdown link to the record reference.
447 """
448 return self._create_markdown_link_from_record_object_reference(record_reference)
450 def _on_string_literal(self, string_literal: String_Literal) -> str:
451 # lobster-trace: SwRequirements.sw_req_markdown_string_format
452 # lobster-trace: SwRequirements.sw_req_markdown_render_md
453 # lobster-trace: SwRequirements.sw_req_markdown_render_gfm
454 """
455 Process the given string literal value.
457 Args:
458 string_literal (String_Literal): The string literal value.
460 Returns:
461 str: The string literal value.
462 """
463 result = string_literal.to_string()
465 if self._ast_meta_data is not None:
466 package_name = self._ast_meta_data.get("package_name", "")
467 type_name = self._ast_meta_data.get("type_name", "")
468 attribute_name = self._ast_meta_data.get("attribute_name", "")
470 result = self._render(package_name, type_name, attribute_name, result)
472 return result
474 # pylint: disable-next=line-too-long
475 def _create_markdown_link_from_record_object_reference(self, record_reference: Record_Reference) -> str:
476 # lobster-trace: SwRequirements.sw_req_markdown_record
477 """
478 Create a Markdown link from a record reference.
479 It considers the file name, the package name, and the record name.
481 Args:
482 record_reference (Record_Reference): Record reference
484 Returns:
485 str: Markdown link
486 """
487 assert record_reference.target is not None
489 file_name = ""
491 # Single document mode?
492 if self._args.single_document is True:
493 file_name = self._args.name
495 # Is the link to a excluded file?
496 for excluded_path in self._excluded_paths:
498 if os.path.commonpath([excluded_path, record_reference.target.location.file_name]) == excluded_path:
499 file_name = self._file_name_trlc_to_md(record_reference.target.location.file_name)
500 break
502 # Multiple document mode
503 else:
504 file_name = self._file_name_trlc_to_md(record_reference.target.location.file_name)
506 record_name = record_reference.target.name
508 anchor_tag = file_name + "#" + record_name.lower().replace(" ", "-")
510 return MarkdownText.link(str(record_reference.to_python_object()), anchor_tag)
512 def _other_dispatcher(self, expression: Expression) -> str:
513 # lobster-trace: SwRequirements.sw_req_markdown_record
514 # lobster-trace: SwRequirements.sw_req_markdown_escape
515 """
516 Dispatcher for all other expressions.
518 Args:
519 expression (Expression): The expression to process.
521 Returns:
522 str: The processed expression.
523 """
524 return MarkdownText.escape(expression.to_string())
526 def _get_trlc_ast_walker(self) -> TrlcAstWalker:
527 # lobster-trace: SwRequirements.sw_req_markdown_record
528 # lobster-trace: SwRequirements.sw_req_markdown_escape
529 # lobster-trace: SwRequirements.sw_req_markdown_string_format
530 """
531 If a record object contains a record reference, the record reference will be converted to
532 a Markdown link.
533 If a record object contains an array of record references, the array will be converted to
534 a Markdown list of links.
535 Otherwise the record object fields attribute values will be written to the Markdown table.
537 Returns:
538 TrlcAstWalker: The TRLC AST walker.
539 """
540 trlc_ast_walker = TrlcAstWalker()
541 trlc_ast_walker.add_dispatcher(
542 Implicit_Null,
543 None,
544 self._on_implict_null,
545 None
546 )
547 trlc_ast_walker.add_dispatcher(
548 Record_Reference,
549 None,
550 self._on_record_reference,
551 None
552 )
553 trlc_ast_walker.add_dispatcher(
554 String_Literal,
555 None,
556 self._on_string_literal,
557 None
558 )
559 trlc_ast_walker.set_other_dispatcher(self._other_dispatcher)
561 return trlc_ast_walker
563 def _render(self, package_name: str, type_name: str, attribute_name: str, attribute_value: str) -> str:
564 # lobster-trace: SwRequirements.sw_req_markdown_string_format
565 # lobster-trace: SwRequirements.sw_req_markdown_render_md
566 # lobster-trace: SwRequirements.sw_req_markdown_render_gfm
567 # lobster-trace: SwRequirements.sw_req_markdown_render_plantuml
568 """Render the attribute value depending on its format.
570 Args:
571 package_name (str): The package name.
572 type_name (str): The type name.
573 attribute_name (str): The attribute name.
574 attribute_value (str): The attribute value.
576 Returns:
577 str: The rendered attribute value.
578 """
579 result = attribute_value
581 # If the attribute value is not already in Markdown format, it will be escaped.
582 if self._render_cfg.is_format_md(package_name, type_name, attribute_name) is False and \
583 self._render_cfg.is_format_gfm(package_name, type_name, attribute_name) is False:
585 result = MarkdownText.escape(attribute_value)
586 result = MarkdownText.lf2soft_return(result)
588 if self._args.render_plantuml is True:
589 result = self._render_plantuml_blocks(result)
591 return result
593 def _render_plantuml_blocks(self, text: str) -> str:
594 # lobster-trace: SwRequirements.sw_req_markdown_render_plantuml
595 # lobster-trace: SwRequirements.sw_req_plantuml
596 """Replace plantuml fenced code blocks with SVG image references.
598 Each ```plantuml ... ``` block is replaced by ````.
599 The SVG is written to the temporary image directory and registered in
600 ``_external_files`` for copying to the output folder. On failure an
601 inline ``[PlantUML error: ...]`` text is emitted instead.
603 Args:
604 text (str): Markdown text that may contain plantuml fenced blocks.
606 Returns:
607 str: Text with plantuml blocks replaced.
608 """
609 assert self._plantuml_tmp_dir is not None
611 def _replace(match: re.Match) -> str:
612 diagram_source = match.group(1)
613 try:
614 plantuml = PlantUML()
615 svg_bytes = plantuml.generate_to_bytes("svg", diagram_source)
616 digest = hashlib.sha1(diagram_source.encode("utf-8")).hexdigest()[:12]
617 local_name = f"plantuml_{digest}.svg"
618 svg_path = os.path.join(self._plantuml_tmp_dir.name, local_name)
619 with open(svg_path, "wb") as svg_file:
620 svg_file.write(svg_bytes)
621 self._external_files.append((svg_path, local_name))
622 return f""
623 except (FileNotFoundError, OSError) as exc:
624 return f"[PlantUML error: {exc}]"
626 return re.sub(r"```plantuml\n(.*?)```", _replace, text, flags=re.DOTALL)
628 def _copy_external_files(self, dest_dir: str) -> None:
629 # lobster-trace: SwRequirements.sw_req_markdown_render_plantuml
630 """Copy all collected external files to the given destination directory.
632 Args:
633 dest_dir (str): Destination directory path.
634 """
635 copied_sources = set()
637 for source_path, local_name in self._external_files:
638 if source_path in copied_sources:
639 continue
641 dest_path = os.path.join(dest_dir, local_name)
643 try:
644 shutil.copy2(source_path, dest_path)
645 copied_sources.add(source_path)
646 except (OSError, IOError) as exc:
647 log_error(f"Failed to copy external file '{source_path}': {exc}", False)
649 def _convert_record_object(self, record: Record_Object, level: int, translation: Optional[dict]) -> Ret:
650 # lobster-trace: SwRequirements.sw_req_markdown_record
651 """
652 Process the given record object.
654 Args:
655 record (Record_Object): The record object.
656 level (int): The record level.
657 translation (Optional[dict]): Translation dictionary for the record object.
658 If None, no translation is applied.
660 Returns:
661 Ret: Status
662 """
663 assert self._document is not None
665 # The record name will be the heading.
666 self._document.add(Heading(record.name, self._get_markdown_heading_level(level + 1)))
668 # The record fields will be written to a table.
669 # First define the table column titles.
670 table_column_titles = ["Attribute Name", "Attribute Value"]
671 table_rows = []
673 # Walk through the record object fields and build the table rows.
674 trlc_ast_walker = self._get_trlc_ast_walker()
676 for name, value in record.field.items():
677 attribute_name = self._translate_attribute_name(translation, name)
678 attribute_name = MarkdownText.escape(attribute_name)
680 # Retrieve the attribute value by processing the field value.
681 # The result will be a string representation of the value.
682 # If the value is an array of record references, the result will be a Markdown list of links.
683 # If the value is a single record reference, the result will be a Markdown link.
684 # If the value is a string literal, the result will be the string literal value that considers
685 # its formatting.
686 # Otherwise the result will be the attribute value in a proper format.
687 self._ast_meta_data = {
688 "package_name": record.n_package.name,
689 "type_name": record.n_typ.name,
690 "attribute_name": name
691 }
692 walker_result = trlc_ast_walker.walk(value)
694 attribute_value = ""
695 if isinstance(walker_result, list):
696 attribute_value = BulletList(walker_result, False).render()
697 else:
698 attribute_value = walker_result
700 # Append the attribute name and value to the table rows.
701 table_rows.append([attribute_name, attribute_value])
703 self._document.add(Table(table_column_titles, table_rows))
705 return Ret.OK
707# Functions ********************************************************************
709# Main *************************************************************************