xref: /aosp_15_r20/external/pigweed/pw_docgen/py/pw_docgen/sphinx/seed_metadata.py (revision 61c4878ac05f98d0ceed94b57d316916de578985)
1# Copyright 2024 The Pigweed Authors
2#
3# Licensed under the Apache License, Version 2.0 (the "License"); you may not
4# use this file except in compliance with the License. You may obtain a copy of
5# the License at
6#
7#     https://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12# License for the specific language governing permissions and limitations under
13# the License.
14"""Sphinx directives for Pigweed SEEDs"""
15
16import json
17import os
18
19import docutils
20from docutils import nodes
21
22# pylint: disable=consider-using-from-import
23import docutils.parsers.rst.directives as directives  # type: ignore
24import docutils.statemachine
25
26# pylint: enable=consider-using-from-import
27from sphinx.application import Sphinx
28from sphinx.util.docutils import SphinxDirective
29from sphinx_design.cards import CardDirective
30
31
32# pylint: disable=import-error
33try:
34    import jsonschema  # type: ignore
35
36    jsonschema_enabled = True
37except ModuleNotFoundError:
38    jsonschema_enabled = False
39# pylint: enable=import-error
40
41
42# Get the SEED metadata and schema. The location of these files changes
43# depending on whether we're building with GN or Bazel.
44try:  # Bazel
45    from python.runfiles import runfiles  # type: ignore
46
47    runfile = runfiles.Create()
48    metadata_path = runfile.Rlocation('pigweed/seed/seed_metadata.json')
49    schema_path = runfile.Rlocation('pigweed/seed/seed_metadata_schema.json')
50except ImportError:  # GN
51    metadata_path = f'{os.environ["PW_ROOT"]}/seed/seed_metadata.json'
52    schema_path = f'{os.environ["PW_ROOT"]}/seed/seed_metadata_schema.json'
53with open(metadata_path, 'r') as f:
54    metadata = json.load(f)
55with open(schema_path, 'r') as f:
56    schema = json.load(f)
57# Make sure the metadata matches its schema. Raise an uncaught exception
58# if not.
59if jsonschema_enabled:
60    jsonschema.validate(metadata, schema)
61
62
63def map_status_to_badge_style(status: str) -> str:
64    """Return the badge style for a given status."""
65    mapping = {
66        'draft': 'bdg-primary-line',
67        'intent_approved': 'bdg-info',
68        'open_for_comments': 'bdg-primary',
69        'last_call': 'bdg-warning',
70        'accepted': 'bdg-success',
71        'rejected': 'bdg-danger',
72        'deprecated': 'bdg-secondary',
73        'superseded': 'bdg-info',
74        'on_hold': 'bdg-secondary-line',
75        'meta': 'bdg-success-line',
76    }
77    badge = mapping[parse_status(status)]
78    return f':{badge}:`{status}`'
79
80
81def status_choice(arg) -> str:
82    return directives.choice(
83        arg,
84        (
85            'draft',
86            'intent_approved',
87            'open_for_comments',
88            'last_call',
89            'accepted',
90            'rejected',
91            'on_hold',
92            'meta',
93        ),
94    )
95
96
97def parse_status(arg) -> str:
98    """Support variations on the status choices.
99
100    For example, you can use capital letters and spaces.
101    """
102
103    return status_choice('_'.join([token.lower() for token in arg.split(' ')]))
104
105
106def status_badge(seed_status: str, badge_status) -> str:
107    """Given a SEED status, return the status badge for rendering."""
108
109    return (
110        ':bdg-primary:'
111        if seed_status == badge_status
112        else ':bdg-secondary-line:'
113    )
114
115
116def cl_link(cl_num):
117    return (
118        f'`pwrev/{cl_num} '
119        '<https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/'
120        f'{cl_num}>`_'
121    )
122
123
124class PigweedSeedDirective(SphinxDirective):
125    """Directive registering & rendering SEED metadata."""
126
127    required_arguments = 0
128    final_argument_whitespace = True
129    has_content = True
130    option_spec = {
131        'number': directives.positive_int,
132        'name': directives.unchanged_required,
133        'status': parse_status,
134        'proposal_date': directives.unchanged_required,
135        'cl': directives.positive_int_list,
136        'authors': directives.unchanged_required,
137        'facilitator': directives.unchanged_required,
138    }
139
140    def _try_get_option(self, option: str):
141        """Try to get an option by name and raise on failure."""
142
143        try:
144            return self.options[option]
145        except KeyError:
146            raise self.error(f' :{option}: option is required')
147
148    def run(self) -> list[nodes.Node]:
149        seed_number = '{:04d}'.format(self._try_get_option('number'))
150        seed_name = self._try_get_option('name')
151        status = self._try_get_option('status')
152        proposal_date = self._try_get_option('proposal_date')
153        cl_nums = self._try_get_option('cl')
154        authors = self._try_get_option('authors')
155        facilitator = self._try_get_option('facilitator')
156
157        title = (
158            f':fas:`seedling` SEED-{seed_number}: :ref:'
159            f'`{seed_name}<seed-{seed_number}>`\n'
160        )
161
162        authors_heading = 'Authors' if len(authors.split(',')) > 1 else 'Author'
163
164        self.content = docutils.statemachine.StringList(
165            [
166                ':octicon:`comment-discussion` Status:',
167                f'{status_badge(status, "open_for_comments")}'
168                '`Open for Comments`',
169                ':octicon:`chevron-right`',
170                f'{status_badge(status, "intent_approved")}'
171                '`Intent Approved`',
172                ':octicon:`chevron-right`',
173                f'{status_badge(status, "last_call")}`Last Call`',
174                ':octicon:`chevron-right`',
175                f'{status_badge(status, "accepted")}`Accepted`',
176                ':octicon:`kebab-horizontal`',
177                f'{status_badge(status, "rejected")}`Rejected`',
178                '\n',
179                f':octicon:`calendar` Proposal Date: {proposal_date}',
180                '\n',
181                ':octicon:`code-review` CL: ',
182                ', '.join([cl_link(cl_num) for cl_num in cl_nums]),
183                '\n',
184                f':octicon:`person` {authors_heading}: {authors}',
185                '\n',
186                f':octicon:`person` Facilitator: {facilitator}',
187            ]
188        )
189
190        card = CardDirective.create_card(
191            inst=self,
192            arguments=[title],
193            options={},
194        )
195
196        return [card]
197
198
199def generate_seed_index(app: Sphinx, docname: str, source: list[str]) -> None:
200    """Auto-generates content for //seed/0000.rst.
201
202    The SEED index table and toctree are generated by modifying the
203    reStructuredText of //seed/0000.rst in-place, before Sphinx converts the
204    reST into HTML.
205    """
206    if docname != 'seed/0000':
207        return
208    # Build the SEED index table.
209    source[0] += '.. csv-table::\n'
210    source[
211        0
212    ] += '   :header: "Number","Title","Status","Authors","Facilitator"\n\n'
213    for number in metadata:
214        cl = metadata[number]['cl']
215        status = map_status_to_badge_style(metadata[number]['status'])
216        title = metadata[number]['title']
217        title = f'`{title} <https://pwrev.dev/{cl}>`__'
218        if status == 'Accepted':
219            title = f':ref:`seed-{number}`'
220        authors = ', '.join(metadata[number]['authors'])
221        facilitator = metadata[number]['facilitator']
222        source[
223            0
224        ] += f'   "{number}","{title}","{status}","{authors}","{facilitator}"\n'
225    source[0] += '\n\n'
226    # Build the toctree for //seed/0000.rst.
227    source[0] += '.. toctree::\n'
228    source[0] += '   :hidden:\n\n'
229    for number in metadata:
230        path = f'{app.srcdir}/seed/{number}.rst'
231        if os.path.exists(path):
232            # Link to the SEED content if it exists.
233            source[0] += f'   {number}\n'
234        else:
235            # Otherwise link to the change associated to the SEED.
236            cl = metadata[number]['cl']
237            title = metadata[number]['title']
238            source[0] += f'   {number}: {title} <https://pwrev.dev/{cl}>\n'
239
240
241def setup(app: Sphinx) -> dict[str, bool]:
242    app.add_directive('seed', PigweedSeedDirective)
243    app.connect('source-read', generate_seed_index)
244    return {
245        'parallel_read_safe': True,
246        'parallel_write_safe': True,
247    }
248