| #!/usr/bin/env python3 |
| # SPDX-License-Identifier: GPL-2.0 |
| # -*- coding: utf-8; mode: python -*- |
| |
| """ |
| Script to auto generate the documentation for Netlink specifications. |
| |
| :copyright: Copyright (C) 2023 Breno Leitao <leitao@debian.org> |
| :license: GPL Version 2, June 1991 see linux/COPYING for details. |
| |
| This script performs extensive parsing to the Linux kernel's netlink YAML |
| spec files, in an effort to avoid needing to heavily mark up the original |
| YAML file. |
| |
| This code is split in three big parts: |
| 1) RST formatters: Use to convert a string to a RST output |
| 2) Parser helpers: Functions to parse the YAML data structure |
| 3) Main function and small helpers |
| """ |
| |
| from typing import Any, Dict, List |
| import os.path |
| import sys |
| import argparse |
| import logging |
| import yaml |
| |
| |
| SPACE_PER_LEVEL = 4 |
| |
| |
| # RST Formatters |
| # ============== |
| def headroom(level: int) -> str: |
| """Return space to format""" |
| return " " * (level * SPACE_PER_LEVEL) |
| |
| |
| def bold(text: str) -> str: |
| """Format bold text""" |
| return f"**{text}**" |
| |
| |
| def inline(text: str) -> str: |
| """Format inline text""" |
| return f"``{text}``" |
| |
| |
| def sanitize(text: str) -> str: |
| """Remove newlines and multiple spaces""" |
| # This is useful for some fields that are spread across multiple lines |
| return str(text).replace("\n", "").strip() |
| |
| |
| def rst_fields(key: str, value: str, level: int = 0) -> str: |
| """Return a RST formatted field""" |
| return headroom(level) + f":{key}: {value}" |
| |
| |
| def rst_definition(key: str, value: Any, level: int = 0) -> str: |
| """Format a single rst definition""" |
| return headroom(level) + key + "\n" + headroom(level + 1) + str(value) |
| |
| |
| def rst_paragraph(paragraph: str, level: int = 0) -> str: |
| """Return a formatted paragraph""" |
| return headroom(level) + paragraph |
| |
| |
| def rst_bullet(item: str, level: int = 0) -> str: |
| """Return a formatted a bullet""" |
| return headroom(level) + f"- {item}" |
| |
| |
| def rst_subsection(title: str) -> str: |
| """Add a sub-section to the document""" |
| return f"{title}\n" + "-" * len(title) |
| |
| |
| def rst_subsubsection(title: str) -> str: |
| """Add a sub-sub-section to the document""" |
| return f"{title}\n" + "~" * len(title) |
| |
| |
| def rst_section(namespace: str, prefix: str, title: str) -> str: |
| """Add a section to the document""" |
| return f".. _{namespace}-{prefix}-{title}:\n\n{title}\n" + "=" * len(title) |
| |
| |
| def rst_subtitle(title: str) -> str: |
| """Add a subtitle to the document""" |
| return "\n" + "-" * len(title) + f"\n{title}\n" + "-" * len(title) + "\n\n" |
| |
| |
| def rst_title(title: str) -> str: |
| """Add a title to the document""" |
| return "=" * len(title) + f"\n{title}\n" + "=" * len(title) + "\n\n" |
| |
| |
| def rst_list_inline(list_: List[str], level: int = 0) -> str: |
| """Format a list using inlines""" |
| return headroom(level) + "[" + ", ".join(inline(i) for i in list_) + "]" |
| |
| |
| def rst_ref(namespace: str, prefix: str, name: str) -> str: |
| """Add a hyperlink to the document""" |
| mappings = {'enum': 'definition', |
| 'fixed-header': 'definition', |
| 'nested-attributes': 'attribute-set', |
| 'struct': 'definition'} |
| if prefix in mappings: |
| prefix = mappings[prefix] |
| return f":ref:`{namespace}-{prefix}-{name}`" |
| |
| |
| def rst_header() -> str: |
| """The headers for all the auto generated RST files""" |
| lines = [] |
| |
| lines.append(rst_paragraph(".. SPDX-License-Identifier: GPL-2.0")) |
| lines.append(rst_paragraph(".. NOTE: This document was auto-generated.\n\n")) |
| |
| return "\n".join(lines) |
| |
| |
| def rst_toctree(maxdepth: int = 2) -> str: |
| """Generate a toctree RST primitive""" |
| lines = [] |
| |
| lines.append(".. toctree::") |
| lines.append(f" :maxdepth: {maxdepth}\n\n") |
| |
| return "\n".join(lines) |
| |
| |
| def rst_label(title: str) -> str: |
| """Return a formatted label""" |
| return f".. _{title}:\n\n" |
| |
| |
| # Parsers |
| # ======= |
| |
| |
| def parse_mcast_group(mcast_group: List[Dict[str, Any]]) -> str: |
| """Parse 'multicast' group list and return a formatted string""" |
| lines = [] |
| for group in mcast_group: |
| lines.append(rst_bullet(group["name"])) |
| |
| return "\n".join(lines) |
| |
| |
| def parse_do(do_dict: Dict[str, Any], level: int = 0) -> str: |
| """Parse 'do' section and return a formatted string""" |
| lines = [] |
| for key in do_dict.keys(): |
| lines.append(rst_paragraph(bold(key), level + 1)) |
| lines.append(parse_do_attributes(do_dict[key], level + 1) + "\n") |
| |
| return "\n".join(lines) |
| |
| |
| def parse_do_attributes(attrs: Dict[str, Any], level: int = 0) -> str: |
| """Parse 'attributes' section""" |
| if "attributes" not in attrs: |
| return "" |
| lines = [rst_fields("attributes", rst_list_inline(attrs["attributes"]), level + 1)] |
| |
| return "\n".join(lines) |
| |
| |
| def parse_operations(operations: List[Dict[str, Any]], namespace: str) -> str: |
| """Parse operations block""" |
| preprocessed = ["name", "doc", "title", "do", "dump"] |
| linkable = ["fixed-header", "attribute-set"] |
| lines = [] |
| |
| for operation in operations: |
| lines.append(rst_section(namespace, 'operation', operation["name"])) |
| lines.append(rst_paragraph(sanitize(operation["doc"])) + "\n") |
| |
| for key in operation.keys(): |
| if key in preprocessed: |
| # Skip the special fields |
| continue |
| value = operation[key] |
| if key in linkable: |
| value = rst_ref(namespace, key, value) |
| lines.append(rst_fields(key, value, 0)) |
| |
| if "do" in operation: |
| lines.append(rst_paragraph(":do:", 0)) |
| lines.append(parse_do(operation["do"], 0)) |
| if "dump" in operation: |
| lines.append(rst_paragraph(":dump:", 0)) |
| lines.append(parse_do(operation["dump"], 0)) |
| |
| # New line after fields |
| lines.append("\n") |
| |
| return "\n".join(lines) |
| |
| |
| def parse_entries(entries: List[Dict[str, Any]], level: int) -> str: |
| """Parse a list of entries""" |
| ignored = ["pad"] |
| lines = [] |
| for entry in entries: |
| if isinstance(entry, dict): |
| # entries could be a list or a dictionary |
| field_name = entry.get("name", "") |
| if field_name in ignored: |
| continue |
| type_ = entry.get("type") |
| if type_: |
| field_name += f" ({inline(type_)})" |
| lines.append( |
| rst_fields(field_name, sanitize(entry.get("doc", "")), level) |
| ) |
| elif isinstance(entry, list): |
| lines.append(rst_list_inline(entry, level)) |
| else: |
| lines.append(rst_bullet(inline(sanitize(entry)), level)) |
| |
| lines.append("\n") |
| return "\n".join(lines) |
| |
| |
| def parse_definitions(defs: Dict[str, Any], namespace: str) -> str: |
| """Parse definitions section""" |
| preprocessed = ["name", "entries", "members"] |
| ignored = ["render-max"] # This is not printed |
| lines = [] |
| |
| for definition in defs: |
| lines.append(rst_section(namespace, 'definition', definition["name"])) |
| for k in definition.keys(): |
| if k in preprocessed + ignored: |
| continue |
| lines.append(rst_fields(k, sanitize(definition[k]), 0)) |
| |
| # Field list needs to finish with a new line |
| lines.append("\n") |
| if "entries" in definition: |
| lines.append(rst_paragraph(":entries:", 0)) |
| lines.append(parse_entries(definition["entries"], 1)) |
| if "members" in definition: |
| lines.append(rst_paragraph(":members:", 0)) |
| lines.append(parse_entries(definition["members"], 1)) |
| |
| return "\n".join(lines) |
| |
| |
| def parse_attr_sets(entries: List[Dict[str, Any]], namespace: str) -> str: |
| """Parse attribute from attribute-set""" |
| preprocessed = ["name", "type"] |
| linkable = ["enum", "nested-attributes", "struct", "sub-message"] |
| ignored = ["checks"] |
| lines = [] |
| |
| for entry in entries: |
| lines.append(rst_section(namespace, 'attribute-set', entry["name"])) |
| for attr in entry["attributes"]: |
| type_ = attr.get("type") |
| attr_line = attr["name"] |
| if type_: |
| # Add the attribute type in the same line |
| attr_line += f" ({inline(type_)})" |
| |
| lines.append(rst_subsubsection(attr_line)) |
| |
| for k in attr.keys(): |
| if k in preprocessed + ignored: |
| continue |
| if k in linkable: |
| value = rst_ref(namespace, k, attr[k]) |
| else: |
| value = sanitize(attr[k]) |
| lines.append(rst_fields(k, value, 0)) |
| lines.append("\n") |
| |
| return "\n".join(lines) |
| |
| |
| def parse_sub_messages(entries: List[Dict[str, Any]], namespace: str) -> str: |
| """Parse sub-message definitions""" |
| lines = [] |
| |
| for entry in entries: |
| lines.append(rst_section(namespace, 'sub-message', entry["name"])) |
| for fmt in entry["formats"]: |
| value = fmt["value"] |
| |
| lines.append(rst_bullet(bold(value))) |
| for attr in ['fixed-header', 'attribute-set']: |
| if attr in fmt: |
| lines.append(rst_fields(attr, |
| rst_ref(namespace, attr, fmt[attr]), |
| 1)) |
| lines.append("\n") |
| |
| return "\n".join(lines) |
| |
| |
| def parse_yaml(obj: Dict[str, Any]) -> str: |
| """Format the whole YAML into a RST string""" |
| lines = [] |
| |
| # Main header |
| |
| lines.append(rst_header()) |
| |
| family = obj['name'] |
| |
| title = f"Family ``{family}`` netlink specification" |
| lines.append(rst_title(title)) |
| lines.append(rst_paragraph(".. contents:: :depth: 3\n")) |
| |
| if "doc" in obj: |
| lines.append(rst_subtitle("Summary")) |
| lines.append(rst_paragraph(obj["doc"], 0)) |
| |
| # Operations |
| if "operations" in obj: |
| lines.append(rst_subtitle("Operations")) |
| lines.append(parse_operations(obj["operations"]["list"], family)) |
| |
| # Multicast groups |
| if "mcast-groups" in obj: |
| lines.append(rst_subtitle("Multicast groups")) |
| lines.append(parse_mcast_group(obj["mcast-groups"]["list"])) |
| |
| # Definitions |
| if "definitions" in obj: |
| lines.append(rst_subtitle("Definitions")) |
| lines.append(parse_definitions(obj["definitions"], family)) |
| |
| # Attributes set |
| if "attribute-sets" in obj: |
| lines.append(rst_subtitle("Attribute sets")) |
| lines.append(parse_attr_sets(obj["attribute-sets"], family)) |
| |
| # Sub-messages |
| if "sub-messages" in obj: |
| lines.append(rst_subtitle("Sub-messages")) |
| lines.append(parse_sub_messages(obj["sub-messages"], family)) |
| |
| return "\n".join(lines) |
| |
| |
| # Main functions |
| # ============== |
| |
| |
| def parse_arguments() -> argparse.Namespace: |
| """Parse arguments from user""" |
| parser = argparse.ArgumentParser(description="Netlink RST generator") |
| |
| parser.add_argument("-v", "--verbose", action="store_true") |
| parser.add_argument("-o", "--output", help="Output file name") |
| |
| # Index and input are mutually exclusive |
| group = parser.add_mutually_exclusive_group() |
| group.add_argument( |
| "-x", "--index", action="store_true", help="Generate the index page" |
| ) |
| group.add_argument("-i", "--input", help="YAML file name") |
| |
| args = parser.parse_args() |
| |
| if args.verbose: |
| logging.basicConfig(level=logging.DEBUG) |
| |
| if args.input and not os.path.isfile(args.input): |
| logging.warning("%s is not a valid file.", args.input) |
| sys.exit(-1) |
| |
| if not args.output: |
| logging.error("No output file specified.") |
| sys.exit(-1) |
| |
| if os.path.isfile(args.output): |
| logging.debug("%s already exists. Overwriting it.", args.output) |
| |
| return args |
| |
| |
| def parse_yaml_file(filename: str) -> str: |
| """Transform the YAML specified by filename into a rst-formmated string""" |
| with open(filename, "r", encoding="utf-8") as spec_file: |
| yaml_data = yaml.safe_load(spec_file) |
| content = parse_yaml(yaml_data) |
| |
| return content |
| |
| |
| def write_to_rstfile(content: str, filename: str) -> None: |
| """Write the generated content into an RST file""" |
| logging.debug("Saving RST file to %s", filename) |
| |
| with open(filename, "w", encoding="utf-8") as rst_file: |
| rst_file.write(content) |
| |
| |
| def generate_main_index_rst(output: str) -> None: |
| """Generate the `networking_spec/index` content and write to the file""" |
| lines = [] |
| |
| lines.append(rst_header()) |
| lines.append(rst_label("specs")) |
| lines.append(rst_title("Netlink Family Specifications")) |
| lines.append(rst_toctree(1)) |
| |
| index_dir = os.path.dirname(output) |
| logging.debug("Looking for .rst files in %s", index_dir) |
| for filename in sorted(os.listdir(index_dir)): |
| if not filename.endswith(".rst") or filename == "index.rst": |
| continue |
| lines.append(f" {filename.replace('.rst', '')}\n") |
| |
| logging.debug("Writing an index file at %s", output) |
| write_to_rstfile("".join(lines), output) |
| |
| |
| def main() -> None: |
| """Main function that reads the YAML files and generates the RST files""" |
| |
| args = parse_arguments() |
| |
| if args.input: |
| logging.debug("Parsing %s", args.input) |
| try: |
| content = parse_yaml_file(os.path.join(args.input)) |
| except Exception as exception: |
| logging.warning("Failed to parse %s.", args.input) |
| logging.warning(exception) |
| sys.exit(-1) |
| |
| write_to_rstfile(content, args.output) |
| |
| if args.index: |
| # Generate the index RST file |
| generate_main_index_rst(args.output) |
| |
| |
| if __name__ == "__main__": |
| main() |