xref: /linux/tools/net/ynl/pyynl/lib/doc_generator.py (revision f25f39e6d26644aeeef72433f67fc6d9e638c800)
1#!/usr/bin/env python3
2# SPDX-License-Identifier: GPL-2.0
3# -*- coding: utf-8; mode: python -*-
4
5"""
6    Class 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 class 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 two classes:
16        1) RST formatters: Use to convert a string to a RST output
17        2) YAML Netlink (YNL) doc generator: Generate docs from YAML data
18"""
19
20from typing import Any, Dict, List
21import os.path
22import sys
23import argparse
24import logging
25import yaml
26
27
28# ==============
29# RST Formatters
30# ==============
31class RstFormatters:
32    SPACE_PER_LEVEL = 4
33
34    @staticmethod
35    def headroom(level: int) -> str:
36        """Return space to format"""
37        return " " * (level * RstFormatters.SPACE_PER_LEVEL)
38
39
40    @staticmethod
41    def bold(text: str) -> str:
42        """Format bold text"""
43        return f"**{text}**"
44
45
46    @staticmethod
47    def inline(text: str) -> str:
48        """Format inline text"""
49        return f"``{text}``"
50
51
52    @staticmethod
53    def sanitize(text: str) -> str:
54        """Remove newlines and multiple spaces"""
55        # This is useful for some fields that are spread across multiple lines
56        return str(text).replace("\n", " ").strip()
57
58
59    def rst_fields(self, key: str, value: str, level: int = 0) -> str:
60        """Return a RST formatted field"""
61        return self.headroom(level) + f":{key}: {value}"
62
63
64    def rst_definition(self, key: str, value: Any, level: int = 0) -> str:
65        """Format a single rst definition"""
66        return self.headroom(level) + key + "\n" + self.headroom(level + 1) + str(value)
67
68
69    def rst_paragraph(self, paragraph: str, level: int = 0) -> str:
70        """Return a formatted paragraph"""
71        return self.headroom(level) + paragraph
72
73
74    def rst_bullet(self, item: str, level: int = 0) -> str:
75        """Return a formatted a bullet"""
76        return self.headroom(level) + f"- {item}"
77
78
79    @staticmethod
80    def rst_subsection(title: str) -> str:
81        """Add a sub-section to the document"""
82        return f"{title}\n" + "-" * len(title)
83
84
85    @staticmethod
86    def rst_subsubsection(title: str) -> str:
87        """Add a sub-sub-section to the document"""
88        return f"{title}\n" + "~" * len(title)
89
90
91    @staticmethod
92    def rst_section(namespace: str, prefix: str, title: str) -> str:
93        """Add a section to the document"""
94        return f".. _{namespace}-{prefix}-{title}:\n\n{title}\n" + "=" * len(title)
95
96
97    @staticmethod
98    def rst_subtitle(title: str) -> str:
99        """Add a subtitle to the document"""
100        return "\n" + "-" * len(title) + f"\n{title}\n" + "-" * len(title) + "\n\n"
101
102
103    @staticmethod
104    def rst_title(title: str) -> str:
105        """Add a title to the document"""
106        return "=" * len(title) + f"\n{title}\n" + "=" * len(title) + "\n\n"
107
108
109    def rst_list_inline(self, list_: List[str], level: int = 0) -> str:
110        """Format a list using inlines"""
111        return self.headroom(level) + "[" + ", ".join(self.inline(i) for i in list_) + "]"
112
113
114    @staticmethod
115    def rst_ref(namespace: str, prefix: str, name: str) -> str:
116        """Add a hyperlink to the document"""
117        mappings = {'enum': 'definition',
118                    'fixed-header': 'definition',
119                    'nested-attributes': 'attribute-set',
120                    'struct': 'definition'}
121        if prefix in mappings:
122            prefix = mappings[prefix]
123        return f":ref:`{namespace}-{prefix}-{name}`"
124
125
126    def rst_header(self) -> str:
127        """The headers for all the auto generated RST files"""
128        lines = []
129
130        lines.append(self.rst_paragraph(".. SPDX-License-Identifier: GPL-2.0"))
131        lines.append(self.rst_paragraph(".. NOTE: This document was auto-generated.\n\n"))
132
133        return "\n".join(lines)
134
135
136    @staticmethod
137    def rst_toctree(maxdepth: int = 2) -> str:
138        """Generate a toctree RST primitive"""
139        lines = []
140
141        lines.append(".. toctree::")
142        lines.append(f"   :maxdepth: {maxdepth}\n\n")
143
144        return "\n".join(lines)
145
146
147    @staticmethod
148    def rst_label(title: str) -> str:
149        """Return a formatted label"""
150        return f".. _{title}:\n\n"
151
152# =======
153# Parsers
154# =======
155class YnlDocGenerator:
156
157    fmt = RstFormatters()
158
159    def parse_mcast_group(self, mcast_group: List[Dict[str, Any]]) -> str:
160        """Parse 'multicast' group list and return a formatted string"""
161        lines = []
162        for group in mcast_group:
163            lines.append(self.fmt.rst_bullet(group["name"]))
164
165        return "\n".join(lines)
166
167
168    def parse_do(self, do_dict: Dict[str, Any], level: int = 0) -> str:
169        """Parse 'do' section and return a formatted string"""
170        lines = []
171        for key in do_dict.keys():
172            lines.append(self.fmt.rst_paragraph(self.fmt.bold(key), level + 1))
173            if key in ['request', 'reply']:
174                lines.append(self.parse_do_attributes(do_dict[key], level + 1) + "\n")
175            else:
176                lines.append(self.fmt.headroom(level + 2) + do_dict[key] + "\n")
177
178        return "\n".join(lines)
179
180
181    def parse_do_attributes(self, attrs: Dict[str, Any], level: int = 0) -> str:
182        """Parse 'attributes' section"""
183        if "attributes" not in attrs:
184            return ""
185        lines = [self.fmt.rst_fields("attributes", self.fmt.rst_list_inline(attrs["attributes"]), level + 1)]
186
187        return "\n".join(lines)
188
189
190    def parse_operations(self, operations: List[Dict[str, Any]], namespace: str) -> str:
191        """Parse operations block"""
192        preprocessed = ["name", "doc", "title", "do", "dump", "flags"]
193        linkable = ["fixed-header", "attribute-set"]
194        lines = []
195
196        for operation in operations:
197            lines.append(self.fmt.rst_section(namespace, 'operation', operation["name"]))
198            lines.append(self.fmt.rst_paragraph(operation["doc"]) + "\n")
199
200            for key in operation.keys():
201                if key in preprocessed:
202                    # Skip the special fields
203                    continue
204                value = operation[key]
205                if key in linkable:
206                    value = self.fmt.rst_ref(namespace, key, value)
207                lines.append(self.fmt.rst_fields(key, value, 0))
208            if 'flags' in operation:
209                lines.append(self.fmt.rst_fields('flags', self.fmt.rst_list_inline(operation['flags'])))
210
211            if "do" in operation:
212                lines.append(self.fmt.rst_paragraph(":do:", 0))
213                lines.append(self.parse_do(operation["do"], 0))
214            if "dump" in operation:
215                lines.append(self.fmt.rst_paragraph(":dump:", 0))
216                lines.append(self.parse_do(operation["dump"], 0))
217
218            # New line after fields
219            lines.append("\n")
220
221        return "\n".join(lines)
222
223
224    def parse_entries(self, entries: List[Dict[str, Any]], level: int) -> str:
225        """Parse a list of entries"""
226        ignored = ["pad"]
227        lines = []
228        for entry in entries:
229            if isinstance(entry, dict):
230                # entries could be a list or a dictionary
231                field_name = entry.get("name", "")
232                if field_name in ignored:
233                    continue
234                type_ = entry.get("type")
235                if type_:
236                    field_name += f" ({self.fmt.inline(type_)})"
237                lines.append(
238                    self.fmt.rst_fields(field_name, self.fmt.sanitize(entry.get("doc", "")), level)
239                )
240            elif isinstance(entry, list):
241                lines.append(self.fmt.rst_list_inline(entry, level))
242            else:
243                lines.append(self.fmt.rst_bullet(self.fmt.inline(self.fmt.sanitize(entry)), level))
244
245        lines.append("\n")
246        return "\n".join(lines)
247
248
249    def parse_definitions(self, defs: Dict[str, Any], namespace: str) -> str:
250        """Parse definitions section"""
251        preprocessed = ["name", "entries", "members"]
252        ignored = ["render-max"]  # This is not printed
253        lines = []
254
255        for definition in defs:
256            lines.append(self.fmt.rst_section(namespace, 'definition', definition["name"]))
257            for k in definition.keys():
258                if k in preprocessed + ignored:
259                    continue
260                lines.append(self.fmt.rst_fields(k, self.fmt.sanitize(definition[k]), 0))
261
262            # Field list needs to finish with a new line
263            lines.append("\n")
264            if "entries" in definition:
265                lines.append(self.fmt.rst_paragraph(":entries:", 0))
266                lines.append(self.parse_entries(definition["entries"], 1))
267            if "members" in definition:
268                lines.append(self.fmt.rst_paragraph(":members:", 0))
269                lines.append(self.parse_entries(definition["members"], 1))
270
271        return "\n".join(lines)
272
273
274    def parse_attr_sets(self, entries: List[Dict[str, Any]], namespace: str) -> str:
275        """Parse attribute from attribute-set"""
276        preprocessed = ["name", "type"]
277        linkable = ["enum", "nested-attributes", "struct", "sub-message"]
278        ignored = ["checks"]
279        lines = []
280
281        for entry in entries:
282            lines.append(self.fmt.rst_section(namespace, 'attribute-set', entry["name"]))
283            for attr in entry["attributes"]:
284                type_ = attr.get("type")
285                attr_line = attr["name"]
286                if type_:
287                    # Add the attribute type in the same line
288                    attr_line += f" ({self.fmt.inline(type_)})"
289
290                lines.append(self.fmt.rst_subsubsection(attr_line))
291
292                for k in attr.keys():
293                    if k in preprocessed + ignored:
294                        continue
295                    if k in linkable:
296                        value = self.fmt.rst_ref(namespace, k, attr[k])
297                    else:
298                        value = self.fmt.sanitize(attr[k])
299                    lines.append(self.fmt.rst_fields(k, value, 0))
300                lines.append("\n")
301
302        return "\n".join(lines)
303
304
305    def parse_sub_messages(self, entries: List[Dict[str, Any]], namespace: str) -> str:
306        """Parse sub-message definitions"""
307        lines = []
308
309        for entry in entries:
310            lines.append(self.fmt.rst_section(namespace, 'sub-message', entry["name"]))
311            for fmt in entry["formats"]:
312                value = fmt["value"]
313
314                lines.append(self.fmt.rst_bullet(self.fmt.bold(value)))
315                for attr in ['fixed-header', 'attribute-set']:
316                    if attr in fmt:
317                        lines.append(self.fmt.rst_fields(attr,
318                                                self.fmt.rst_ref(namespace, attr, fmt[attr]),
319                                                1))
320                lines.append("\n")
321
322        return "\n".join(lines)
323
324
325    def parse_yaml(self, obj: Dict[str, Any]) -> str:
326        """Format the whole YAML into a RST string"""
327        lines = []
328
329        # Main header
330
331        family = obj['name']
332
333        lines.append(self.fmt.rst_header())
334        lines.append(self.fmt.rst_label("netlink-" + family))
335
336        title = f"Family ``{family}`` netlink specification"
337        lines.append(self.fmt.rst_title(title))
338        lines.append(self.fmt.rst_paragraph(".. contents:: :depth: 3\n"))
339
340        if "doc" in obj:
341            lines.append(self.fmt.rst_subtitle("Summary"))
342            lines.append(self.fmt.rst_paragraph(obj["doc"], 0))
343
344        # Operations
345        if "operations" in obj:
346            lines.append(self.fmt.rst_subtitle("Operations"))
347            lines.append(self.parse_operations(obj["operations"]["list"], family))
348
349        # Multicast groups
350        if "mcast-groups" in obj:
351            lines.append(self.fmt.rst_subtitle("Multicast groups"))
352            lines.append(self.parse_mcast_group(obj["mcast-groups"]["list"]))
353
354        # Definitions
355        if "definitions" in obj:
356            lines.append(self.fmt.rst_subtitle("Definitions"))
357            lines.append(self.parse_definitions(obj["definitions"], family))
358
359        # Attributes set
360        if "attribute-sets" in obj:
361            lines.append(self.fmt.rst_subtitle("Attribute sets"))
362            lines.append(self.parse_attr_sets(obj["attribute-sets"], family))
363
364        # Sub-messages
365        if "sub-messages" in obj:
366            lines.append(self.fmt.rst_subtitle("Sub-messages"))
367            lines.append(self.parse_sub_messages(obj["sub-messages"], family))
368
369        return "\n".join(lines)
370
371
372    # Main functions
373    # ==============
374
375
376    def parse_yaml_file(self, filename: str) -> str:
377        """Transform the YAML specified by filename into an RST-formatted string"""
378        with open(filename, "r", encoding="utf-8") as spec_file:
379            yaml_data = yaml.safe_load(spec_file)
380            content = self.parse_yaml(yaml_data)
381
382        return content
383