From 5a11b685bdf8d3ade81d87e678fa6a1e4582d9b0 Mon Sep 17 00:00:00 2001 From: Jochen Hoenle Date: Thu, 25 Jun 2026 10:41:22 +0200 Subject: [PATCH] [trlc rst] refactor - expose meaninful public api - implement configurable rendering --- tools/trlc_rst/tests/simple-flat/schema.rsl | 2 +- tools/trlc_rst/tests/with-image/schema.rsl | 2 +- tools/trlc_rst/tests/with-sections/schema.rsl | 2 +- tools/trlc_rst/trlc_rst.py | 325 +++++++++++++++--- 4 files changed, 289 insertions(+), 42 deletions(-) diff --git a/tools/trlc_rst/tests/simple-flat/schema.rsl b/tools/trlc_rst/tests/simple-flat/schema.rsl index cf14224..27a5e22 100644 --- a/tools/trlc_rst/tests/simple-flat/schema.rsl +++ b/tools/trlc_rst/tests/simple-flat/schema.rsl @@ -1,5 +1,5 @@ package T type Requirement { - description optional String + description optional Markup_String } diff --git a/tools/trlc_rst/tests/with-image/schema.rsl b/tools/trlc_rst/tests/with-image/schema.rsl index cf14224..27a5e22 100644 --- a/tools/trlc_rst/tests/with-image/schema.rsl +++ b/tools/trlc_rst/tests/with-image/schema.rsl @@ -1,5 +1,5 @@ package T type Requirement { - description optional String + description optional Markup_String } diff --git a/tools/trlc_rst/tests/with-sections/schema.rsl b/tools/trlc_rst/tests/with-sections/schema.rsl index cf14224..27a5e22 100644 --- a/tools/trlc_rst/tests/with-sections/schema.rsl +++ b/tools/trlc_rst/tests/with-sections/schema.rsl @@ -1,5 +1,5 @@ package T type Requirement { - description optional String + description optional Markup_String } diff --git a/tools/trlc_rst/trlc_rst.py b/tools/trlc_rst/trlc_rst.py index a2fa57f..c121ccc 100644 --- a/tools/trlc_rst/trlc_rst.py +++ b/tools/trlc_rst/trlc_rst.py @@ -16,6 +16,7 @@ _MARKUP_REF_RE = re.compile(r"\[\[([^\]]+)\]\]") _RST_DIRECTIVE_RE = re.compile(r"^\.\. [a-zA-Z][\w-]*::") _MD_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(([^\)]+)\)") +_DEFAULT_FIELDS = ["description"] class TRLCParseError(Exception): @@ -54,6 +55,23 @@ def __init__( self._symbols = None self._requirements_tree = None + @staticmethod + def _default_label(field_name: str) -> str: + """Derive a human-readable column/field label from a field name.""" + return field_name.replace("_", " ").strip().title() + + @classmethod + def _normalize_fields(cls, fields: list[str] | dict[str, str]) -> list[tuple[str, str]]: + """Convert a field spec to ``[(field_name, label), ...]``. + + Accepts: + - ``list[str]``: labels are derived automatically from field names. + - ``dict[str, str]``: maps ``{field_name: label}`` (custom labels). + """ + if isinstance(fields, dict): + return list(fields.items()) + return [(f, cls._default_label(f)) for f in fields] + def _resolve_source_files(self) -> set[str] | None: """Resolve source file paths to absolute paths for filtering. @@ -70,7 +88,11 @@ def parse_trlc_files(self) -> None: When *input_directory* is set the directory is scanned via ``register_directory``. Otherwise every file in *dep_files* and *source_files* is registered individually via ``register_file``. + + Idempotent: if files have already been parsed this call is a no-op. """ + if self._symbols is not None: + return message_handler = Message_Handler() source_manager = Source_Manager(message_handler) @@ -95,7 +117,7 @@ def parse_trlc_files(self) -> None: self._symbols = symbols - def convert_symbols_to_tree(self) -> None: + def convert_symbols_to_tree(self, records: set[str] | None = None) -> None: """Convert parsed symbols to a hierarchical tree structure. Tree Construction Algorithm: @@ -110,20 +132,13 @@ def convert_symbols_to_tree(self) -> None: Example: If two requirements share sections "ISO 26262" -> "Management", those sections are created once and both requirements become children. - """ - if self._symbols is None: - raise ValueError("Symbols not parsed. Call parse_trlc_files() first.") - - source_file_set = self._resolve_source_files() + Args: + records: Optional set of fully-qualified names to include. + """ requirements_root = Node("root") obj: trlc.ast.Record_Object - for obj in self._symbols.iter_record_objects(): - # If source files are specified, only include objects from those files - if source_file_set is not None: - obj_file = os.path.abspath(obj.location.file_name) - if obj_file not in source_file_set: - continue + for obj in self._iter_filtered_objects(records): parent_root = Node("root") parent_tree = self._build_parent_tree(obj.section, parent_root) # Store the TRLC object in the node so we can access its attributes later @@ -150,7 +165,12 @@ def convert_symbols_to_tree(self) -> None: self._requirements_tree = requirements_root - def render_to_file(self, output_path: str, title: str = "Requirements") -> None: + def render_to_file( + self, + output_path: str, + title: str = "Requirements", + fields: list[str] | None = None, + ) -> None: """Render the requirements tree to a reStructuredText file. Args: @@ -159,13 +179,16 @@ def render_to_file(self, output_path: str, title: str = "Requirements") -> None: Pass an empty string to omit the title entirely (useful when the output is ``.. include::``d inside an already-titled section). + fields: Field spec (see :meth:`render`). Defaults to + ``["description"]``. """ - if self._requirements_tree is None: raise ValueError( "Requirements tree not built. Call convert_symbols_to_tree() first." ) + normalized = self._normalize_fields(fields if fields is not None else _DEFAULT_FIELDS) + with open(output_path, "w", newline="", encoding="utf-8") as file: if title: file.write(f"{title}\n") @@ -195,23 +218,209 @@ def render_to_file(self, output_path: str, title: str = "Requirements") -> None: file.write(f"{separator_char * len(node.name)}\n") else: - file.write(f".. requirement:definition:: {node.name}\n\n") trlc_obj = getattr(node, "trlc_obj", None) + file.write( + "\n".join(self._render_record_block(node.name, trlc_obj, normalized)) + ) + file.write("\n") - if trlc_obj: - desc_field = trlc_obj.field.get("description") - if ( - isinstance(desc_field, trlc.ast.String_Literal) - and desc_field.has_references - ): - description = TRLCRST._resolve_markup_references( - desc_field.value, desc_field.references - ) - else: - description = trlc_obj.to_python_dict().get("description") - if description: - file.write(self._preprocess_description(description)) - file.write("\n\n") + @staticmethod + def _is_markup_field(trlc_obj, field_name: str) -> bool: + """Return True when *field_name* is a ``Markup_String`` field.""" + if trlc_obj is None: + return False + field_node = trlc_obj.field.get(field_name) + return isinstance(field_node, trlc.ast.String_Literal) and field_node.has_references + + def _field_value(self, trlc_obj, field_name: str) -> str | None: + """Return the rendered value of *field_name* on *trlc_obj*. + + ``Markup_String`` fields get ``[[...]]`` markup-reference resolution; + all other fields are read from ``to_python_dict()`` (enums become their + literal name). Returns ``None`` when the field is absent or ``None``. + """ + if trlc_obj is None: + return None + field_node = trlc_obj.field.get(field_name) + if isinstance(field_node, trlc.ast.String_Literal) and field_node.has_references: + return TRLCRST._resolve_markup_references( + field_node.value, field_node.references + ) + return trlc_obj.to_python_dict().get(field_name) + + def _render_record_block(self, fqn: str, trlc_obj, fields: list[tuple[str, str]]) -> list: + """Render one record as a ``requirement:definition`` directive block. + + ``Markup_String`` fields are rendered as preprocessed prose paragraphs. + All other fields are rendered as ``:Label: value`` field-list entries. + + Returns a list of RST lines (with a trailing blank line). + """ + lines = [f".. requirement:definition:: {fqn}", ""] + + field_list = [] + block_values = [] + for field_name, label in fields: + value = self._field_value(trlc_obj, field_name) + if value is None or value == "": + continue + if self._is_markup_field(trlc_obj, field_name): + block_values.append(value) + else: + field_list.append((label, value)) + + for label, value in field_list: + lines.append(f":{label}: {value}") + if field_list: + lines.append("") + for value in block_values: + lines.append(self._preprocess_description(value)) + lines.append("") + if not field_list and not block_values: + lines.append("") + return lines + + def _iter_filtered_objects(self, records: set[str] | None = None): + """Yield record objects honouring the source-file and fqn filters. + + Args: + records: Optional set of fully-qualified names; when given, only + records in the set are yielded (applied on top of the + source-file filter). + """ + if self._symbols is None: + raise ValueError("Symbols not parsed. Call parse_trlc_files() first.") + source_file_set = self._resolve_source_files() + for obj in self._symbols.iter_record_objects(): + if source_file_set is not None: + if os.path.abspath(obj.location.file_name) not in source_file_set: + continue + if records is not None and obj.fully_qualified_name() not in records: + continue + yield obj + + def _objects_by_fqn(self, records: set[str] | None = None) -> dict: + """Return ``{fully_qualified_name: trlc_obj}`` for all filtered records.""" + return {o.fully_qualified_name(): o for o in self._iter_filtered_objects(records)} + + def objects_by_fqn(self, records: set[str] | None = None) -> dict: + """Public access to ``{fqn: trlc_obj}`` for callers needing raw TRLC objects.""" + return self._objects_by_fqn(records) + + def field_value_for( + self, + fqn: str, + field_name: str, + records: set[str] | None = None, + ) -> str | None: + """Return the rendered value of *field_name* for the record *fqn*. + + For string fields (both plain ``String`` and ``Markup_String``), + applies Markdown/RST preprocessing and returns a flush-left string + with no directive-body indent, so the caller can embed it freely + (e.g. inside a sphinx-design ``dropdown``). + ``Markup_String`` fields additionally get ``[[...]]`` reference + resolution. Non-string fields are returned as their plain string + representation. + + Args: + fqn: Fully-qualified record name. + field_name: Name of the field to render. + records: Optional FQN filter (same semantics as in render methods). + + Returns: + Rendered string, or ``None`` if the record or field is absent. + """ + obj = self._objects_by_fqn(records).get(fqn) + if obj is None: + return None + value = self._field_value(obj, field_name) + if not value: + return None + field_node = obj.field.get(field_name) + if isinstance(field_node, trlc.ast.String_Literal): + return self._preprocess_description(value, indent=0) + return value + + def render_records_to_string( + self, + fields: list[str] | None = None, + fqns: list[str] | None = None, + records: set[str] | None = None, + ) -> str: + """Render selected records to an RST string (no section headings). + + Args: + fields: Field names to render. Defaults to ``["description"]``. + fqns: Ordered list of fully-qualified record names to render. + ``None`` renders every record passing the source-file and + *records* filters, in parse order. Unknown names are skipped. + records: Optional set of fully-qualified names to include + (additional filter on top of the source-file filter). + + Returns: + RST string ending with a newline. + """ + normalized = self._normalize_fields(fields if fields is not None else _DEFAULT_FIELDS) + obj_map = self._objects_by_fqn(records) + if fqns is None: + objs = list(self._iter_filtered_objects(records)) + else: + objs = [obj_map[f] for f in fqns if f in obj_map] + + lines = [] + for obj in objs: + lines += self._render_record_block( + obj.fully_qualified_name(), obj, normalized + ) + return "\n".join(lines) + "\n" + + def render_table_to_string( + self, + columns: list[str] | dict[str, str], + fqns: list[str] | None = None, + name_header: str = "Name", + link_fn=None, + records: set[str] | None = None, + ) -> str: + """Render a summary ``.. list-table::`` with one row per record. + + Args: + columns: Field names to use as columns after the leading name + column. Labels are auto-derived from the field names. + fqns: Ordered list of fully-qualified record names (rows). When + ``None`` every record passing the source-file and *records* + filters is included in parse order. + name_header: Header label for the leading name column. + link_fn: Optional ``(fqn, name) -> str`` returning the RST text for + the name cell (e.g. a ``:ref:`` cross-reference). Defaults to + the bare record name. + records: Optional set of fully-qualified names to include. + + Returns: + RST string ending with a newline. + """ + normalized = self._normalize_fields(columns) + obj_map = self._objects_by_fqn(records) + if fqns is None: + objs = [(o.fully_qualified_name(), o) for o in self._iter_filtered_objects(records)] + else: + objs = [(f, obj_map[f]) for f in fqns if f in obj_map] + + lines = [".. list-table::", " :header-rows: 1", ""] + header = [name_header] + [label for _, label in normalized] + lines.append(f" * - {header[0]}") + for head in header[1:]: + lines.append(f" - {head}") + for fqn, obj in objs: + name = obj.name if hasattr(obj, "name") else fqn + cell = link_fn(fqn, name) if link_fn is not None else name + lines.append(f" * - {cell}") + for field_name, _ in normalized: + value = self._field_value(obj, field_name) + lines.append(f" - {'' if value is None else value}") + lines.append("") + return "\n".join(lines) + "\n" @staticmethod def _resolve_markup_references(raw_value: str, references: list) -> str: @@ -251,7 +460,7 @@ def _replace(match): return _MARKUP_REF_RE.sub(_replace, raw_value) @staticmethod - def _preprocess_description(description: str) -> str: + def _preprocess_description(description: str, indent: int = 3) -> str: """Preprocess a description for proper reStructuredText formatting. Supported RST formatting in descriptions: @@ -311,7 +520,7 @@ def _md_image_sub(m): # relative_indent = 20-16 = 4, resulting in 6 base + 4 = 10 total spaces original_indent = len(line) - len(line.lstrip()) relative_indent = original_indent - code_block_base_indent - processed_lines.append(" " + " " * relative_indent + stripped) + processed_lines.append(" " * (indent + 3) + " " * relative_indent + stripped) skip_normal = True elif in_directive: original_indent = len(line) - len(line.lstrip()) @@ -319,7 +528,7 @@ def _md_image_sub(m): # Directive option or body content — preserve relative indentation # so that e.g. ``:width: 50%`` stays indented under ``.. image::``. relative_indent = original_indent - directive_base_indent - processed_lines.append(" " + " " * relative_indent + stripped) + processed_lines.append(" " * indent + " " * relative_indent + stripped) prev_was_list_item = False skip_normal = True else: @@ -341,8 +550,8 @@ def _md_image_sub(m): if is_list_item and processed_lines and not prev_was_list_item: processed_lines.append("") - # Add RST directive content indentation (3 spaces) - processed_lines.append(" " + normalized) + # Add directive-body indentation + processed_lines.append(" " * indent + normalized) # Check if line opens an RST directive (e.g. ``.. image:: path``). # Directive options/content follow immediately without a blank line, @@ -404,16 +613,28 @@ def _build_parent_tree(sections, root: Node) -> Node: current = Node(section.name, parent=current) return current - def render(self, output_path: str, title: str = "Requirements") -> None: + def render( + self, + output_path: str, + title: str = "Requirements", + fields: list[str] | None = None, + records: set[str] | None = None, + ) -> None: """Parse TRLC files and render them to reStructuredText. + Idempotent: if :meth:`parse_trlc_files` was already called (e.g. to + warm the symbol cache before multiple render calls), parsing is skipped. + Args: output_path: Path of the RST file to write. - title: Section title. Pass an empty string to omit it. + title: Section title. Pass an empty string to omit it. + fields: Field names to render. Defaults to ``["description"]``. + records: Optional set of fully-qualified names to include + (additional filter on top of the source-file filter). """ self.parse_trlc_files() - self.convert_symbols_to_tree() - self.render_to_file(output_path, title=title) + self.convert_symbols_to_tree(records) + self.render_to_file(output_path, title=title, fields=fields) def argument_parser() -> argparse.ArgumentParser: @@ -451,10 +672,31 @@ def argument_parser() -> argparse.ArgumentParser: "Pass an empty string to omit the title entirely " "(useful when the output is included inside an already-titled section).", ) + parser.add_argument( + "-f", + "--fields", + nargs="+", + default=None, + help="Field names to render for each record (e.g. ``guideword status description``). " + "Labels are derived automatically. Defaults to description only.", + ) + parser.add_argument( + "-r", + "--records", + nargs="+", + default=None, + help="Fully-qualified record names to render (e.g. ``Pkg.Record``). " + "Acts as an additional filter on top of --source-files / --input-dir.", + ) return parser +def _parse_field_tokens(tokens: list[str] | None) -> list[str] | None: + """Return the field-name token list, or ``None`` if none were given.""" + return tokens + + def main() -> None: parser = argument_parser() args = parser.parse_args() @@ -468,7 +710,12 @@ def main() -> None: source_files=args.source_files, dep_files=args.dep_files, ) - renderer.render(args.output, title=args.title) + renderer.render( + args.output, + title=args.title, + fields=_parse_field_tokens(args.fields), + records=set(args.records) if args.records else None, + ) except TRLCParseError as e: print(f"Error: {e}", file=sys.stderr) sys.exit(1)