aboutsummaryrefslogtreecommitdiff
path: root/pw_docgen/py
diff options
context:
space:
mode:
Diffstat (limited to 'pw_docgen/py')
-rw-r--r--pw_docgen/py/BUILD.gn4
-rw-r--r--pw_docgen/py/pw_docgen/docserver.py299
-rw-r--r--pw_docgen/py/pw_docgen/seed.py125
-rw-r--r--pw_docgen/py/pw_docgen/sphinx/module_metadata.py310
-rw-r--r--pw_docgen/py/pw_docgen/sphinx/pigweed_live.py21
-rw-r--r--pw_docgen/py/pw_docgen/sphinx/seed_metadata.py22
-rw-r--r--pw_docgen/py/setup.cfg1
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]