xref: /linux/tools/net/ynl/ynl-gen-rst.py (revision 3daee2e4b3568f0ed88b0598df96547fcf21cb9b)
1#!/usr/bin/env python3
2# SPDX-License-Identifier: GPL-2.0
3# -*- coding: utf-8; mode: python -*-
4
5"""
6    Script to auto generate the documentation for Netlink specifications.
7
8    :copyright:  Copyright (C) 2023  Breno Leitao <leitao@debian.org>
9    :license:    GPL Version 2, June 1991 see linux/COPYING for details.
10
11    This script performs extensive parsing to the Linux kernel's netlink YAML
12    spec files, in an effort to avoid needing to heavily mark up the original
13    YAML file.
14
15    This code is split in three big parts:
16        1) RST formatters: Use to convert a string to a RST output
17        2) Parser helpers: Functions to parse the YAML data structure
18        3) Main function and small helpers
19"""
20
21from typing import Any, Dict, List
22import os.path
23import sys
24import argparse
25import logging
26import yaml
27
28
29SPACE_PER_LEVEL = 4
30
31
32# RST Formatters
33# ==============
34def headroom(level: int) -> str:
35    """Return space to format"""
36    return " " * (level * SPACE_PER_LEVEL)
37
38
39def bold(text: str) -> str:
40    """Format bold text"""
41    return f"**{text}**"
42
43
44def inline(text: str) -> str:
45    """Format inline text"""
46    return f"``{text}``"
47
48
49def sanitize(text: str) -> str:
50    """Remove newlines and multiple spaces"""
51    # This is useful for some fields that are spread across multiple lines
52    return str(text).replace("\n", "").strip()
53
54
55def rst_fields(key: str, value: str, level: int = 0) -> str:
56    """Return a RST formatted field"""
57    return headroom(level) + f":{key}: {value}"
58
59
60def rst_definition(key: str, value: Any, level: int = 0) -> str:
61    """Format a single rst definition"""
62    return headroom(level) + key + "\n" + headroom(level + 1) + str(value)
63
64
65def rst_paragraph(paragraph: str, level: int = 0) -> str:
66    """Return a formatted paragraph"""
67    return headroom(level) + paragraph
68
69
70def rst_bullet(item: str, level: int = 0) -> str:
71    """Return a formatted a bullet"""
72    return headroom(level) + f"- {item}"
73
74
75def rst_subsection(title: str) -> str:
76    """Add a sub-section to the document"""
77    return f"{title}\n" + "-" * len(title)
78
79
80def rst_subsubsection(title: str) -> str:
81    """Add a sub-sub-section to the document"""
82    return f"{title}\n" + "~" * len(title)
83
84
85def rst_section(namespace: str, prefix: str, title: str) -> str:
86    """Add a section to the document"""
87    return f".. _{namespace}-{prefix}-{title}:\n\n{title}\n" + "=" * len(title)
88
89
90def rst_subtitle(title: str) -> str:
91    """Add a subtitle to the document"""
92    return "\n" + "-" * len(title) + f"\n{title}\n" + "-" * len(title) + "\n\n"
93
94
95def rst_title(title: str) -> str:
96    """Add a title to the document"""
97    return "=" * len(title) + f"\n{title}\n" + "=" * len(title) + "\n\n"
98
99
100def rst_list_inline(list_: List[str], level: int = 0) -> str:
101    """Format a list using inlines"""
102    return headroom(level) + "[" + ", ".join(inline(i) for i in list_) + "]"
103
104
105def rst_ref(namespace: str, prefix: str, name: str) -> str:
106    """Add a hyperlink to the document"""
107    mappings = {'enum': 'definition',
108                'fixed-header': 'definition',
109                'nested-attributes': 'attribute-set',
110                'struct': 'definition'}
111    if prefix in mappings:
112        prefix = mappings[prefix]
113    return f":ref:`{namespace}-{prefix}-{name}`"
114
115
116def rst_header() -> str:
117    """The headers for all the auto generated RST files"""
118    lines = []
119
120    lines.append(rst_paragraph(".. SPDX-License-Identifier: GPL-2.0"))
121    lines.append(rst_paragraph(".. NOTE: This document was auto-generated.\n\n"))
122
123    return "\n".join(lines)
124
125
126def rst_toctree(maxdepth: int = 2) -> str:
127    """Generate a toctree RST primitive"""
128    lines = []
129
130    lines.append(".. toctree::")
131    lines.append(f"   :maxdepth: {maxdepth}\n\n")
132
133    return "\n".join(lines)
134
135
136def rst_label(title: str) -> str:
137    """Return a formatted label"""
138    return f".. _{title}:\n\n"
139
140
141# Parsers
142# =======
143
144
145def parse_mcast_group(mcast_group: List[Dict[str, Any]]) -> str:
146    """Parse 'multicast' group list and return a formatted string"""
147    lines = []
148    for group in mcast_group:
149        lines.append(rst_bullet(group["name"]))
150
151    return "\n".join(lines)
152
153
154def parse_do(do_dict: Dict[str, Any], level: int = 0) -> str:
155    """Parse 'do' section and return a formatted string"""
156    lines = []
157    for key in do_dict.keys():
158        lines.append(rst_paragraph(bold(key), level + 1))
159        lines.append(parse_do_attributes(do_dict[key], level + 1) + "\n")
160
161    return "\n".join(lines)
162
163
164def parse_do_attributes(attrs: Dict[str, Any], level: int = 0) -> str:
165    """Parse 'attributes' section"""
166    if "attributes" not in attrs:
167        return ""
168    lines = [rst_fields("attributes", rst_list_inline(attrs["attributes"]), level + 1)]
169
170    return "\n".join(lines)
171
172
173def parse_operations(operations: List[Dict[str, Any]], namespace: str) -> str:
174    """Parse operations block"""
175    preprocessed = ["name", "doc", "title", "do", "dump"]
176    linkable = ["fixed-header", "attribute-set"]
177    lines = []
178
179    for operation in operations:
180        lines.append(rst_section(namespace, 'operation', operation["name"]))
181        lines.append(rst_paragraph(sanitize(operation["doc"])) + "\n")
182
183        for key in operation.keys():
184            if key in preprocessed:
185                # Skip the special fields
186                continue
187            value = operation[key]
188            if key in linkable:
189                value = rst_ref(namespace, key, value)
190            lines.append(rst_fields(key, value, 0))
191
192        if "do" in operation:
193            lines.append(rst_paragraph(":do:", 0))
194            lines.append(parse_do(operation["do"], 0))
195        if "dump" in operation:
196            lines.append(rst_paragraph(":dump:", 0))
197            lines.append(parse_do(operation["dump"], 0))
198
199        # New line after fields
200        lines.append("\n")
201
202    return "\n".join(lines)
203
204
205def parse_entries(entries: List[Dict[str, Any]], level: int) -> str:
206    """Parse a list of entries"""
207    ignored = ["pad"]
208    lines = []
209    for entry in entries:
210        if isinstance(entry, dict):
211            # entries could be a list or a dictionary
212            field_name = entry.get("name", "")
213            if field_name in ignored:
214                continue
215            type_ = entry.get("type")
216            if type_:
217                field_name += f" ({inline(type_)})"
218            lines.append(
219                rst_fields(field_name, sanitize(entry.get("doc", "")), level)
220            )
221        elif isinstance(entry, list):
222            lines.append(rst_list_inline(entry, level))
223        else:
224            lines.append(rst_bullet(inline(sanitize(entry)), level))
225
226    lines.append("\n")
227    return "\n".join(lines)
228
229
230def parse_definitions(defs: Dict[str, Any], namespace: str) -> str:
231    """Parse definitions section"""
232    preprocessed = ["name", "entries", "members"]
233    ignored = ["render-max"]  # This is not printed
234    lines = []
235
236    for definition in defs:
237        lines.append(rst_section(namespace, 'definition', definition["name"]))
238        for k in definition.keys():
239            if k in preprocessed + ignored:
240                continue
241            lines.append(rst_fields(k, sanitize(definition[k]), 0))
242
243        # Field list needs to finish with a new line
244        lines.append("\n")
245        if "entries" in definition:
246            lines.append(rst_paragraph(":entries:", 0))
247            lines.append(parse_entries(definition["entries"], 1))
248        if "members" in definition:
249            lines.append(rst_paragraph(":members:", 0))
250            lines.append(parse_entries(definition["members"], 1))
251
252    return "\n".join(lines)
253
254
255def parse_attr_sets(entries: List[Dict[str, Any]], namespace: str) -> str:
256    """Parse attribute from attribute-set"""
257    preprocessed = ["name", "type"]
258    linkable = ["enum", "nested-attributes", "struct", "sub-message"]
259    ignored = ["checks"]
260    lines = []
261
262    for entry in entries:
263        lines.append(rst_section(namespace, 'attribute-set', entry["name"]))
264        for attr in entry["attributes"]:
265            type_ = attr.get("type")
266            attr_line = attr["name"]
267            if type_:
268                # Add the attribute type in the same line
269                attr_line += f" ({inline(type_)})"
270
271            lines.append(rst_subsubsection(attr_line))
272
273            for k in attr.keys():
274                if k in preprocessed + ignored:
275                    continue
276                if k in linkable:
277                    value = rst_ref(namespace, k, attr[k])
278                else:
279                    value = sanitize(attr[k])
280                lines.append(rst_fields(k, value, 0))
281            lines.append("\n")
282
283    return "\n".join(lines)
284
285
286def parse_sub_messages(entries: List[Dict[str, Any]], namespace: str) -> str:
287    """Parse sub-message definitions"""
288    lines = []
289
290    for entry in entries:
291        lines.append(rst_section(namespace, 'sub-message', entry["name"]))
292        for fmt in entry["formats"]:
293            value = fmt["value"]
294
295            lines.append(rst_bullet(bold(value)))
296            for attr in ['fixed-header', 'attribute-set']:
297                if attr in fmt:
298                    lines.append(rst_fields(attr,
299                                            rst_ref(namespace, attr, fmt[attr]),
300                                            1))
301            lines.append("\n")
302
303    return "\n".join(lines)
304
305
306def parse_yaml(obj: Dict[str, Any]) -> str:
307    """Format the whole YAML into a RST string"""
308    lines = []
309
310    # Main header
311
312    lines.append(rst_header())
313
314    family = obj['name']
315
316    title = f"Family ``{family}`` netlink specification"
317    lines.append(rst_title(title))
318    lines.append(rst_paragraph(".. contents:: :depth: 3\n"))
319
320    if "doc" in obj:
321        lines.append(rst_subtitle("Summary"))
322        lines.append(rst_paragraph(obj["doc"], 0))
323
324    # Operations
325    if "operations" in obj:
326        lines.append(rst_subtitle("Operations"))
327        lines.append(parse_operations(obj["operations"]["list"], family))
328
329    # Multicast groups
330    if "mcast-groups" in obj:
331        lines.append(rst_subtitle("Multicast groups"))
332        lines.append(parse_mcast_group(obj["mcast-groups"]["list"]))
333
334    # Definitions
335    if "definitions" in obj:
336        lines.append(rst_subtitle("Definitions"))
337        lines.append(parse_definitions(obj["definitions"], family))
338
339    # Attributes set
340    if "attribute-sets" in obj:
341        lines.append(rst_subtitle("Attribute sets"))
342        lines.append(parse_attr_sets(obj["attribute-sets"], family))
343
344    # Sub-messages
345    if "sub-messages" in obj:
346        lines.append(rst_subtitle("Sub-messages"))
347        lines.append(parse_sub_messages(obj["sub-messages"], family))
348
349    return "\n".join(lines)
350
351
352# Main functions
353# ==============
354
355
356def parse_arguments() -> argparse.Namespace:
357    """Parse arguments from user"""
358    parser = argparse.ArgumentParser(description="Netlink RST generator")
359
360    parser.add_argument("-v", "--verbose", action="store_true")
361    parser.add_argument("-o", "--output", help="Output file name")
362
363    # Index and input are mutually exclusive
364    group = parser.add_mutually_exclusive_group()
365    group.add_argument(
366        "-x", "--index", action="store_true", help="Generate the index page"
367    )
368    group.add_argument("-i", "--input", help="YAML file name")
369
370    args = parser.parse_args()
371
372    if args.verbose:
373        logging.basicConfig(level=logging.DEBUG)
374
375    if args.input and not os.path.isfile(args.input):
376        logging.warning("%s is not a valid file.", args.input)
377        sys.exit(-1)
378
379    if not args.output:
380        logging.error("No output file specified.")
381        sys.exit(-1)
382
383    if os.path.isfile(args.output):
384        logging.debug("%s already exists. Overwriting it.", args.output)
385
386    return args
387
388
389def parse_yaml_file(filename: str) -> str:
390    """Transform the YAML specified by filename into a rst-formmated string"""
391    with open(filename, "r", encoding="utf-8") as spec_file:
392        yaml_data = yaml.safe_load(spec_file)
393        content = parse_yaml(yaml_data)
394
395    return content
396
397
398def write_to_rstfile(content: str, filename: str) -> None:
399    """Write the generated content into an RST file"""
400    logging.debug("Saving RST file to %s", filename)
401
402    with open(filename, "w", encoding="utf-8") as rst_file:
403        rst_file.write(content)
404
405
406def generate_main_index_rst(output: str) -> None:
407    """Generate the `networking_spec/index` content and write to the file"""
408    lines = []
409
410    lines.append(rst_header())
411    lines.append(rst_label("specs"))
412    lines.append(rst_title("Netlink Family Specifications"))
413    lines.append(rst_toctree(1))
414
415    index_dir = os.path.dirname(output)
416    logging.debug("Looking for .rst files in %s", index_dir)
417    for filename in sorted(os.listdir(index_dir)):
418        if not filename.endswith(".rst") or filename == "index.rst":
419            continue
420        lines.append(f"   {filename.replace('.rst', '')}\n")
421
422    logging.debug("Writing an index file at %s", output)
423    write_to_rstfile("".join(lines), output)
424
425
426def main() -> None:
427    """Main function that reads the YAML files and generates the RST files"""
428
429    args = parse_arguments()
430
431    if args.input:
432        logging.debug("Parsing %s", args.input)
433        try:
434            content = parse_yaml_file(os.path.join(args.input))
435        except Exception as exception:
436            logging.warning("Failed to parse %s.", args.input)
437            logging.warning(exception)
438            sys.exit(-1)
439
440        write_to_rstfile(content, args.output)
441
442    if args.index:
443        # Generate the index RST file
444        generate_main_index_rst(args.output)
445
446
447if __name__ == "__main__":
448    main()
449