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