blob: 620a06676c8641a3d4face0a41f271aeef600991 [file]
# 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 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 json
import os
import sys
from typing import cast, TypeVar
# We use BeautifulSoup for certain docs rendering features. It may not be
# available in downstream projects. If so, no problem. We fall back to simpler
# docs rendering.
# pylint: disable=import-error
try:
from bs4 import BeautifulSoup # type: ignore
from bs4.element import Tag as HTMLTag # type: ignore
bs_enabled = True
except ModuleNotFoundError:
bs_enabled = False
try:
import jsonschema # type: ignore
jsonschema_enabled = True
except ModuleNotFoundError:
jsonschema_enabled = False
# pylint: enable=import-error
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.addnodes import document as Document
from sphinx.application import Sphinx
from sphinx.environment import BuildEnvironment
from sphinx.util.docutils import SphinxDirective
from sphinx_design.cards import CardDirective
EnvAttrT = TypeVar('EnvAttrT')
# The module metadata is exposed as a global because it's used as read-only
# data. Opening and reading the metadata file in one of the event handlers
# would cause hundreds of filesystem reads on each build because those event
# handlers fire once for each docs page that's built.
try: # Bazel location for the data
from python.runfiles import runfiles # type: ignore
script_dir = os.path.dirname(os.path.abspath(__file__))
schema_file = f"{script_dir}/../module_metadata_schema.json"
metadata_file = f"{script_dir}/../module_metadata.json"
except ImportError: # GN location for the data
schema_file = f'{os.environ["PW_ROOT"]}/docs/module_metadata_schema.json'
metadata_file = f'{os.environ["PW_ROOT"]}/docs/module_metadata.json'
with open(schema_file, 'r') as f:
schema = json.load(f)
with open(metadata_file, 'r') as f:
metadata = json.load(f)
# Make sure the metadata matches its schema. Raise an uncaught exception
# if not.
if jsonschema_enabled:
jsonschema.validate(metadata, schema)
@dataclass
class ParsedBody:
topnav: str
body_without_topnav: str
class EnvMetadata:
"""Easier access to the Sphinx `env` for custom metadata.
You can store things in the Sphinx `env`, which is just a dict. But each
time you do, you have to handle the possibility that the key you want
hasn't been set yet, and set it to a default. The `env` is also untyped,
so you have to cast the value you get to whatever type you expect it to be.
Or you can use this class to define your metadata keys up front, and just
access them like: `value = EnvMetadata(env).my_value`
... which will handle initializing the value if it hasn't been yet and
provide you a typed result.
"""
def __init__(self, env: BuildEnvironment):
self._env = env
def _get_env_attr(self, attr: str, default: EnvAttrT) -> EnvAttrT:
if not hasattr(self._env, attr):
value: EnvAttrT = default
setattr(self._env, attr, value)
else:
value = getattr(self._env, attr)
return value
@property
def pw_parsed_bodies(self) -> dict[str, ParsedBody]:
default: dict[str, ParsedBody] = {}
return self._get_env_attr('pw_module_nav', default)
def get_languages(module_name: str) -> list[str] | None:
"""Returns the list of languages that a module supports.
Args:
module_name: The module to look up.
Returns:
A list of programming languages that the module supports, or ``None``
if this has not been defined in ``//docs/sphinx/module_metadata.json``.
"""
if module_name not in metadata:
return None
if 'languages' not in metadata[module_name]:
return None
return metadata[module_name]['languages']
def get_status(module_name: str) -> str:
"""Returns the status of a module.
Preconditions:
The status must be defined in ``//docs/sphinx/module_metadata.json``.
Args:
module_name: The module to look up.
Returns:
The status of the module as a string.
"""
if module_name not in metadata:
sys.exit(f'{module_name} not found in {metadata_file}')
if 'status' not in metadata[module_name]:
sys.exit(f'{module_name}.status not found in {metadata_file}')
return metadata[module_name]['status']
def get_tagline(module_name: str) -> str | None:
"""Returns the tagline for a module.
Args:
module_name: The module to look up.
Returns:
The module's tagline or ``None`` if no tagline has been defined
in ``//docs/sphinx/module_metadata.json``.
"""
if module_name not in metadata:
return None
if 'tagline' not in metadata[module_name]:
return None
return metadata[module_name]['tagline']
def get_code_size(module_name: str) -> str | None:
"""Returns the code size impact summary for a module.
Args:
module_name: The module to look up.
Returns:
The code size impact summary as a string or ``None`` if no summary
has been defined in ``//docs/sphinx/module_metadata.json``.
"""
if module_name not in metadata:
return None
if 'size' not in metadata[module_name]:
return None
return metadata[module_name]['size']
def status_badge(module_status: str) -> str:
"""Given a module status, return the status badge for rendering."""
# Use a different badge for each module status value.
# https://sphinx-design.readthedocs.io/en/latest/badges_buttons.html#badges
status_to_badge = {
'stable': 'primary',
'unstable': 'secondary',
'experimental': 'warning',
'deprecated': 'danger',
}
badge = status_to_badge[module_status]
role = f':bdg-{badge}:'
return role + f'`{module_status.title()}`'
def cs_url(module_name: str) -> str:
"""Return the codesearch URL for the given module."""
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 rustdoc_url(module_name: str) -> str:
"""Returns the rustdoc URL for a given module."""
return f'https://pigweed.dev/rustdoc/{module_name}'
def doxygen_url(module_name: str) -> str:
"""Returns the Doxygen URL for a given module."""
module_name = module_name.replace("_", "__")
return f'https://pigweed.dev/api/cc/group__{module_name}.html'
def concat_tags(*tag_lists: list[str]) -> list[str]:
"""Given a list of tag lists, return them concat'ed and ready for render."""
all_tags = tag_lists[0]
for tag_list in tag_lists[1:]:
if len(tag_list) > 0:
all_tags.append(':octicon:`dot-fill`')
all_tags.extend(tag_list)
return all_tags
def create_topnav(
subtitle: str | None,
extra_classes: list[str] | None = None,
) -> nodes.Node:
"""Create the nodes for the top title and navigation bar."""
topnav_classes = (
['pw-topnav'] + extra_classes if extra_classes is not None else []
)
topnav_container = nodes.container(classes=topnav_classes)
if subtitle:
subtitle_node = nodes.paragraph(
classes=['pw-topnav-subtitle'],
text=subtitle,
)
topnav_container += subtitle_node
return topnav_container
class PigweedModuleDirective(SphinxDirective):
"""Directive registering module metadata, rendering title & info card."""
required_arguments = 0
final_argument_whitespace = True
has_content = True
option_spec = {'name': directives.unchanged_required}
def _try_get_option(self, option: str):
"""Try to get an option by name and raise on failure."""
try:
return self.options[option]
except KeyError:
raise self.error(f' :{option}: option is required')
def _maybe_get_option(self, option: str):
"""Try to get an option by name and return None on failure."""
return self.options.get(option, None)
def run(self) -> list[nodes.Node]:
module_name = self._try_get_option('name')
tagline = get_tagline(module_name)
status = get_status(module_name)
status_tags: list[str] = [
status_badge(status),
]
languages = get_languages(module_name)
language_tags = []
if languages:
for language in languages:
language_tags.append(f':bdg-info:`{language}`')
code_size_impact = []
code_size_text = get_code_size(module_name)
if code_size_text:
code_size_impact.append(f'**Code Size Impact:** {code_size_text}')
# Move the directive content into a section that we can render wherever
# we want.
raw_content = cast(list[str], self.content) # type: ignore
content = nodes.paragraph()
self.state.nested_parse(raw_content, 0, content)
# The card inherits its content from this node's content, which we've
# already pulled out. So we can replace this node's content with the
# content we need in the card.
self.content = docutils.statemachine.StringList(
concat_tags(status_tags, language_tags, code_size_impact)
)
card = CardDirective.create_card(
inst=self,
arguments=[],
options={},
)
topbar = create_topnav(
tagline,
['pw-module-index'],
)
return [topbar, card, content]
class PigweedModuleSubpageDirective(PigweedModuleDirective):
"""Directive registering module metadata, rendering title & info card."""
required_arguments = 0
final_argument_whitespace = True
has_content = True
option_spec = {
'name': directives.unchanged_required,
'nav': directives.unchanged_required,
}
def run(self) -> list[nodes.Node]:
module_name = self._try_get_option('name')
tagline = get_tagline(module_name)
# Prepend the module name on sub-pages so that it's very clear what
# the tagline is referring to.
tagline = f'{module_name}: {tagline}'
topbar = create_topnav(
tagline,
['pw-module-subpage'],
)
return [topbar]
def _parse_body(body: str) -> ParsedBody:
"""From the `body` HTML, return the topnav and the body without topnav.
The fundamental idea is this: Our Sphinx directives can only render nodes
*within* the docutils doc, but we want to elevate the top navbar *outside*
of that doc into the web theme. Sphinx by itself provides no mechanism for
this, since it's model looks something like this:
┌──────────────────┐
│ Theme │
│ ┌──────────────┐│ When Sphinx builds HTML, the output is plain HTML
│ │ Sphinx HTML ││ with a structure defined by docutils. Themes can
│ │ ││ build *around* that and cascade styles down *into*
│ │ ││ that HTML, but there's no mechanism in the Sphinx
│ └──────────────┘│ build to render docutils nodes in the theme.
└──────────────────┘
The escape hatch is this:
- Render things within the Sphinx HTML output (`body`)
- Use Sphinx theme templates to run code during the final render phase
- Extract the HTML from the `body` and insert it in the theme via templates
So this function extracts the things that we rendered in the `body` but
actually want in the theme (the top navbar), returns them for rendering in
the template, and returns the `body` with those things removed.
"""
if not bs_enabled:
return ParsedBody('', body)
def _add_class_to_tag(tag: HTMLTag, classname: str) -> None:
tag['class'] = tag.get('class', []) + [classname] # type: ignore
def _add_classes_to_tag(
tag: HTMLTag, classnames: str | list[str] | None
) -> None:
tag['class'] = tag.get('class', []) + classnames # type: ignore
html = BeautifulSoup(body, features='html.parser')
# Render the doc unchanged, unless it has the module doc topnav
if (topnav := html.find('div', attrs={'class': 'pw-topnav'})) is None:
return ParsedBody('', body)
assert isinstance(topnav, HTMLTag)
# Find the topnav title and subtitle
topnav_title = topnav.find('p', attrs={'class': 'pw-topnav-title'})
topnav_subtitle = topnav.find('p', attrs={'class': 'pw-topnav-subtitle'})
assert isinstance(topnav_title, HTMLTag)
assert isinstance(topnav_subtitle, HTMLTag)
# Find the single `h1` element, the doc's canonical title
doc_title = html.find('h1')
assert isinstance(doc_title, HTMLTag)
topnav_str = ''
if 'pw-module-index' in topnav['class']:
# Take the standard Sphinx/docutils title and add topnav styling
_add_class_to_tag(doc_title, 'pw-topnav-title')
# Replace the placeholder title in the topnav with the "official" `h1`
topnav_title.replace_with(doc_title)
# Promote the subtitle to `h2`
topnav_subtitle.name = 'h2'
# We're done mutating topnav; write it to string for rendering elsewhere
topnav_str = str(topnav)
# Destroy the instance that was rendered in the document
topnav.decompose()
elif 'pw-module-subpage' in topnav['class']:
# Take the title from the topnav (the module name), promote it to `h1`
topnav_title.name = 'h1'
# Add the heading link, but pointed to the module index page
heading_link = html.new_tag(
'a',
attrs={
'class': ['headerlink'],
'href': 'docs.html',
'title': 'Permalink to module index',
},
)
heading_link.string = '#'
topnav_title.append(heading_link)
# Promote the subtitle to `h2`
topnav_subtitle.name = 'h2'
# We're done mutating topnav; write it to string for rendering elsewhere
topnav_str = str(topnav)
# Destroy the instance that was rendered in the document
topnav.decompose()
return ParsedBody(topnav_str, str(html))
def setup_parse_body(_app, _pagename, _templatename, context, _doctree):
def parse_body(body: str) -> ParsedBody:
return _parse_body(body)
context['parse_body'] = parse_body
def fix_canonical_url(canonical_url: str | None) -> str | None:
"""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: dict[str, str | None] | None,
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 should_add_rust_link(module_name: str, languages: list[str] | None) -> bool:
if languages is None:
return False
ignore = [
# TODO: https://pwbug.dev/424641732 - Remove this.
'pw_kernel',
]
if module_name in ignore:
return False
if 'Rust' not in languages:
return False
return True
# TODO: https://pwbug.dev/426012010 - Make this logic general,
# like how `should_add_rust_link` does it.
def should_add_doxygen_link(module_name) -> bool:
ok = [
'pw_alignment',
'pw_allocator',
'pw_analog',
'pw_async',
'pw_async2',
'pw_async_basic',
'pw_base64',
'pw_bloat',
'pw_bluetooth',
'pw_bluetooth_proxy',
'pw_bluetooth_sapphire',
'pw_build',
'pw_bytes',
'pw_channel',
'pw_chre',
'pw_chrono',
'pw_clock_tree',
'pw_clock_tree_mcuxpresso',
'pw_containers',
'pw_crypto',
'pw_digital_io',
'pw_digital_io_mcuxpresso',
'pw_display',
'pw_elf',
'pw_function',
'pw_hdlc',
'pw_hex_dump',
'pw_i2c',
'pw_i2c_linux',
'pw_interrupt',
'pw_json',
'pw_kvs',
'pw_log',
'pw_log_tokenized',
'pw_malloc',
'pw_multibuf',
'pw_numeric',
'pw_perf_test',
'pw_persistent_ram',
'pw_polyfill',
'pw_preprocessor',
'pw_protobuf',
'pw_random',
'pw_result',
'pw_rpc',
'pw_span',
'pw_spi',
'pw_status',
'pw_stream',
'pw_stream_uart_linux',
'pw_string',
'pw_sync',
'pw_sys_io',
'pw_system',
'pw_thread',
'pw_tokenizer',
'pw_toolchain',
'pw_trace_tokenized',
'pw_transfer',
'pw_uart',
'pw_uuid',
'pw_varint',
'pw_work_queue',
]
return module_name in ok
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.
"""
languages = get_languages(module_name)
ignore = [
# TODO: https://pwbug.dev/424641732 - Remove this.
'pw_kernel',
]
if should_add_rust_link(module_name, languages):
rustdoc = ('Rust API reference', rustdoc_url(module_name))
toctree['entries'] += [rustdoc]
toctree['rawentries'] += [rustdoc[0]]
if should_add_doxygen_link(module_name):
doxygen = ('C/C++ API reference', doxygen_url(module_name))
toctree['entries'] += [doxygen]
toctree['rawentries'] += [doxygen[0]]
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) -> Element | None:
"""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,
}