diff options
Diffstat (limited to 'pw_docgen/py')
-rw-r--r-- | pw_docgen/py/BUILD.gn | 4 | ||||
-rw-r--r-- | pw_docgen/py/pw_docgen/docserver.py | 299 | ||||
-rw-r--r-- | pw_docgen/py/pw_docgen/seed.py | 125 | ||||
-rw-r--r-- | pw_docgen/py/pw_docgen/sphinx/module_metadata.py | 310 | ||||
-rw-r--r-- | pw_docgen/py/pw_docgen/sphinx/pigweed_live.py | 21 | ||||
-rw-r--r-- | pw_docgen/py/pw_docgen/sphinx/seed_metadata.py | 22 | ||||
-rw-r--r-- | pw_docgen/py/setup.cfg | 1 |
7 files changed, 764 insertions, 18 deletions
diff --git a/pw_docgen/py/BUILD.gn b/pw_docgen/py/BUILD.gn index a52f3ab31..45dded303 100644 --- a/pw_docgen/py/BUILD.gn +++ b/pw_docgen/py/BUILD.gn @@ -1,4 +1,4 @@ -# Copyright 2020 The Pigweed Authors +# Copyright 2023 The Pigweed Authors # # Licensed under the Apache License, Version 2.0 (the "License"); you may not # use this file except in compliance with the License. You may obtain a copy of @@ -24,6 +24,8 @@ pw_python_package("py") { sources = [ "pw_docgen/__init__.py", "pw_docgen/docgen.py", + "pw_docgen/docserver.py", + "pw_docgen/seed.py", "pw_docgen/sphinx/__init__.py", "pw_docgen/sphinx/google_analytics.py", "pw_docgen/sphinx/kconfig.py", diff --git a/pw_docgen/py/pw_docgen/docserver.py b/pw_docgen/py/pw_docgen/docserver.py new file mode 100644 index 000000000..b2ab3c418 --- /dev/null +++ b/pw_docgen/py/pw_docgen/docserver.py @@ -0,0 +1,299 @@ +# Copyright 2023 The Pigweed Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. +"""Serve locally-built docs files. + +There are essentially four components here: + +1. A simple HTTP server that serves docs out of the build directory. + +2. JavaScript that tells a doc to refresh itself when it receives a message + that its source file has been changed. This is injected into each served + page by #1. + +3. A WebSocket server that pushes refresh messages to pages generated by #1 + using the WebSocket client included in #2. + +4. A very simple file watcher that looks for changes in the built docs files + and pushes messages about changed files to #3. +""" + +import asyncio +import http.server +import io +import logging +from pathlib import Path +import socketserver +import threading +from tempfile import TemporaryFile +from typing import Callable + +from watchdog.events import FileModifiedEvent, FileSystemEventHandler +from watchdog.observers import Observer +import websockets + +_LOG = logging.getLogger('pw_docgen.docserver') + + +def _generate_script(path: str, host: str, port: str) -> bytes: + """Generate the JavaScript to inject into served docs pages.""" + return f"""<script> + var connection = null; + var originFilePath = "{path}"; + + function watchForReload() {{ + connection = new WebSocket("ws://{host}:{port}/"); + console.log("Connecting to WebSocket server..."); + + connection.onopen = function () {{ + console.log("Connected to WebSocket server"); + }} + + connection.onerror = function () {{ + console.log("WebSocket connection disconnected or failed"); + }} + + connection.onmessage = function (message) {{ + if (message.data === originFilePath) {{ + window.location.reload(true); + }} + }} + }} + + watchForReload(); +</script> +</body> +""".encode( + "utf-8" + ) + + +class OpenAndInjectScript: + """A substitute for `open` that injects the refresh handler script. + + Instead of returning a handle to the file you asked for, it returns a + handle to a temporary file which has been modified. That file will + disappear as soon as it is `.close()`ed, but that has to be done manually; + it will not close automatically when exiting scope. + + The instance stores the last path that was opened in `path`. + """ + + def __init__(self, host: str, port: str): + self.path: str = "" + self._host = host + self._port = port + + def __call__(self, path: str, mode: str) -> io.BufferedReader: + if 'b' not in mode: + raise ValueError( + "This should only be used to open files in binary mode." + ) + + content = ( + Path(path) + .read_bytes() + .replace(b"</body>", _generate_script(path, self._host, self._port)) + ) + + tempfile = TemporaryFile('w+b') + tempfile.write(content) + # Let the caller read the file like it's just been opened. + tempfile.seek(0) + # Store the path that held the original file. + self.path = path + return tempfile # type: ignore + + +def _docs_http_server( + address: str, port: int, path: Path +) -> Callable[[], None]: + """A simple file system-based HTTP server for built docs.""" + + class DocsStaticRequestHandler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=str(path), **kwargs) + + # Disable logs to stdout. + def log_message( + self, format: str, *args # pylint: disable=redefined-builtin + ) -> None: + return + + def http_server_thread(): + with socketserver.TCPServer( + (address, port), DocsStaticRequestHandler + ) as httpd: + httpd.serve_forever() + + return http_server_thread + + +class TaskFinishedException(Exception): + """Indicates one task has completed successfully.""" + + +class WebSocketConnectionClosedException(Exception): + """Indicates that the WebSocket connection has been closed.""" + + +class DocsWebsocketRequestHandler: + """WebSocket server that sends page refresh info to clients. + + Push messages to the message queue to broadcast them to all connected + clients. + """ + + def __init__(self, address: str = '127.0.0.1', port: int = 8765): + self._address = address + self._port = port + self._connections = set() # type: ignore + self._messages: asyncio.Queue = asyncio.Queue() + + async def _register_connection(self, websocket) -> None: + """Handle client connections and their event loops.""" + self._connections.add(websocket) + _LOG.info("Client connection established: %s", websocket.id) + + while True: + try: + # Run all of these tasks simultaneously. We don't wait for *all* + # of them to finish -- when one finishes, it raises one of the + # flow control exceptions to determine what happens next. + await asyncio.gather( + self._send_messages(), + self._drop_lost_connection(websocket), + ) + except TaskFinishedException: + _LOG.debug("One awaited task finished; iterating event loop.") + except WebSocketConnectionClosedException: + _LOG.debug("WebSocket connection closed; ending event loop.") + return + + async def _drop_lost_connection(self, websocket) -> None: + """Remove connections to clients with no heartbeat.""" + await asyncio.sleep(1) + + if websocket.closed: + self._connections.remove(websocket) + _LOG.info("Client connection dropped: %s", websocket.id) + raise WebSocketConnectionClosedException + + _LOG.debug("Client connection heartbeat active: %s", websocket.id) + raise TaskFinishedException + + async def _send_messages(self) -> None: + """Send the messages in the message queue to all clients. + + Every page change is broadcast to every client. It is up to the client + to determine whether the contents of a messages means it should refresh. + This is a pretty easy determination to make though -- the client knows + its own source file's path, so it just needs to check if the path in the + message matches it. + """ + message = await self._messages.get() + websockets.broadcast(self._connections, message) # type: ignore # pylint: disable=no-member + _LOG.info("Sent to %d clients: %s", len(self._connections), message) + raise TaskFinishedException + + async def _run(self) -> None: + self._messages = asyncio.Queue() + + async with websockets.serve( # type: ignore # pylint: disable=no-member + self._register_connection, self._address, self._port + ): + await asyncio.Future() + + def push_message(self, message: str) -> None: + """Push a message on to the message queue.""" + if len(self._connections) > 0: + self._messages.put_nowait(message) + _LOG.info("Pushed to message queue: %s", message) + + def run(self): + """Run the WebSocket server.""" + asyncio.run(self._run()) + + +class DocsFileChangeEventHandler(FileSystemEventHandler): + """Handle watched built doc files events.""" + + def __init__(self, ws_handler: DocsWebsocketRequestHandler) -> None: + self._ws_handler = ws_handler + + def on_modified(self, event) -> None: + if isinstance(event, FileModifiedEvent): + # Push the path of the modified file to the WebSocket server's + # message queue. + path = Path(event.src_path).relative_to(Path.cwd()) + self._ws_handler.push_message(str(path)) + + return super().on_modified(event) + + +class DocsFileChangeObserver(Observer): # pylint: disable=too-many-ancestors + """Watch for changes to built docs files.""" + + def __init__( + self, path: str, event_handler: FileSystemEventHandler, *args, **kwargs + ): + super().__init__(*args, **kwargs) + self.schedule(event_handler, path, recursive=True) + _LOG.info("Watching build docs files at: %s", path) + + +def serve_docs( + build_dir: Path, + docs_path: Path, + address: str = '127.0.0.1', + port: int = 8000, + ws_port: int = 8765, +) -> None: + """Run the docs server. + + This actually spawns three threads, one each for the HTTP server, the + WebSockets server, and the file watcher. + """ + docs_path = build_dir.joinpath(docs_path.joinpath('html')) + http_server_thread = _docs_http_server(address, port, docs_path) + + # The `http.server.SimpleHTTPRequestHandler.send_head` method loads the + # HTML file from disk, generates and sends headers to the client, then + # passes the file to the HTTP request handlers. We need to modify the file + # in the middle of the process, and the only facility we have for doing that + # is the somewhat distasteful patching of `open`. + _open_and_inject_script = OpenAndInjectScript(address, str(ws_port)) + setattr(http.server, 'open', _open_and_inject_script) + + websocket_server = DocsWebsocketRequestHandler(address, ws_port) + event_handler = DocsFileChangeEventHandler(websocket_server) + + threading.Thread(None, websocket_server.run, 'pw_docserver_ws').start() + threading.Thread(None, http_server_thread, 'pw_docserver_http').start() + DocsFileChangeObserver(str(docs_path), event_handler).start() + + _LOG.info('Serving docs at http://%s:%d', address, port) + + +async def ws_client( + address: str = '127.0.0.1', + port: int = 8765, +): + """A simple WebSocket client, useful for testing. + + Run it like this: `asyncio.run(ws_client())` + """ + async with websockets.connect(f"ws://{address}:{port}") as websocket: # type: ignore # pylint: disable=no-member + _LOG.info("Connection ID: %s", websocket.id) + async for message in websocket: + _LOG.info("Message received: %s", message) diff --git a/pw_docgen/py/pw_docgen/seed.py b/pw_docgen/py/pw_docgen/seed.py new file mode 100644 index 000000000..875e02914 --- /dev/null +++ b/pw_docgen/py/pw_docgen/seed.py @@ -0,0 +1,125 @@ +# Copyright 2023 The Pigweed Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. +"""Generates additional documentation for SEEDs.""" + +import argparse +import json +from pathlib import Path + + +def _parse_args() -> argparse.Namespace: + """Parses command-line arguments.""" + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + '--output', + type=Path, + required=True, + help='Output file to write', + ) + parser.add_argument( + 'seed_metadata', + nargs='+', + help='JSON file containing information about a SEED', + ) + + return parser.parse_args() + + +_STATUS_BADGES = { + 'draft': 'bdg-primary-line', + 'open for comments': 'bdg-primary', + 'last call': 'bdg-warning', + 'accepted': 'bdg-success', + 'rejected': 'bdg-danger', + 'deprecated': 'bdg-secondary', + 'superseded': 'bdg-info', + 'on hold': 'bdg-secondary-line', + 'meta': 'bdg-success-line', +} + + +def _status_badge(status: str) -> str: + badge = _STATUS_BADGES.get(status.lower().strip(), 'bdg-primary') + return f':{badge}:`{status}`' + + +def _main(): + args = _parse_args() + + # Table containing entries for each SEED. + seed_table = [ + '\n.. list-table::', + ' :widths: auto', + ' :header-rows: 1\n', + ' * - Number', + ' - Title', + ' - Status', + ' - Author', + ' - Facilitator', + ] + + # RST toctree including each SEED's source file. + seed_toctree = [] + + for metadata_file in args.seed_metadata: + with open(metadata_file, 'r') as file: + meta = json.load(file) + + if 'changelist' in meta: + # The SEED has not yet been merged and points to an active CL. + change_link = ( + 'https://pigweed-review.googlesource.com' + f'/c/pigweed/pigweed/+/{meta["changelist"]}' + ) + title = f'`{meta["title"]} <{change_link}>`__' + seed_toctree.append( + f'{meta["number"]}: {meta["title"]}<{change_link}>', + ) + else: + # The SEED document is in the source tree. + title = f':ref:`{meta["title"]} <seed-{meta["number"]}>`' + seed_toctree.append(Path(meta["rst_file"]).stem) + + facilitator = meta.get('facilitator', 'Unassigned') + + seed_table.extend( + [ + f' * - {meta["number"]}', + f' - {title}', + f' - {_status_badge(meta["status"])}', + f' - {meta["author"]}', + f' - {facilitator}', + ] + ) + + table = '\n'.join(seed_table) + + # Hide the toctree it is only used for the sidebar. + toctree_lines = '\n '.join(seed_toctree) + + # fmt: off + toctree = ( + '.. toctree::\n' + ' :hidden:\n' + '\n ' + f'{toctree_lines}' + ) + # fmt: on + + args.output.write_text(f'{table}\n\n{toctree}') + + +if __name__ == '__main__': + _main() diff --git a/pw_docgen/py/pw_docgen/sphinx/module_metadata.py b/pw_docgen/py/pw_docgen/sphinx/module_metadata.py index fad1736d7..71d2c233f 100644 --- a/pw_docgen/py/pw_docgen/sphinx/module_metadata.py +++ b/pw_docgen/py/pw_docgen/sphinx/module_metadata.py @@ -11,9 +11,19 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations under # the License. -"""Sphinx directives for Pigweed module metadata""" +"""Generates content related to Pigweed module metadata on pigweed.dev. + +This file implements the following pigweed.dev features: + +* The `.. pigweed-module::` and `.. pigweed-module-subpage::` directives. +* The auto-generated "Source code" and "Issues" URLs that appear in the site + nav for each module. + +Everything is implemented through the Sphinx Extension API. +""" from dataclasses import dataclass +import sys from typing import cast, Dict, List, Optional, TypeVar, Union # We use BeautifulSoup for certain docs rendering features. It may not be @@ -31,13 +41,15 @@ except ModuleNotFoundError: import docutils from docutils import nodes +from docutils.nodes import Element import docutils.statemachine # pylint: disable=consider-using-from-import import docutils.parsers.rst.directives as directives # type: ignore # pylint: enable=consider-using-from-import -from sphinx.application import Sphinx as SphinxApplication +from sphinx.addnodes import document as Document +from sphinx.application import Sphinx from sphinx.environment import BuildEnvironment from sphinx.util.docutils import SphinxDirective @@ -100,6 +112,11 @@ def cs_url(module_name: str) -> str: return f'https://cs.opensource.google/pigweed/pigweed/+/main:{module_name}/' +def issues_url(module_name: str) -> str: + """Returns open issues that mention the given module name.""" + return f'https://issues.pigweed.dev/issues?q={module_name}%20status:open' + + def concat_tags(*tag_lists: List[str]) -> List[str]: """Given a list of tag lists, return them concat'ed and ready for render.""" @@ -345,10 +362,295 @@ def setup_parse_body(_app, _pagename, _templatename, context, _doctree): context['parse_body'] = parse_body -def setup(app: SphinxApplication): +def fix_canonical_url(canonical_url: Optional[str]) -> Optional[str]: + """Rewrites the canonical URL for `pigweed.dev/*/docs.html` pages. + + Our server is configured to remove `docs.html` from URLs. E.g. + pigweed.dev/pw_string/docs.html` redirects to `pigweed.dev/pw_string`. + To improve our SEO, the `<link rel="canonical" href="..."/>` tag in our + HTML should match the URL that the server provides. + + Args: + docname: + Basically the relative path to the doc, except `.rst` is omitted + from the filename. E.g. `pw_string/docs`. + canonical_url: + The default canonical URL that Sphinx has generated for the doc. + + Returns: + The corrected canonical URL if the page would normally end with + `docs.html`, otherwise the original canonical URL value unmodified. + """ + if canonical_url is None or not canonical_url.endswith('/docs.html'): + return canonical_url + canonical_url = canonical_url.replace('/docs.html', '/') + return canonical_url + + +def on_html_page_context( + app: Sphinx, # pylint: disable=unused-argument + docname: str, # pylint: disable=unused-argument + templatename: str, # pylint: disable=unused-argument + context: Optional[Dict[str, Optional[str]]], + doctree: Document, # pylint: disable=unused-argument +) -> None: + """Handles modifications to HTML page metadata, e.g. canonical URLs. + + Args: + docname: + Basically the relative path to the doc, except `.rst` is omitted + from the filename. E.g. `pw_string/docs`. + context: + A dict containing the HTML page's metadata. + + Returns: + None. Modifications happen to the HTML metadata in-place. + """ + canonical_url_key = 'pageurl' + if context is None or canonical_url_key not in context: + return + canonical_url = context[canonical_url_key] + context[canonical_url_key] = fix_canonical_url(canonical_url) + + +def add_links(module_name: str, toctree: Element) -> None: + """Adds source code and issues URLs to a module's table of contents tree. + + This function is how we auto-generate the source code and issues URLs + that appear for each module in the pigweed.dev site nav. + + Args: + module_name: + The Pigweed module that we're creating links for. + toctree: + The table of contents tree from that module's homepage. + + Returns: + `None`. `toctree` is modified in-place. + """ + src = ('Source code', cs_url(module_name)) + issues = ('Issues', issues_url(module_name)) + # Maintenance tip: the trick here is to create the `toctree` the same way + # that Sphinx generates it. When in doubt, enable logging in this file, + # manually modify the `.. toctree::` directive on a module's homepage, log + # out `toctree` from somewhere in this script (you should see an XML-style + # node), and then just make sure your code modifies the `toctree` the same + # way that Sphinx generates it. + toctree['entries'] += [src, issues] + toctree['rawentries'] += [src[0], issues[0]] + + +def find_first_toctree(doctree: Document) -> Optional[Element]: + """Finds the first `toctree` (table of contents tree) node in a `Document`. + + Args: + doctree: + The content of a doc, represented as a tree of Docutils nodes. + + Returns: + The first `toctree` node found in `doctree` or `None` if none was + found. + """ + for node in doctree.traverse(nodes.Element): + if node.tagname == 'toctree': + return node + return None + + +def parse_module_name(docname: str) -> str: + """Extracts a Pigweed module name from a Sphinx docname. + + Preconditions: + `docname` is assumed to start with `pw_`. I.e. the docs are assumed to + have a flat directory structure, where the first directory is the name + of a Pigweed module. + + Args: + docname: + Basically the relative path to the doc, except `.rst` is omitted + from the filename. E.g. `pw_string/docs`. + + Returns: + Just the Pigweed module name, e.g. `pw_string`. + """ + tokens = docname.split('/') + return tokens[0] + + +def on_doctree_read(app: Sphinx, doctree: Document) -> None: + """Event handler that enables manipulating a doc's Docutils tree. + + Sphinx fires this listener after it has parsed a doc's reStructuredText + into a tree of Docutils nodes. The listener fires once for each doc that's + processed. + + In general, this stage of the Sphinx event lifecycle can only be used for + content changes that do not affect the Sphinx build environment [1]. For + example, creating a `toctree` node at this stage does not work, but + inserting links into a pre-existing `toctree` node is OK. + + Args: + app: + Our Sphinx docs build system. + doctree: + The doc content, structured as a tree. + + Returns: + `None`. The main modifications happen in-place in `doctree`. + + [1] See link in `on_source_read()` + """ + docname = app.env.docname + if not is_module_homepage(docname): + return + toctree = find_first_toctree(doctree) + if toctree is None: + # `add_toctree_to_module_homepage()` should ensure that every + # `pw_*/docs.rst` file has a `toctree` node but if something went wrong + # then we should bail. + sys.exit(f'[module_metadata.py] error: toctree missing in {docname}') + module_name = parse_module_name(docname) + add_links(module_name, toctree) + + +def is_module_homepage(docname: str) -> bool: + """Determines if a doc is a module homepage. + + Any doc that matches the pattern `pw_*/docs.rst` is considered a module + homepage. Watch out for the false positive of `pw_*/*/docs.rst`. + + Preconditions: + `docname` is assumed to start with `pw_`. I.e. the docs are assumed to + have a flat directory structure, where the first directory is the name + of a Pigweed module. + + Args: + docname: + Basically the relative path to the doc, except `.rst` is omitted + from the filename. + + Returns: + `True` if the doc is a module homepage, else `False`. + """ + tokens = docname.split('/') + if len(tokens) != 2: + return False + if not tokens[0].startswith('pw_'): + return False + if tokens[1] != 'docs': + return False + return True + + +def add_toctree_to_module_homepage(docname: str, source: str) -> str: + """Appends an empty `toctree` to a module homepage. + + Note that this function only needs to create the `toctree` node; it doesn't + need to fully populate the `toctree`. Inserting links later via the more + ergonomic Docutils API works fine. + + Args: + docname: + Basically the relative path to `source`, except `.rst` is omitted + from the filename. + source: + The reStructuredText source code of `docname`. + + Returns: + For module homepages that did not already have a `toctree`, the + original contents of `source` plus an empty `toctree` is returned. + For all other cases, the original contents of `source` are returned + with no modification. + """ + # Don't do anything if the page is not a module homepage, i.e. its + # `docname` doesn't match the pattern `pw_*`/docs`. + if not is_module_homepage(docname): + return source + # Don't do anything if the module homepage already has a `toctree`. + if '.. toctree::' in source: + return source + # Append an empty `toctree` to the content. + # yapf: disable + return ( + f'{source}\n\n' + '.. toctree::\n' + ' :hidden:\n' + ' :maxdepth: 1\n' + ) + # yapf: enable + # Python formatting (yapf) is disabled in the return statement because the + # formatter tries to change it to a less-readable single line string. + + +# inclusive-language: disable +def on_source_read( + app: Sphinx, # pylint: disable=unused-argument + docname: str, + source: List[str], +) -> None: + """Event handler that enables manipulating a doc's reStructuredText. + + Sphinx fires this event early in its event lifecycle [1], before it has + converted a doc's reStructuredText (reST) into a tree of Docutils nodes. + The listener fires once for each doc that's processed. + + This is the place to make docs changes that have to propagate across the + site. Take our use case of adding a link in the site nav to each module's + source code. To do this we need a `toctree` (table of contents tree) node + on each module's homepage; the `toctree` is where we insert the source code + link. If we try to dynamically insert the `toctree` node via the Docutils + API later in the event lifecycle, e.g. during the `doctree-read` event, we + have to do a bunch of complex and fragile logic to make the Sphinx build + environment [2] aware of the new node. It's simpler and more reliable to + just insert a `.. toctree::` directive into the doc source before Sphinx + has processed the doc and then let Sphinx create its build environment as + it normally does. We just have to make sure the reStructuredText we're + injecting into the content is syntactically correct. + + Args: + app: + Our Sphinx docs build system. + docname: + Basically the relative path to `source`, except `.rst` is omitted + from the filename. + source: + The reStructuredText source code of `docname`. + + Returns: + None. `source` is modified in-place. + + [1] www.sphinx-doc.org/en/master/extdev/appapi.html#sphinx-core-events + [2] www.sphinx-doc.org/en/master/extdev/envapi.html + """ + # inclusive-language: enable + # If a module homepage doesn't have a `toctree`, add one. + source[0] = add_toctree_to_module_homepage(docname, source[0]) + + +def setup(app: Sphinx) -> Dict[str, bool]: + """Hooks the extension into our Sphinx docs build system. + + This runs only once per docs build. + + Args: + app: + Our Sphinx docs build system. + + Returns: + A dict that provides Sphinx info about our extension. + """ + # Register the `.. pigweed-module::` and `.. pigweed-module-subpage::` + # directives that are used on `pw_*/*.rst` pages. app.add_directive('pigweed-module', PigweedModuleDirective) app.add_directive('pigweed-module-subpage', PigweedModuleSubpageDirective) - + # inclusive-language: disable + # Register the Sphinx event listeners that automatically generate content + # for `pw_*/*.rst` pages: + # www.sphinx-doc.org/en/master/extdev/appapi.html#sphinx-core-events + # inclusive-language: enable + app.connect('source-read', on_source_read) + app.connect('doctree-read', on_doctree_read) + app.connect('html-page-context', on_html_page_context) return { 'parallel_read_safe': True, 'parallel_write_safe': True, diff --git a/pw_docgen/py/pw_docgen/sphinx/pigweed_live.py b/pw_docgen/py/pw_docgen/sphinx/pigweed_live.py index d7382cd38..39710d2fa 100644 --- a/pw_docgen/py/pw_docgen/sphinx/pigweed_live.py +++ b/pw_docgen/py/pw_docgen/sphinx/pigweed_live.py @@ -42,7 +42,7 @@ class PigweedLiveDirective(Directive): '2023-11-06 13:00:00', # 2023-11-20 skipped since it's a holiday(ish) '2023-12-04 13:00:00', - '2023-12-18 13:00:00', + # 2023-12-18 skipped due to its holiday proximity # 2024-01-01 and 2024-01-15 are skipped because they're holidays. '2024-01-29 13:00:00', '2024-02-12 13:00:00', @@ -76,21 +76,18 @@ class PigweedLiveDirective(Directive): meeting_text = nodes.strong() meeting_text += nodes.Text(next_meeting) paragraph += meeting_text - paragraph += nodes.Text( - ( - ". Please join us to discuss what's new in Pigweed and " - "anything else Pigweed-related. Or stop in just to say hi and " - "meet the team! You'll find a link for the meeting in the " - "#pigweed-live channel of our " - ) + paragraph += nodes.Text(". Please ") + refuri = ( + 'https://discord.com/channels/691686718377558037/' + '951228399119126548' ) - link = nodes.reference(refuri='https://discord.gg/M9NSeTA') - link += nodes.Text('Discord') + link = nodes.reference(refuri=refuri) + link += nodes.Text('join us') paragraph += link paragraph += nodes.Text( ( - '. We meet bi-weekly. The meeting is public. Everyone is ' - 'welcome to join.' + " to discuss what's new in Pigweed and anything else " + "Pigweed-related." ) ) return paragraph diff --git a/pw_docgen/py/pw_docgen/sphinx/seed_metadata.py b/pw_docgen/py/pw_docgen/sphinx/seed_metadata.py index 9134081ea..6df4ac7e6 100644 --- a/pw_docgen/py/pw_docgen/sphinx/seed_metadata.py +++ b/pw_docgen/py/pw_docgen/sphinx/seed_metadata.py @@ -31,7 +31,14 @@ from sphinx_design.cards import CardDirective def status_choice(arg) -> str: return directives.choice( - arg, ('open_for_comments', 'last_call', 'accepted', 'rejected') + arg, + ( + 'open_for_comments', + 'intent_approved', + 'last_call', + 'accepted', + 'rejected', + ), ) @@ -74,6 +81,8 @@ class PigweedSeedDirective(SphinxDirective): 'status': parse_status, 'proposal_date': directives.unchanged_required, 'cl': directives.positive_int_list, + 'authors': directives.unchanged_required, + 'facilitator': directives.unchanged_required, } def _try_get_option(self, option: str): @@ -90,18 +99,25 @@ class PigweedSeedDirective(SphinxDirective): status = self._try_get_option('status') proposal_date = self._try_get_option('proposal_date') cl_nums = self._try_get_option('cl') + authors = self._try_get_option('authors') + facilitator = self._try_get_option('facilitator') title = ( f':fas:`seedling` SEED-{seed_number}: :ref:' f'`{seed_name}<seed-{seed_number}>`\n' ) + authors_heading = 'Authors' if len(authors.split(',')) > 1 else 'Author' + self.content = docutils.statemachine.StringList( [ ':octicon:`comment-discussion` Status:', f'{status_badge(status, "open_for_comments")}' '`Open for Comments`', ':octicon:`chevron-right`', + f'{status_badge(status, "intent_approved")}' + '`Intent Approved`', + ':octicon:`chevron-right`', f'{status_badge(status, "last_call")}`Last Call`', ':octicon:`chevron-right`', f'{status_badge(status, "accepted")}`Accepted`', @@ -112,6 +128,10 @@ class PigweedSeedDirective(SphinxDirective): '\n', ':octicon:`code-review` CL: ', ', '.join([cl_link(cl_num) for cl_num in cl_nums]), + '\n', + f':octicon:`person` {authors_heading}: {authors}', + '\n', + f':octicon:`person` Facilitator: {facilitator}', ] ) diff --git a/pw_docgen/py/setup.cfg b/pw_docgen/py/setup.cfg index c139c9633..6e4171d7c 100644 --- a/pw_docgen/py/setup.cfg +++ b/pw_docgen/py/setup.cfg @@ -26,6 +26,7 @@ install_requires = sphinx-argparse sphinxcontrib-mermaid>=0.7.1 sphinx-design>=0.3.0 + websockets [options.package_data] |