Merge branch 'bug-rest-lists-find-wo-role' into 'master'
[mailman.git] / _ext / configplugin.py
blob1979ce855e89b1440a341515daba1b439121e9c8
1 # Copyright (C) 2020-2023 by the Free Software Foundation, Inc.
3 # This file is part of GNU Mailman.
5 # GNU Mailman is free software: you can redistribute it and/or modify it under
6 # the terms of the GNU General Public License as published by the Free
7 # Software Foundation, either version 3 of the License, or (at your option)
8 # any later version.
10 # GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
11 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12 # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
13 # more details.
15 # You should have received a copy of the GNU General Public License along with
16 # GNU Mailman. If not, see <https://www.gnu.org/licenses/>.
18 """Sphinx plugin to render Mailman Core configuration file schema.cfg."""
20 import re
21 import configparser
23 from docutils import nodes
24 from docutils.parsers.rst import Directive, directives
25 from docutils.statemachine import ViewList
26 from importlib.resources import files
27 from sphinx.util.nodes import nested_parse_with_titles
30 def get_config_text():
31 """Get Mailman's schema.cfg as str"""
32 return files('mailman.config').joinpath('schema.cfg').read_text()
35 def get_section_text(section, schema_text):
36 """Get the text of a give ini section.
38 This includes the region between two `[section]` headers in the ini file.
40 :param section: The name of the section.
41 :param schema_text: The whole config file contents.
42 :returns: The str of all the value in the section.
43 :raises ValueError: If the name of the section can't be found.
44 ."""
45 # Split the whole file at the boundary of [sections].
46 sections = re.split(r'^\[(?P<header>[^]]+)\]',
47 schema_text, flags=re.MULTILINE)
48 if not f'{section}' in sections:
49 raise ValueError('Invalid section name {}'.format(section))
50 section_index = sections.index(section)
51 section_text = sections[section_index + 1]
53 return section_text
55 def is_comment(para):
56 """Check if a paragraph is comment without any options.
58 :param para: The paragraph text.
59 :returns: True if all lines start with '#', False otherwise.
60 """
61 para = para.strip()
62 for line in para.splitlines():
63 if not line.startswith('#'):
64 return False
65 return True
68 def get_options(section_text):
69 """Parse the key:value pairs from it along with comments.
71 Given the text of a section, split the whole text with empty lines
72 ('\n\n'). For each part get the (key: value) pairs by letting configparser
73 parse the text. The remaining lines of text, which ends up being the
74 comments in the file serve as the documentation for those key: value pairs.
76 Note: We append a `[dummy]` section name to the section_text since
77 configparser will refuse to parse a section text that doesn't include a
78 `[section]` header. There is no real significance of that since we
79 immidiately discard the section name.
81 If the section starts off with a block of just comment, it is called
82 "section_doc".
84 The return format looks something like:
86 ([{'key': 'value', 'key2': 'value2', 'doc': 'Comments'}], "Section Doc")
88 The first item is a list of dictionaries, each of which represents a
89 paragraph in the ini text. All (key: value) pairs are in the dictionary
90 and the comments as a part of the 'doc'. When there are no comments, 'doc'
91 option is omitted.
93 :param section_text: The whole text of the section, not include the header
94 `[section]` itself.
95 :returns: Parsed options, docs and section docs.
96 """
97 options = []
98 opts_list = section_text.split('\n\n')
99 section_doc = None
101 # We check for a section leve doc by looking if the
102 # first *two* paragraphs are both comments and *only* comments.
103 if is_comment(opts_list[0]) and is_comment(opts_list[1]):
104 section_doc = opts_list.pop(0)
106 for each in opts_list:
107 if each.strip() == '':
108 continue
109 # Configparser will refuse to parse section without it's name.
110 each = '[dummy]\n' + each
111 config = configparser.ConfigParser()
112 config.read_string(each)
113 data = {}
114 for key in config['dummy']:
115 data[key] = config['dummy'][key]
117 doc = '\n'.join(line for line in each.splitlines() if line.startswith('#'))
118 data['doc'] = doc.replace('#', '')
119 options.append(data)
120 return options, section_doc
123 def get_section_rst(section, section_doc, opts):
124 """Convert the section text into formatted ReST.
126 A section from ini file that looks like this:
128 [section]
129 # This is a section level documentation.
130 <BLANKLINE>
131 # This documentation if for immediately following key:value
132 key: value
134 Is converted to ReST that looks something like:
136 ``[section]``
137 =============
141 **default**: value
143 This documentation is for the immediately following key:value
145 rst = '``[{}]``\n{}\n'.format(section, '='*(len(section) + 6))
146 if section_doc:
147 rst += section_doc.replace('#', '')
148 for each in opts:
149 doc = '\n'
150 if 'doc' in each:
151 doc = each.pop('doc')
152 for opt, value in each.items():
153 rst += '{}\n{}\n'.format(opt, '~'*len(opt))
154 if value:
155 rst += '**default**: {}\n\n'.format(value)
156 rst += doc.replace('#', '')
157 rst += '\n\n'
158 return rst
161 class ConfigSectionDirective(Directive):
162 """Sphinx plugin that renders Mailman's ini configuration as ReST."""
164 required_arguments = 1
165 final_argument_whitespace = True
166 option_spec = {}
167 has_content = False
169 def run(self):
170 """Split the arguments as a list of sections and render as ReST."""
172 sections = self.arguments[0].split()
173 child_nodes = []
174 lineno = 1
175 for section in sections:
176 rst = ViewList()
177 config_text = get_config_text()
178 section_text = get_section_text(section, config_text)
179 section_opts, section_doc = get_options(section_text)
180 section_rst = get_section_rst(section, section_doc, section_opts)
181 for line in section_rst.splitlines():
182 rst.append(line, 'fakefile.rst', lineno)
183 lineno += 1
185 node = nodes.section()
186 node.document = self.state.document
187 nested_parse_with_titles(self.state, rst, node)
188 child_nodes.extend(node.children)
189 return child_nodes
192 def setup(app):
193 app.add_directive('configsection', ConfigSectionDirective)
195 return {
196 'version': '0.1',
197 'parallel_read_safe': True,
198 'parallel_write_safe': True,