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