Coverage for src/pyTRLCConverter/reqif_converter.py: 95%
478 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 ReqIF 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 html
24import mimetypes
25import os
26import re
27import shutil
28import tempfile
29import zipfile
30from datetime import datetime, timezone
31from typing import Any, Optional
32from marko import Markdown
33from reqif.object_lookup import ReqIFObjectLookup
34from reqif.reqif_bundle import ReqIFBundle
35from reqif.unparser import ReqIFUnparser
36from reqif.models.reqif_core_content import ReqIFCoreContent
37from reqif.models.reqif_data_type import (
38 ReqIFDataTypeDefinitionEnumeration,
39 ReqIFDataTypeDefinitionString,
40 ReqIFDataTypeDefinitionXHTML,
41 ReqIFEnumValue
42)
43from reqif.models.reqif_namespace_info import ReqIFNamespaceInfo
44from reqif.models.reqif_reqif_header import ReqIFReqIFHeader
45from reqif.models.reqif_req_if_content import ReqIFReqIFContent
46from reqif.models.reqif_spec_hierarchy import ReqIFSpecHierarchy
47from reqif.models.reqif_spec_object import ReqIFSpecObject, SpecObjectAttribute
48from reqif.models.reqif_spec_object_type import ReqIFSpecObjectType, SpecAttributeDefinition
49from reqif.models.reqif_spec_relation import ReqIFSpecRelation
50from reqif.models.reqif_spec_relation_type import ReqIFSpecRelationType
51from reqif.models.reqif_specification import ReqIFSpecification
52from reqif.models.reqif_specification_type import ReqIFSpecificationType
53from reqif.models.reqif_types import SpecObjectAttributeType
54from trlc.ast import (
55 Array_Aggregate, Enumeration_Literal, Enumeration_Type, Implicit_Null,
56 Record_Object, Record_Reference, String_Literal, Expression, Symbol_Table
57)
58from pyTRLCConverter.base_converter import BaseConverter
59from pyTRLCConverter.reqif_identifier_store import ReqifIdentifierStore
60from pyTRLCConverter.marko.md2reqif_renderer import Md2ReqifRenderer
61from pyTRLCConverter.marko.gfm2reqif_renderer import Gfm2ReqifRenderer
62from pyTRLCConverter.ret import Ret
63from pyTRLCConverter.trlc_helper import TrlcAstWalker
64from pyTRLCConverter.logger import log_error, log_verbose
65from pyTRLCConverter.version import __version__
67# pylint: disable=too-many-lines
69# Variables ********************************************************************
71# Classes **********************************************************************
74# pylint: disable-next=too-many-instance-attributes
75class ReqifConverter(BaseConverter):
76 """ReqifConverter provides functionality for converting to ReqIF format."""
78 REQIF_VERSION = "1.0"
79 EMPTY_ATTRIBUTE_DEFAULT = ""
81 OUTPUT_FILE_NAME_DEFAULT = "output.reqif"
82 TOP_LEVEL_DEFAULT = "Specification"
84 DATATYPE_XHTML_IDENTIFIER = "datatype-xhtml"
85 DATATYPE_STRING_IDENTIFIER = "datatype-string"
86 SPECIFICATION_TYPE_IDENTIFIER = "specification-type-trlc"
87 XSI_NAMESPACE = "http://www.w3.org/2001/XMLSchema-instance"
89 SYSTEM_ATTRIBUTE_PREFIX = "ReqIF."
90 ATTRIBUTE_KEY_RECORD_FOREIGN_ID = "foreignID"
92 def __init__(self, args: Any) -> None:
93 # lobster-trace: SwRequirements.sw_req_reqif
94 """
95 Initializes the converter.
97 Args:
98 args (Any): The parsed program arguments.
99 """
100 super().__init__(args)
102 self._out_path = args.out
103 self._markdown_renderer_md = Markdown(renderer=Md2ReqifRenderer)
104 self._markdown_renderer_gfm = Markdown(renderer=Gfm2ReqifRenderer, extensions=["gfm"])
105 self._plantuml_tmp_dir: Optional[tempfile.TemporaryDirectory] = None
107 self._id_store_path = getattr(args, "id_store", None)
108 self._id_store: Optional[ReqifIdentifierStore] = None
110 self._spec_objects = []
111 self._spec_relations = []
112 self._root_hierarchies = []
113 self._hierarchy_stack = []
114 self._attribute_definitions_by_type = {}
115 self._spec_object_type_info = {}
116 self._spec_relation_type_info = {}
117 self._pending_relations = []
118 self._spec_object_identifier_by_record = {}
119 self._id_counter = 0
120 self._document_title = ReqifConverter.TOP_LEVEL_DEFAULT
121 self._enum_datatype_registry = {}
122 self._last_spec_object_identifier: Optional[str] = None
123 self._pending_hierarchy_args: Optional[tuple] = None
124 self._spec_title_captured: bool = False
125 self._external_files: list = []
127 @staticmethod
128 def get_subcommand() -> str:
129 # lobster-trace: SwRequirements.sw_req_reqif
130 """
131 Return subcommand token for this converter.
133 Returns:
134 str: Parser subcommand token
135 """
136 return "reqif"
138 @staticmethod
139 def get_description() -> str:
140 # lobster-trace: SwRequirements.sw_req_reqif
141 """
142 Return converter description.
144 Returns:
145 str: Converter description
146 """
147 return "Convert into ReqIF format."
149 @classmethod
150 def register(cls, args_parser: Any) -> None:
151 # lobster-trace: SwRequirements.sw_req_reqif_multiple_doc_mode
152 # lobster-trace: SwRequirements.sw_req_reqif_single_doc_mode
153 # lobster-trace: SwRequirements.sw_req_reqif_top_level_default
154 # lobster-trace: SwRequirements.sw_req_reqif_top_level_custom
155 # lobster-trace: SwRequirements.sw_req_reqif_out_file_name_default
156 # lobster-trace: SwRequirements.sw_req_reqif_out_file_name_custom
157 # lobster-trace: SwRequirements.sw_req_reqif_reqifz
158 # lobster-trace: SwRequirements.sw_req_reqif_identifier_immutable
159 """
160 Register converter specific argument parser.
162 Args:
163 args_parser (Any): Argument parser
164 """
165 super().register(args_parser)
167 assert BaseConverter._parser is not None
169 BaseConverter._parser.add_argument(
170 "-e",
171 "--empty",
172 type=str,
173 default=ReqifConverter.EMPTY_ATTRIBUTE_DEFAULT,
174 required=False,
175 help="Every attribute value which is empty will output the string "
176 f"(default = {ReqifConverter.EMPTY_ATTRIBUTE_DEFAULT})."
177 )
179 BaseConverter._parser.add_argument(
180 "-n",
181 "--name",
182 type=str,
183 default=ReqifConverter.OUTPUT_FILE_NAME_DEFAULT,
184 required=False,
185 help="Name of the generated output file inside the output folder "
186 f"(default = {ReqifConverter.OUTPUT_FILE_NAME_DEFAULT}) in "
187 "case a single document is generated."
188 )
190 BaseConverter._parser.add_argument(
191 "-sd",
192 "--single-document",
193 action="store_true",
194 required=False,
195 default=False,
196 help="Generate a single document instead of multiple files. The default is to generate multiple files."
197 )
199 BaseConverter._parser.add_argument(
200 "-tl",
201 "--top-level",
202 type=str,
203 default=ReqifConverter.TOP_LEVEL_DEFAULT,
204 required=False,
205 help="Name of the top level heading, required in single document mode "
206 f"(default = {ReqifConverter.TOP_LEVEL_DEFAULT})."
207 )
209 BaseConverter._parser.add_argument(
210 "--reqifz",
211 action="store_true",
212 required=False,
213 default=False,
214 help="Archive the ReqIF output as a ZIP file with the .reqifz extension. "
215 "The default is to write plain .reqif files."
216 )
218 BaseConverter._parser.add_argument(
219 "--id-store",
220 type=str,
221 default=None,
222 required=False,
223 help="Path to a JSON file used to keep the identifiers of ReqIF Identifiable "
224 "elements immutable across consecutive exports. On the initial conversion "
225 "the file is created with the generated identifiers; on subsequent "
226 "conversions the stored identifiers are reused and new elements are added."
227 )
229 def begin(self) -> Ret:
230 # lobster-trace: SwRequirements.sw_req_reqif_single_doc_mode
231 # lobster-trace: SwRequirements.sw_req_reqif_identifier_store_init
232 # lobster-trace: SwRequirements.sw_req_reqif_identifier_store_reuse
233 """
234 Begin the conversion process.
236 Returns:
237 Ret: Status
238 """
239 result = BaseConverter.begin(self)
241 if result == Ret.OK:
242 self._empty_attribute_value = self._args.empty
243 log_verbose(f"Empty attribute value: {self._empty_attribute_value}")
245 if self._id_store_path is not None:
246 self._id_store = ReqifIdentifierStore()
247 if self._id_store.load(self._id_store_path) is False:
248 result = Ret.ERROR
250 # Temporary directory for inline PlantUML images generated while
251 # rendering Markdown attributes. The directory is cleaned up in
252 # finish() once all documents have been written.
253 # pylint: disable-next=consider-using-with
254 self._plantuml_tmp_dir = tempfile.TemporaryDirectory(prefix="pyTRLCConverter_reqif_")
256 if self._args.single_document is True:
257 log_verbose("Single document mode.")
258 self._reset_document_state(self._args.top_level)
259 else:
260 log_verbose("Multiple document mode.")
262 return result
264 def enter_file(self, file_name: str) -> Ret:
265 # lobster-trace: SwRequirements.sw_req_reqif_multiple_doc_mode
266 """Enter a file and reset document state in multiple document mode.
268 Args:
269 file_name (str): File name
271 Returns:
272 Ret: Status
273 """
274 if self._args.single_document is False:
275 file_title = os.path.splitext(os.path.basename(file_name))[0]
276 self._reset_document_state(file_title)
278 return Ret.OK
280 def leave_file(self, file_name: str) -> Ret:
281 # lobster-trace: SwRequirements.sw_req_reqif_multiple_doc_mode
282 """Leave a file and write the ReqIF document in multiple document mode.
284 Args:
285 file_name (str): File name
287 Returns:
288 Ret: Status
289 """
290 if self._args.single_document is False:
291 self._flush_pending_hierarchy()
292 out_file_name = self._file_name_trlc_to_reqif(file_name)
293 return self._write_document(out_file_name)
295 return Ret.OK
297 def convert_section(self, section: str, level: int) -> Ret:
298 # lobster-trace: SwRequirements.sw_req_reqif_section
299 """Process the given section item as a named SPEC-HIERARCHY container.
301 The section reuses the last processed TRLC record's SPEC-OBJECT as the mandatory
302 SPEC-OBJECT-REF in the SPEC-HIERARCHY node (the ReqIF v1.2 schema requires
303 minOccurs=1). The section title is carried by the hierarchy node's LONG-NAME.
304 No dedicated section SPEC-OBJECT is created.
306 If no record has been processed yet, the section name is used as the document
307 title (specification long-name) for the very first such section. Any further
308 section without a preceding record emits a warning and is skipped.
310 Args:
311 section (str): The section name
312 level (int): The section indentation level
314 Returns:
315 Ret: Status
316 """
317 assert len(section) > 0
319 if self._last_spec_object_identifier is None:
320 if self._spec_title_captured is False:
321 self._document_title = section
322 self._spec_title_captured = True
323 else:
324 log_error(f"Warning: Section '{section}' has no preceding record; skipped in ReqIF hierarchy.")
325 return Ret.OK
327 if self._pending_hierarchy_args is not None:
328 # Promote the pending record to the section container: it is placed once in the
329 # hierarchy at the section's level (is_container=True) instead of being flushed
330 # as a standalone leaf.
331 section_spec_object_id = self._pending_hierarchy_args[0]
332 self._pending_hierarchy_args = None
333 else:
334 # No pending record; reuse the last known spec-object identifier.
335 section_spec_object_id = self._last_spec_object_identifier
337 self._append_hierarchy(section_spec_object_id, level, section, is_container=True)
339 return Ret.OK
341 # pylint: disable-next=too-many-locals
342 def convert_record_object_generic(self, record: Record_Object, level: int, translation: Optional[dict]) -> Ret:
343 # lobster-trace: SwRequirements.sw_req_reqif_record
344 """Convert a record object generically to a ReqIF spec-object.
346 Args:
347 record (Record_Object): The record object.
348 level (int): The record level.
349 translation (Optional[dict]): Translation dictionary for the record object.
350 If None, no translation is applied.
352 Returns:
353 Ret: Status
354 """
355 self._flush_pending_hierarchy()
356 trlc_ast_walker = self._get_trlc_ast_walker()
357 attribute_value_map = {
358 ReqifConverter.ATTRIBUTE_KEY_RECORD_FOREIGN_ID: {
359 "long_name": f"{ReqifConverter.SYSTEM_ATTRIBUTE_PREFIX}ForeignID",
360 "value": record.name,
361 "attribute_type": SpecObjectAttributeType.STRING
362 }
363 }
364 type_key = self._get_record_type_key(record)
366 for name, value in record.field.items():
367 if self._queue_spec_relations_from_expression(record, value, name) is True:
368 continue
370 attribute_name = self._translate_attribute_name(translation, name)
371 enum_type = self._get_field_enum_type(record, name)
373 if enum_type is not None:
374 enum_values = self._collect_enum_values_from_expression(value)
375 if enum_values is not None:
376 attribute_value_map[f"field_{name}"] = {
377 "long_name": attribute_name,
378 "value": enum_values,
379 "attribute_type": SpecObjectAttributeType.ENUMERATION,
380 "enum_type": enum_type,
381 }
382 continue
384 walker_result = trlc_ast_walker.walk(value)
386 attribute_value = ""
387 if isinstance(walker_result, list):
388 attribute_value = "\n".join([str(item) for item in walker_result])
389 else:
390 attribute_value = str(walker_result)
392 if len(attribute_value) == 0:
393 attribute_value = self._empty_attribute_value
395 rendered_value = self._render(
396 package_name=record.n_package.name,
397 type_name=record.n_typ.name,
398 attribute_name=name,
399 attribute_value=attribute_value
400 )
402 attribute_value_map[f"field_{name}"] = {
403 "long_name": attribute_name,
404 "value": rendered_value,
405 "attribute_type": SpecObjectAttributeType.XHTML
406 }
408 spec_object = self._create_spec_object(
409 item_name=record.name,
410 type_key=type_key,
411 type_long_name=record.n_typ.name,
412 attribute_value_map=attribute_value_map,
413 identifier_key=f"spec-object:{record.n_package.name}.{record.name}"
414 )
415 self._spec_object_identifier_by_record[id(record)] = spec_object.identifier
416 self._last_spec_object_identifier = spec_object.identifier
417 self._pending_hierarchy_args = (spec_object.identifier, level, record.name)
419 return Ret.OK
421 def finish(self) -> Ret:
422 # lobster-trace: SwRequirements.sw_req_reqif_single_doc_mode
423 # lobster-trace: SwRequirements.sw_req_reqif_reqifz
424 # lobster-trace: SwRequirements.sw_req_reqif_identifier_store_init
425 # lobster-trace: SwRequirements.sw_req_reqif_identifier_store_reuse
426 """Finish the conversion process and write the output in single document mode.
428 The persistent identifier store, if enabled, is written back so that the
429 generated identifiers stay immutable on subsequent conversions.
431 Returns:
432 Ret: Status
433 """
434 result = Ret.OK
436 if self._args.single_document is True:
437 self._flush_pending_hierarchy()
438 result = self._write_document(self._args.name)
440 if self._id_store is not None:
441 if self._id_store.save(self._id_store_path) is False:
442 result = Ret.ERROR
444 if self._plantuml_tmp_dir is not None:
445 self._plantuml_tmp_dir.cleanup()
446 self._plantuml_tmp_dir = None
448 return result
450 def _write_document(self, file_name: str) -> Ret:
451 # lobster-trace: SwRequirements.sw_req_reqif_multiple_doc_mode
452 # lobster-trace: SwRequirements.sw_req_reqif_single_doc_mode
453 # lobster-trace: SwRequirements.sw_req_reqif_out_file_name_default
454 # lobster-trace: SwRequirements.sw_req_reqif_out_file_name_custom
455 # lobster-trace: SwRequirements.sw_req_reqif_reqifz
456 # lobster-trace: SwRequirements.sw_req_reqif_render_path
457 """Build the ReqIF bundle and write it to the output file.
459 Without --reqifz the .reqif file is written directly into the output folder.
460 With --reqifz a subfolder named after the document is created inside the
461 output folder, the .reqif file is placed there, and all files inside that
462 subfolder are bundled into a .reqifz archive in the output folder.
464 Args:
465 file_name (str): Output file name (without path prefix).
467 Returns:
468 Ret: Status
469 """
470 doc_name = os.path.splitext(os.path.basename(file_name))[0]
472 try:
473 reqif_xml = ReqIFUnparser.unparse(self._build_reqif_bundle())
475 if self._args.reqifz:
476 self._bundle_as_reqifz(doc_name, reqif_xml)
477 else:
478 out_file_name = os.path.join(self._out_path, file_name) if 0 < len(self._out_path) else file_name
479 with open(out_file_name, "w", encoding="utf-8") as fd:
480 fd.write(reqif_xml)
481 out_dir = self._out_path if 0 < len(self._out_path) else "."
482 self._copy_external_files(out_dir)
484 return Ret.OK
486 except (OSError, IOError, ValueError) as exc:
487 log_error(f"Failed to write ReqIF output: {exc}")
488 return Ret.ERROR
490 def _bundle_as_reqifz(self, doc_name: str, reqif_xml: str) -> None:
491 # lobster-trace: SwRequirements.sw_req_reqif_reqifz
492 # lobster-trace: SwRequirements.sw_req_reqif_render_path
493 """Create the document subfolder, write the .reqif, and bundle into a .reqifz archive.
495 The subfolder is named after the document and placed inside the output folder.
496 All files inside the subfolder are included in the archive with paths relative
497 to the subfolder root.
499 Args:
500 doc_name (str): Document base name (no extension).
501 reqif_xml (str): Serialised ReqIF XML content.
502 """
503 subfolder = os.path.join(self._out_path, doc_name) if 0 < len(self._out_path) else doc_name
504 reqifz_path = (os.path.join(self._out_path, doc_name + ".reqifz")
505 if 0 < len(self._out_path) else doc_name + ".reqifz")
506 os.makedirs(subfolder, exist_ok=True)
507 with open(os.path.join(subfolder, doc_name + ".reqif"), "w", encoding="utf-8") as fd:
508 fd.write(reqif_xml)
509 self._copy_external_files(subfolder)
510 with zipfile.ZipFile(reqifz_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
511 for dirpath, _, filenames in os.walk(subfolder):
512 for fname in filenames:
513 abs_path = os.path.join(dirpath, fname)
514 arc_name = os.path.relpath(abs_path, subfolder).replace(os.sep, "/")
515 zf.write(abs_path, arc_name)
517 def _build_reqif_bundle(self) -> ReqIFBundle:
518 # lobster-trace: SwRequirements.sw_req_reqif
519 """Assemble a complete ReqIFBundle from the accumulated spec objects, hierarchies and attribute definitions.
521 Returns:
522 ReqIFBundle: The assembled ReqIF bundle ready for unparsing.
523 """
524 last_change = self._get_reqif_timestamp()
526 data_type = ReqIFDataTypeDefinitionXHTML(
527 identifier=ReqifConverter.DATATYPE_XHTML_IDENTIFIER,
528 last_change=last_change,
529 long_name="XHTML"
530 )
532 string_data_type = ReqIFDataTypeDefinitionString(
533 identifier=ReqifConverter.DATATYPE_STRING_IDENTIFIER,
534 last_change=last_change,
535 long_name="String",
536 max_length="255"
537 )
539 spec_object_type_list = [
540 ReqIFSpecObjectType.create(
541 identifier=type_info["identifier"],
542 long_name=type_info["long_name"],
543 last_change=last_change,
544 attribute_definitions=list(self._attribute_definitions_by_type.get(type_key, {}).values())
545 )
546 for type_key, type_info in self._spec_object_type_info.items()
547 ]
549 spec_relation_type_list = [
550 ReqIFSpecRelationType(
551 identifier=relation_type_info["identifier"],
552 last_change=last_change,
553 long_name=relation_type_info["long_name"],
554 is_self_closed=True,
555 attribute_definitions=None
556 )
557 for relation_type_info in self._spec_relation_type_info.values()
558 ]
560 specification_type = ReqIFSpecificationType(
561 identifier=ReqifConverter.SPECIFICATION_TYPE_IDENTIFIER,
562 last_change=last_change,
563 long_name="TRLC Specification",
564 spec_attributes=[]
565 )
567 specification = ReqIFSpecification(
568 identifier=self._document_title,
569 long_name=self._document_title,
570 last_change=last_change,
571 specification_type=ReqifConverter.SPECIFICATION_TYPE_IDENTIFIER,
572 values=[],
573 children=self._root_hierarchies
574 )
576 req_if_header = ReqIFReqIFHeader(
577 identifier=self._obtain_identifier(f"req-if-header:{self._document_title}", "req-if-header"),
578 comment="Generated by pyTRLCConverter",
579 creation_time=last_change,
580 repository_id="",
581 req_if_tool_id=f"pyTRLCConverter {__version__}",
582 req_if_version=ReqifConverter.REQIF_VERSION,
583 source_tool_id=f"pyTRLCConverter {__version__}",
584 title=self._document_title
585 )
587 namespace_info = ReqIFNamespaceInfo.create_default()
588 namespace_info.schema_namespace = ReqifConverter.XSI_NAMESPACE
589 namespace_info.schema_location = (
590 f"{ReqIFNamespaceInfo.REQIF_XSD} {ReqIFNamespaceInfo.REQIF_XSD}"
591 )
593 enum_data_types = [
594 ReqIFDataTypeDefinitionEnumeration(
595 identifier=info["identifier"],
596 long_name=info["long_name"],
597 last_change=last_change,
598 values=info["values"]
599 )
600 for info in self._enum_datatype_registry.values()
601 ]
603 content = ReqIFReqIFContent(
604 data_types=[data_type, string_data_type, *enum_data_types],
605 spec_types=[*spec_object_type_list, *spec_relation_type_list, specification_type],
606 spec_objects=self._spec_objects,
607 spec_relations=self._build_spec_relations(),
608 specifications=[specification]
609 )
611 return ReqIFBundle(
612 namespace_info=namespace_info,
613 req_if_header=req_if_header,
614 core_content=ReqIFCoreContent(req_if_content=content),
615 tool_extensions_tag_exists=False,
616 lookup=ReqIFObjectLookup.empty(),
617 exceptions=[]
618 )
620 def _reset_document_state(self, title: str) -> None:
621 # lobster-trace: SwRequirements.sw_req_reqif_multiple_doc_mode
622 # lobster-trace: SwRequirements.sw_req_reqif_single_doc_mode
623 """Reset all per-document state.
625 Args:
626 title (str): The document title used as the specification long-name.
627 """
628 self._spec_objects = []
629 self._spec_relations = []
630 self._root_hierarchies = []
631 self._hierarchy_stack = []
632 self._attribute_definitions_by_type = {}
633 self._spec_object_type_info = {}
634 self._spec_relation_type_info = {}
635 self._pending_relations = []
636 self._spec_object_identifier_by_record = {}
637 self._id_counter = 0
638 self._document_title = title
639 self._enum_datatype_registry = {}
640 self._last_spec_object_identifier = None
641 self._pending_hierarchy_args = None
642 self._spec_title_captured = False
643 self._external_files = []
645 def _flush_pending_hierarchy(self) -> None:
646 # lobster-trace: SwRequirements.sw_req_reqif_section
647 """Flush a deferred record hierarchy node as a standalone leaf.
649 When a record is processed its hierarchy insertion is deferred so that a
650 following section can promote it to a container instead. Call this method
651 before inserting any new node (record or section) that must not consume the
652 pending record.
653 """
654 if self._pending_hierarchy_args is not None:
655 pending_id, pending_level, pending_name = self._pending_hierarchy_args
656 self._append_hierarchy(pending_id, pending_level + 1, pending_name,
657 is_container=False)
658 self._pending_hierarchy_args = None
660 def _append_hierarchy(self, spec_object_identifier: str, level: int, long_name: str,
661 is_container: bool) -> None:
662 # lobster-trace: SwRequirements.sw_req_reqif_section
663 # lobster-trace: SwRequirements.sw_req_reqif_record
664 """Append a new ReqIFSpecHierarchy node at the given nesting level.
666 The internal hierarchy stack is pruned so the new node is correctly
667 attached either as a child of the current parent or as a root entry.
669 Args:
670 spec_object_identifier (str): Identifier of the spec-object this hierarchy node references.
671 level (int): TRLC nesting level (0 = top-level).
672 long_name (str): Display name for the hierarchy node.
673 is_container (bool): If True, keep the new hierarchy node on the stack as a parent.
674 """
675 hierarchy_level = max(1, level + 1)
677 while (len(self._hierarchy_stack) > 0 and
678 self._hierarchy_stack[-1].level >= hierarchy_level):
679 self._hierarchy_stack.pop()
681 hierarchy = ReqIFSpecHierarchy(
682 identifier=self._obtain_identifier(
683 f"hierarchy:{spec_object_identifier}:{long_name}", "hierarchy"),
684 spec_object=spec_object_identifier,
685 level=hierarchy_level,
686 children=[],
687 long_name=long_name,
688 ref_then_children_order=True,
689 last_change=self._get_reqif_timestamp(),
690 editable=False,
691 is_table_internal=False,
692 is_self_closed=False
693 )
695 if len(self._hierarchy_stack) > 0:
696 self._hierarchy_stack[-1].add_child(hierarchy)
697 else:
698 self._root_hierarchies.append(hierarchy)
700 if is_container is True:
701 self._hierarchy_stack.append(hierarchy)
703 # pylint: disable-next=too-many-arguments,too-many-positional-arguments
704 def _create_spec_object(self,
705 item_name: str,
706 type_key: str,
707 type_long_name: str,
708 attribute_value_map: dict[str, dict[str, Any]],
709 identifier_key: str) -> ReqIFSpecObject:
710 # lobster-trace: SwRequirements.sw_req_reqif_record
711 # lobster-trace: SwRequirements.sw_req_reqif_identifier_immutable
712 """Create a ReqIFSpecObject for the given ReqIF type and field attributes.
714 Args:
715 item_name (str): Name of the TRLC item used as the spec-object long-name.
716 type_key (str): Internal key of the ReqIF spec-object type.
717 type_long_name (str): Human-readable long-name of the ReqIF spec-object type.
718 attribute_value_map (dict[str, dict[str, Any]]): Mapping from field key to
719 ``{"long_name": ..., "value": ...}`` for each dynamic field attribute.
720 identifier_key (str): Stable logical key for the spec-object identifier.
722 Returns:
723 ReqIFSpecObject: The created and registered spec-object.
724 """
725 self._ensure_spec_object_type(type_key, type_long_name)
727 attributes = []
729 for key in sorted(attribute_value_map):
730 attribute_info = attribute_value_map[key]
731 attribute_type = attribute_info.get("attribute_type", SpecObjectAttributeType.XHTML)
733 if attribute_type == SpecObjectAttributeType.STRING:
734 attributes.append(
735 self._create_string_attribute(
736 type_key,
737 key,
738 attribute_info["long_name"],
739 attribute_info["value"]
740 )
741 )
742 elif attribute_type == SpecObjectAttributeType.ENUMERATION:
743 attributes.append(
744 self._create_enum_attribute(
745 type_key,
746 key,
747 attribute_info["long_name"],
748 attribute_info["value"],
749 attribute_info["enum_type"]
750 )
751 )
752 else:
753 attributes.append(
754 self._create_xhtml_attribute(
755 type_key,
756 key,
757 attribute_info["long_name"],
758 attribute_info["value"]
759 )
760 )
762 spec_object = ReqIFSpecObject(
763 identifier=self._obtain_identifier(identifier_key, "spec-object"),
764 attributes=attributes,
765 spec_object_type=self._get_spec_object_type_identifier(type_key),
766 long_name=item_name,
767 last_change=self._get_reqif_timestamp()
768 )
770 self._spec_objects.append(spec_object)
772 return spec_object
774 def _create_xhtml_attribute(self, type_key: str, definition_key: str, long_name: str,
775 xhtml_value: str) -> SpecObjectAttribute:
776 # lobster-trace: SwRequirements.sw_req_reqif_record
777 """Create a SpecObjectAttribute of type XHTML, ensuring the corresponding attribute definition exists.
779 Args:
780 type_key (str): Internal key of the owning ReqIF spec-object type.
781 definition_key (str): Internal dictionary key used to look up or create the attribute definition.
782 long_name (str): Human-readable attribute name registered in the spec-object type.
783 xhtml_value (str): XHTML-wrapped content string.
785 Returns:
786 SpecObjectAttribute: The created attribute.
787 """
788 definition_identifier = self._ensure_attribute_definition(
789 type_key,
790 definition_key,
791 long_name,
792 SpecObjectAttributeType.XHTML
793 )
795 return SpecObjectAttribute(
796 attribute_type=SpecObjectAttributeType.XHTML,
797 definition_ref=definition_identifier,
798 value=xhtml_value
799 )
801 def _create_string_attribute(self, type_key: str, definition_key: str, long_name: str,
802 value: str) -> SpecObjectAttribute:
803 # lobster-trace: SwRequirements.sw_req_reqif_record
804 """Create a SpecObjectAttribute of type STRING for the given ReqIF spec-object type.
806 Args:
807 type_key (str): Internal key of the owning ReqIF spec-object type.
808 definition_key (str): Internal dictionary key used to look up or create the attribute definition.
809 long_name (str): Human-readable attribute name registered in the spec-object type.
810 value (str): String attribute value.
812 Returns:
813 SpecObjectAttribute: The created attribute.
814 """
815 definition_identifier = self._ensure_attribute_definition(
816 type_key,
817 definition_key,
818 long_name,
819 SpecObjectAttributeType.STRING
820 )
822 return SpecObjectAttribute(
823 attribute_type=SpecObjectAttributeType.STRING,
824 definition_ref=definition_identifier,
825 value=value
826 )
828 # pylint: disable-next=too-many-arguments,too-many-positional-arguments
829 def _create_enum_attribute(self, type_key: str, definition_key: str, long_name: str,
830 literal_names: list, enum_type: Enumeration_Type) -> SpecObjectAttribute:
831 # lobster-trace: SwRequirements.sw_req_reqif_enum
832 """Create a SpecObjectAttribute of type ENUMERATION for the given ReqIF spec-object type.
834 Args:
835 type_key (str): Internal key of the owning ReqIF spec-object type.
836 definition_key (str): Internal dictionary key used to look up or create the attribute definition.
837 long_name (str): Human-readable attribute name registered in the spec-object type.
838 literal_names (list): List of TRLC enumeration literal names for the attribute value.
839 enum_type (Enumeration_Type): The TRLC enumeration type.
841 Returns:
842 SpecObjectAttribute: The created attribute.
843 """
844 definition_identifier = self._ensure_enum_attribute_definition(
845 type_key, definition_key, long_name, enum_type
846 )
847 registry = self._enum_datatype_registry[enum_type.name]
848 value_identifiers = [registry["literal_identifier_by_name"][name] for name in literal_names]
850 return SpecObjectAttribute(
851 attribute_type=SpecObjectAttributeType.ENUMERATION,
852 definition_ref=definition_identifier,
853 value=value_identifiers
854 )
856 def _ensure_enum_datatype(self, enum_type: Enumeration_Type) -> str:
857 # lobster-trace: SwRequirements.sw_req_reqif_enum
858 # lobster-trace: SwRequirements.sw_req_reqif_enum_key_order
859 """Return the identifier of an existing DATATYPE-DEFINITION-ENUMERATION, or create and register a new one.
861 ENUM-VALUE keys are assigned as consecutive integers starting at 0,
862 in the order the literals are declared in the RSL enumeration.
864 Args:
865 enum_type (Enumeration_Type): The TRLC enumeration type.
867 Returns:
868 str: The datatype definition identifier.
869 """
870 if enum_type.name in self._enum_datatype_registry:
871 return self._enum_datatype_registry[enum_type.name]["identifier"]
873 last_change = self._get_reqif_timestamp()
874 sanitized = self._sanitize_identifier_token(enum_type.name)
875 datatype_identifier = f"datatype-enum-{sanitized}"
877 enum_values = []
878 literal_identifier_by_name = {}
879 for key_idx, literal_spec in enumerate(enum_type.literals.table.values()):
880 value_identifier = f"{datatype_identifier}-value-{key_idx}"
881 enum_values.append(ReqIFEnumValue(
882 identifier=value_identifier,
883 key=str(key_idx),
884 long_name=literal_spec.name,
885 last_change=last_change,
886 other_content=""
887 ))
888 literal_identifier_by_name[literal_spec.name] = value_identifier
890 self._enum_datatype_registry[enum_type.name] = {
891 "identifier": datatype_identifier,
892 "long_name": enum_type.name,
893 "values": enum_values,
894 "literal_identifier_by_name": literal_identifier_by_name
895 }
897 return datatype_identifier
899 def _ensure_enum_attribute_definition(self, type_key: str, definition_key: str,
900 long_name: str, enum_type: Enumeration_Type) -> str:
901 # lobster-trace: SwRequirements.sw_req_reqif_enum
902 """Return the identifier of an existing ATTRIBUTE-DEFINITION-ENUMERATION, or create and register a new one.
904 Args:
905 type_key (str): Internal key of the owning ReqIF spec-object type.
906 definition_key (str): Internal dictionary key for the attribute definition.
907 long_name (str): Human-readable name to assign when creating a new definition.
908 enum_type (Enumeration_Type): The TRLC enumeration type.
910 Returns:
911 str: The attribute definition identifier.
912 """
913 attribute_definitions = self._attribute_definitions_by_type.setdefault(type_key, {})
915 if definition_key in attribute_definitions:
916 return attribute_definitions[definition_key].identifier
918 type_identifier = self._ensure_spec_object_type(type_key, type_key)
919 sanitized_name = self._sanitize_identifier_token(definition_key)
920 definition_identifier = f"attribute-{type_identifier}-{sanitized_name}"
921 enum_datatype_identifier = self._ensure_enum_datatype(enum_type)
923 definition = SpecAttributeDefinition(
924 attribute_type=SpecObjectAttributeType.ENUMERATION,
925 identifier=definition_identifier,
926 datatype_definition=enum_datatype_identifier,
927 long_name=long_name,
928 last_change=self._get_reqif_timestamp(),
929 multi_valued=False
930 )
932 attribute_definitions[definition_key] = definition
934 return definition_identifier
936 @staticmethod
937 def _get_field_enum_type(record: Record_Object, field_name: str) -> Optional[Enumeration_Type]:
938 # lobster-trace: SwRequirements.sw_req_reqif_enum
939 # lobster-trace: SwRequirements.sw_req_reqif_enum_null
940 """Return the TRLC Enumeration_Type for the named field, or None if it is not an enum field.
942 Traverses the component hierarchy including inherited components.
944 Args:
945 record (Record_Object): The TRLC record object.
946 field_name (str): The field name to look up.
948 Returns:
949 Optional[Enumeration_Type]: The enumeration type, or None.
950 """
951 simplified = Symbol_Table.simplified_name(field_name)
952 stab = getattr(record.n_typ, "components", None)
953 component = None
955 while stab is not None and component is None:
956 component = stab.table.get(simplified)
957 stab = stab.parent
959 if component is not None and isinstance(component.n_typ, Enumeration_Type):
960 return component.n_typ
962 return None
964 @staticmethod
965 def _collect_enum_values_from_expression(value: Expression) -> Optional[list]:
966 # lobster-trace: SwRequirements.sw_req_reqif_enum
967 # lobster-trace: SwRequirements.sw_req_reqif_enum_null
968 """Collect TRLC enumeration literal names from a field expression.
970 Args:
971 value (Expression): The TRLC field expression.
973 Returns:
974 Optional[list]: List of literal name strings, or None if the expression is null or unsupported.
975 """
976 if isinstance(value, Enumeration_Literal):
977 return [value.value.name]
979 if isinstance(value, Array_Aggregate):
980 names = [item.value.name for item in value.value if isinstance(item, Enumeration_Literal)]
981 return names if names else None
983 return None
985 def _ensure_spec_object_type(self, type_key: str, long_name: str) -> str:
986 # lobster-trace: SwRequirements.sw_req_reqif_record
987 """Return the identifier of an existing ReqIF spec-object type, or create and register a new one.
989 Args:
990 type_key (str): Internal dictionary key for the spec-object type.
991 long_name (str): Human-readable name to assign when creating a new type.
993 Returns:
994 str: The spec-object type identifier.
995 """
996 if type_key in self._spec_object_type_info:
997 return self._spec_object_type_info[type_key]["identifier"]
999 sanitized_name = self._sanitize_identifier_token(type_key)
1000 type_identifier = f"spec-object-type-{sanitized_name}"
1002 self._spec_object_type_info[type_key] = {
1003 "identifier": type_identifier,
1004 "long_name": long_name
1005 }
1006 self._attribute_definitions_by_type[type_key] = {}
1008 return type_identifier
1010 def _get_spec_object_type_identifier(self, type_key: str) -> str:
1011 # lobster-trace: SwRequirements.sw_req_reqif_record
1012 """Return the identifier of the requested ReqIF spec-object type.
1014 Args:
1015 type_key (str): Internal dictionary key for the spec-object type.
1017 Returns:
1018 str: The spec-object type identifier.
1019 """
1020 type_info = self._spec_object_type_info.get(type_key)
1021 assert type_info is not None
1022 return type_info["identifier"]
1024 def _ensure_attribute_definition(self, type_key: str, definition_key: str, long_name: str,
1025 attribute_type: SpecObjectAttributeType) -> str:
1026 # lobster-trace: SwRequirements.sw_req_reqif_record
1027 """Return the identifier of an existing attribute definition, or create and register a new one.
1029 Args:
1030 type_key (str): Internal key of the owning ReqIF spec-object type.
1031 definition_key (str): Internal dictionary key for the attribute definition.
1032 long_name (str): Human-readable name to assign when creating a new definition.
1033 attribute_type (SpecObjectAttributeType): ReqIF attribute type.
1035 Returns:
1036 str: The attribute definition identifier.
1037 """
1038 attribute_definitions = self._attribute_definitions_by_type.setdefault(type_key, {})
1040 if definition_key in attribute_definitions:
1041 definition = attribute_definitions[definition_key]
1042 assert isinstance(definition, SpecAttributeDefinition)
1043 return definition.identifier
1045 type_identifier = self._ensure_spec_object_type(type_key, type_key)
1046 sanitized_name = self._sanitize_identifier_token(definition_key)
1047 definition_identifier = f"attribute-{type_identifier}-{sanitized_name}"
1049 definition = SpecAttributeDefinition(
1050 attribute_type=attribute_type,
1051 identifier=definition_identifier,
1052 datatype_definition=self._get_datatype_identifier(attribute_type),
1053 long_name=long_name,
1054 last_change=self._get_reqif_timestamp(),
1055 multi_valued=None
1056 )
1058 attribute_definitions[definition_key] = definition
1060 return definition_identifier
1062 def _get_record_type_key(self, record: Record_Object) -> str:
1063 # lobster-trace: SwRequirements.sw_req_reqif_record
1064 """Return the ReqIF type key for the given TRLC record.
1066 Args:
1067 record (Record_Object): The record object.
1069 Returns:
1070 str: ReqIF type key derived from the TRLC record type.
1071 """
1072 return record.n_typ.name
1074 def _queue_spec_relation(self, source_record: Record_Object, record_reference: Record_Reference,
1075 relation_name: str) -> None:
1076 # lobster-trace: SwRequirements.sw_req_reqif_relation
1077 """Queue a ReqIF spec relation derived from a TRLC record reference.
1079 Args:
1080 source_record (Record_Object): Source record containing the reference.
1081 record_reference (Record_Reference): Referenced target record.
1082 relation_name (str): TRLC field name that defines the relation type.
1083 """
1084 assert record_reference.target is not None
1086 self._ensure_spec_relation_type(relation_name, relation_name)
1087 self._pending_relations.append(
1088 {
1089 "source_record": id(source_record),
1090 "target_record": id(record_reference.target),
1091 "relation_type_key": relation_name,
1092 "long_name": relation_name
1093 }
1094 )
1096 def _queue_spec_relations_from_expression(self, source_record: Record_Object, expression: Expression,
1097 relation_name: str) -> bool:
1098 # lobster-trace: SwRequirements.sw_req_reqif_relation
1099 """Queue ReqIF spec relations from a TRLC expression if it consists of record references.
1101 Args:
1102 source_record (Record_Object): Source record containing the reference expression.
1103 expression (Expression): TRLC field expression to inspect.
1104 relation_name (str): TRLC field name that defines the relation type.
1106 Returns:
1107 bool: True if the expression was handled as one or more record references.
1108 """
1109 if isinstance(expression, Record_Reference):
1110 self._queue_spec_relation(source_record, expression, relation_name)
1111 return True
1113 if isinstance(expression, Array_Aggregate):
1114 has_record_references = False
1116 for item in expression.value:
1117 item_is_relation = self._queue_spec_relations_from_expression(
1118 source_record,
1119 item,
1120 relation_name
1121 )
1122 has_record_references = has_record_references or item_is_relation
1124 return has_record_references
1126 return False
1128 def _ensure_spec_relation_type(self, relation_type_key: str, long_name: str) -> str:
1129 # lobster-trace: SwRequirements.sw_req_reqif_relation
1130 """Return the identifier of an existing ReqIF spec-relation type, or create and register a new one.
1132 Args:
1133 relation_type_key (str): Internal dictionary key for the spec-relation type.
1134 long_name (str): Human-readable relation type name.
1136 Returns:
1137 str: The spec-relation type identifier.
1138 """
1139 if relation_type_key in self._spec_relation_type_info:
1140 return self._spec_relation_type_info[relation_type_key]["identifier"]
1142 relation_type_identifier = f"spec-relation-type-{self._sanitize_identifier_token(relation_type_key)}"
1143 self._spec_relation_type_info[relation_type_key] = {
1144 "identifier": relation_type_identifier,
1145 "long_name": long_name
1146 }
1148 return relation_type_identifier
1150 def _build_spec_relations(self) -> list[ReqIFSpecRelation]:
1151 # lobster-trace: SwRequirements.sw_req_reqif_relation
1152 """Build ReqIF spec relations from queued TRLC record references.
1154 Returns:
1155 list[ReqIFSpecRelation]: Resolved ReqIF spec relations.
1156 """
1157 self._spec_relations = []
1159 for pending_relation in self._pending_relations:
1160 source_identifier = self._spec_object_identifier_by_record.get(pending_relation["source_record"])
1161 target_identifier = self._spec_object_identifier_by_record.get(pending_relation["target_record"])
1163 if source_identifier is None or target_identifier is None:
1164 log_error(
1165 "Failed to create ReqIF relation because the source or target record is not part of the output.",
1166 False
1167 )
1168 continue
1170 relation_type_identifier = self._ensure_spec_relation_type(
1171 pending_relation["relation_type_key"],
1172 pending_relation["long_name"]
1173 )
1175 self._spec_relations.append(
1176 ReqIFSpecRelation(
1177 identifier=self._obtain_identifier(
1178 f"spec-relation:{source_identifier}:{relation_type_identifier}:{target_identifier}",
1179 "spec-relation"),
1180 relation_type_ref=relation_type_identifier,
1181 source=source_identifier,
1182 target=target_identifier,
1183 last_change=self._get_reqif_timestamp(),
1184 long_name=pending_relation["long_name"]
1185 )
1186 )
1188 return self._spec_relations
1190 @staticmethod
1191 def _get_datatype_identifier(attribute_type: SpecObjectAttributeType) -> str:
1192 # lobster-trace: SwRequirements.sw_req_reqif
1193 """Return the datatype definition identifier for the given ReqIF attribute type.
1195 Args:
1196 attribute_type (SpecObjectAttributeType): ReqIF attribute type.
1198 Returns:
1199 str: Matching datatype definition identifier.
1200 """
1201 if attribute_type == SpecObjectAttributeType.STRING:
1202 return ReqifConverter.DATATYPE_STRING_IDENTIFIER
1204 if attribute_type == SpecObjectAttributeType.XHTML:
1205 return ReqifConverter.DATATYPE_XHTML_IDENTIFIER
1207 raise NotImplementedError(attribute_type)
1209 @staticmethod
1210 def _sanitize_identifier_token(value: str) -> str:
1211 # lobster-trace: SwRequirements.sw_req_reqif
1212 """Sanitize an identifier token for stable ReqIF identifiers.
1214 Args:
1215 value (str): Raw identifier token.
1217 Returns:
1218 str: Sanitized lowercase token.
1219 """
1220 sanitized_value = re.sub(r"[^a-zA-Z0-9]+", "-", value.lower()).strip("-")
1221 return sanitized_value if len(sanitized_value) > 0 else "item"
1223 def _file_name_trlc_to_reqif(self, file_name_trlc: str) -> str:
1224 # lobster-trace: SwRequirements.sw_req_reqif_multiple_doc_mode
1225 """Convert a TRLC file name to a ReqIF file name.
1227 Args:
1228 file_name_trlc (str): TRLC file name
1230 Returns:
1231 str: ReqIF file name
1232 """
1233 file_name = os.path.basename(file_name_trlc)
1234 file_name = os.path.splitext(file_name)[0] + ".reqif"
1236 return file_name
1238 def _on_implict_null(self, _: Implicit_Null) -> str:
1239 # lobster-trace: SwRequirements.sw_req_reqif_record
1240 """
1241 Process the given implicit null value.
1243 Returns:
1244 str: The configured empty attribute value.
1245 """
1246 return self._empty_attribute_value
1248 def _on_record_reference(self, record_reference: Record_Reference) -> str:
1249 # lobster-trace: SwRequirements.sw_req_reqif_record
1250 """
1251 Process the given record reference value.
1253 Args:
1254 record_reference (Record_Reference): The record reference value.
1256 Returns:
1257 str: String representation of the referenced record.
1258 """
1259 return str(record_reference.to_python_object())
1261 def _on_string_literal(self, string_literal: String_Literal) -> str:
1262 # lobster-trace: SwRequirements.sw_req_reqif_record
1263 """
1264 Process the given string literal value.
1266 Args:
1267 string_literal (String_Literal): The string literal value.
1269 Returns:
1270 str: The string literal value.
1271 """
1272 return string_literal.to_string()
1274 def _other_dispatcher(self, expression: Expression) -> str:
1275 # lobster-trace: SwRequirements.sw_req_reqif_record
1276 """
1277 Dispatcher for all other expressions.
1279 Args:
1280 expression (Expression): The expression to process.
1282 Returns:
1283 str: The processed expression.
1284 """
1285 return expression.to_string()
1287 def _get_trlc_ast_walker(self) -> TrlcAstWalker:
1288 # lobster-trace: SwRequirements.sw_req_reqif_record
1289 """
1290 Create and configure a TrlcAstWalker for traversing TRLC record field values.
1292 Returns:
1293 TrlcAstWalker: The configured TRLC AST walker.
1294 """
1295 trlc_ast_walker = TrlcAstWalker()
1296 trlc_ast_walker.add_dispatcher(
1297 Implicit_Null,
1298 None,
1299 self._on_implict_null,
1300 None
1301 )
1302 trlc_ast_walker.add_dispatcher(
1303 Record_Reference,
1304 None,
1305 self._on_record_reference,
1306 None
1307 )
1308 trlc_ast_walker.add_dispatcher(
1309 String_Literal,
1310 None,
1311 self._on_string_literal,
1312 None
1313 )
1314 trlc_ast_walker.set_other_dispatcher(self._other_dispatcher)
1316 return trlc_ast_walker
1318 def _render(self, package_name: str, type_name: str, attribute_name: str, attribute_value: str) -> str:
1319 # lobster-trace: SwRequirements.sw_req_reqif_render_md
1320 # lobster-trace: SwRequirements.sw_req_reqif_render_gfm
1321 # lobster-trace: SwRequirements.sw_req_reqif_render_xhtml
1322 # lobster-trace: SwRequirements.sw_req_reqif_render_path
1323 # lobster-trace: SwRequirements.sw_req_reqif_render_table_options
1324 """Render an attribute value to an XHTML-wrapped string based on the render configuration.
1326 If the attribute is configured as CommonMark Markdown, it is converted via marko.
1327 If configured as GFM, it is converted with the GFM extension.
1328 If configured as XHTML, the value is passed through as-is inside the XHTML wrapper.
1329 If configured as path, the value is treated as a file path and converted to an XHTML
1330 ``<object>`` element; the file is scheduled for copying to the output folder.
1331 Otherwise plain text is HTML-escaped and wrapped.
1333 For Markdown and GFM formats, optional table rendering options (``border``,
1334 ``headingStyle``) from the render configuration are applied to the generated HTML.
1336 Args:
1337 package_name (str): TRLC package name of the record.
1338 type_name (str): TRLC type name of the record.
1339 attribute_name (str): Attribute field name.
1340 attribute_value (str): Raw attribute value string.
1342 Returns:
1343 str: XHTML-wrapped content string.
1344 """
1345 if self._render_cfg.is_format_md(package_name, type_name, attribute_name) is True:
1346 table_options = self._render_cfg.get_table_options(package_name, type_name, attribute_name)
1347 return self._markdown_to_xhtml(attribute_value, gfm_mode=False, table_options=table_options)
1349 if self._render_cfg.is_format_gfm(package_name, type_name, attribute_name) is True:
1350 table_options = self._render_cfg.get_table_options(package_name, type_name, attribute_name)
1351 return self._markdown_to_xhtml(attribute_value, gfm_mode=True, table_options=table_options)
1353 if self._render_cfg.is_format_xhtml(package_name, type_name, attribute_name) is True:
1354 if "<" in attribute_value:
1355 return self._wrap_xhtml(attribute_value)
1356 return self._plain_text_to_xhtml(attribute_value)
1358 if self._render_cfg.is_format_path(package_name, type_name, attribute_name) is True:
1359 return self._path_to_xhtml(attribute_value)
1361 return self._plain_text_to_xhtml(attribute_value)
1363 def _plain_text_to_xhtml(self, plain_text: str) -> str:
1364 # lobster-trace: SwRequirements.sw_req_reqif_record
1365 """Convert plain text to an XHTML-wrapped string.
1367 Blank-line-separated paragraphs are wrapped in ``<p>`` tags; single
1368 newlines within a paragraph become ``<br/>`` elements.
1370 Args:
1371 plain_text (str): Plain text to convert.
1373 Returns:
1374 str: XHTML-wrapped content string.
1375 """
1376 escaped = html.escape(plain_text)
1378 paragraph_list = []
1380 for paragraph in escaped.split("\n\n"):
1381 paragraph_linebreaks = paragraph.replace("\n", "<br/>")
1382 paragraph_list.append(f"<p>{paragraph_linebreaks}</p>")
1384 return self._wrap_xhtml("".join(paragraph_list))
1386 def _markdown_to_xhtml(self, markdown_text: str, gfm_mode: bool,
1387 table_options: Optional[dict] = None) -> str:
1388 # lobster-trace: SwRequirements.sw_req_reqif_render_md
1389 # lobster-trace: SwRequirements.sw_req_reqif_render_gfm
1390 # lobster-trace: SwRequirements.sw_req_reqif_render_table_options
1391 # lobster-trace: SwRequirements.sw_req_reqif_render_plantuml
1392 """Convert Markdown text to an XHTML-wrapped string using marko.
1394 If ``table_options`` is provided and non-empty, table styling is applied to the
1395 generated HTML before wrapping (see :meth:`_apply_table_options`).
1397 Fenced code blocks tagged ``plantuml`` are rendered as embedded SVG
1398 images via :class:`Md2ReqifRenderer` / :class:`Gfm2ReqifRenderer`; the
1399 generated images are registered in ``self._external_files`` so they are
1400 copied next to the ReqIF document.
1402 Args:
1403 markdown_text (str): Markdown source text.
1404 gfm_mode (bool): If True, use the GFM extension; otherwise use CommonMark.
1405 table_options (Optional[dict]): Optional table rendering options with keys
1406 ``"border"`` and/or ``"headingStyle"``.
1408 Returns:
1409 str: XHTML-wrapped HTML string.
1410 """
1411 renderer = self._markdown_renderer_gfm if gfm_mode else self._markdown_renderer_md
1413 # Wire the inline-PlantUML renderer state to this converter so generated
1414 # SVGs are written to the converter's temp directory and tracked in
1415 # _external_files for copying.
1416 assert self._plantuml_tmp_dir is not None
1417 Md2ReqifRenderer.image_dir = self._plantuml_tmp_dir.name
1418 Md2ReqifRenderer.external_files = self._external_files
1420 html_text = renderer.convert(markdown_text).strip()
1422 if len(html_text) == 0:
1423 html_text = "<p></p>"
1425 # Marko's GFM renderer outputs HTML void elements (e.g. <input ...>) for task list
1426 # checkboxes. XHTML requires all void elements to be self-closed (<input ... />).
1427 html_text = re.sub(r'<(input)(\s[^>]*)>', r'<\1\2/>', html_text)
1429 if table_options:
1430 html_text = self._apply_table_options(html_text, table_options)
1432 return self._wrap_xhtml(html_text)
1434 @staticmethod
1435 def _apply_table_options(html_text: str, table_options: dict) -> str:
1436 # lobster-trace: SwRequirements.sw_req_reqif_render_table_options
1437 """Apply table rendering options to generated HTML.
1439 Replaces the plain ``<table>`` tag with one carrying a ``style`` attribute when
1440 ``"border"`` is set, and injects a ``style`` attribute into every ``<th>`` tag
1441 when ``"headingStyle"`` is set. Values are HTML-escaped before insertion.
1443 Args:
1444 html_text (str): HTML string produced by the Markdown renderer.
1445 table_options (dict): Table options with optional keys ``"border"`` (str) and
1446 ``"headingStyle"`` (str), both interpreted as CSS style values.
1448 Returns:
1449 str: HTML string with table styling applied.
1450 """
1451 result = html_text
1453 table_border = table_options.get("border", "")
1454 if table_border:
1455 escaped = html.escape(table_border, quote=True)
1456 result = result.replace("<table>", f'<table style="{escaped}">')
1458 heading_style = table_options.get("headingStyle", "")
1459 if heading_style:
1460 escaped = html.escape(heading_style, quote=True)
1461 result = re.sub(
1462 r'<th((?:\s+align="[^"]*")?)>',
1463 lambda m: f'<th{m.group(1)} style="{escaped}">',
1464 result
1465 )
1467 return result
1469 def _path_to_xhtml(self, file_path: str) -> str:
1470 # lobster-trace: SwRequirements.sw_req_reqif_render_path
1471 """Convert a file path to a ReqIF XHTML ``<object>`` element and schedule the file for copying.
1473 The file is referenced locally by its basename. The MIME type is determined from
1474 the file extension; if unknown, ``application/octet-stream`` is used. If ``file_path``
1475 is empty the value is rendered as plain text instead.
1477 Args:
1478 file_path (str): Path to the external file (may be relative or absolute).
1480 Returns:
1481 str: XHTML-wrapped ``<object>`` element string.
1482 """
1483 if len(file_path) == 0:
1484 return self._plain_text_to_xhtml(file_path)
1486 local_name = os.path.basename(file_path)
1487 mime_type, _ = mimetypes.guess_type(file_path)
1488 if mime_type is None:
1489 mime_type = "application/octet-stream"
1491 self._external_files.append((file_path, local_name))
1493 return self._wrap_xhtml(
1494 f'<object type="{html.escape(mime_type)}" data="{html.escape(local_name)}"></object>'
1495 )
1497 def _copy_external_files(self, dest_dir: str) -> None:
1498 # lobster-trace: SwRequirements.sw_req_reqif_render_path
1499 """Copy all collected external files to the given destination directory.
1501 Each file is copied using its basename as the destination file name. Duplicate
1502 source paths are copied only once. If a source file cannot be read, an error
1503 is logged and the file is skipped.
1505 Args:
1506 dest_dir (str): Destination directory path.
1507 """
1508 copied_sources = set()
1510 for source_path, local_name in self._external_files:
1511 if source_path in copied_sources:
1512 continue
1514 dest_path = os.path.join(dest_dir, local_name)
1516 try:
1517 shutil.copy2(source_path, dest_path)
1518 copied_sources.add(source_path)
1519 except (OSError, IOError) as exc:
1520 log_error(f"Failed to copy external file '{source_path}': {exc}", False)
1522 @staticmethod
1523 def _wrap_xhtml(fragment: str) -> str:
1524 # lobster-trace: SwRequirements.sw_req_reqif
1525 """Wrap an HTML fragment in a ReqIF-compatible XHTML div element.
1527 Args:
1528 fragment (str): Inner HTML fragment.
1530 Returns:
1531 str: XHTML-wrapped string.
1532 """
1533 return "<div xmlns=\"http://www.w3.org/1999/xhtml\">" + fragment + "</div>"
1535 @staticmethod
1536 def _get_reqif_timestamp() -> str:
1537 # lobster-trace: SwRequirements.sw_req_reqif
1538 """Return the current UTC time as a ReqIF-compatible ISO 8601 timestamp.
1540 Returns:
1541 str: ISO 8601 timestamp string (UTC, no microseconds).
1542 """
1543 return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
1545 def _new_identifier(self, prefix: str) -> str:
1546 # lobster-trace: SwRequirements.sw_req_reqif
1547 """Generate a unique identifier by combining the given prefix with an auto-incrementing counter.
1549 Args:
1550 prefix (str): Identifier prefix (e.g. ``"spec-object"`` or ``"hierarchy"``)
1552 Returns:
1553 str: Unique identifier string.
1554 """
1555 self._id_counter += 1
1556 return f"{prefix}-{self._id_counter}"
1558 def _obtain_identifier(self, key: str, prefix: str) -> str:
1559 # lobster-trace: SwRequirements.sw_req_reqif_identifier_immutable
1560 """Obtain an identifier for an Identifiable element, persistent if a store is enabled.
1562 When an identifier store is enabled (``--id-store``) the identifier is looked up
1563 by its stable logical key and reused if known, keeping it immutable across
1564 consecutive exports. Otherwise a volatile auto-incremented identifier is used.
1566 Args:
1567 key (str): Stable logical key identifying the ReqIF element.
1568 prefix (str): Identifier prefix (e.g. ``"spec-object"`` or ``"hierarchy"``).
1570 Returns:
1571 str: The identifier associated with the element.
1572 """
1573 if self._id_store is not None:
1574 identifier = self._id_store.get_or_create(key, prefix)
1575 else:
1576 identifier = self._new_identifier(prefix)
1578 return identifier