zephyr/doc/_scripts/gen_devicetree_rest.py

792 lines
26 KiB
Python

# Copyright (c) 2020 Nordic Semiconductor ASA
# SPDX-License-Identifier: Apache-2.0
"""
Like gen_kconfig_rest.py, but for generating an index of existing
devicetree bindings.
"""
import argparse
from collections import defaultdict
import glob
import io
import logging
import os
from pathlib import Path
import pprint
import re
import sys
import textwrap
from devicetree import edtlib
import gen_helpers
ZEPHYR_BASE = Path(__file__).parents[2]
GENERIC_OR_VENDOR_INDEPENDENT = 'Generic or vendor-independent'
UNKNOWN_VENDOR = 'Unknown vendor'
ZEPHYR_BASE = Path(__file__).parents[2]
# Base properties that have documentation in 'dt-important-props'.
DETAILS_IN_IMPORTANT_PROPS = set('compatible label reg status interrupts'.split())
logger = logging.getLogger('gen_devicetree_rest')
class VndLookup:
"""
A convenience class for looking up information based on a
devicetree compatible's vendor prefix 'vnd'.
"""
def __init__(self, vendor_prefixes, bindings):
self.vnd2vendor = self.load_vnd2vendor(vendor_prefixes)
self.vnd2bindings = self.init_vnd2bindings(bindings)
self.vnd2ref_target = self.init_vnd2ref_target()
def vendor(self, vnd):
return self.vnd2vendor.get(vnd, UNKNOWN_VENDOR)
def bindings(self, vnd, default=None):
return self.vnd2bindings.get(vnd, default)
def target(self, vnd):
return self.vnd2ref_target.get(
vnd, self.vnd2ref_target[(UNKNOWN_VENDOR,)])
@staticmethod
def load_vnd2vendor(vendor_prefixes):
# Load the vendor-prefixes.txt file. Return a dict mapping 'vnd'
# vendor prefixes as they are found in compatible properties to
# each vendor's full name.
#
# For example, this line:
#
# vnd A stand-in for a real vendor
#
# Gets split into a key 'vnd' and a value 'A stand-in for a real
# vendor' in the return value.
#
# The 'None' key maps to GENERIC_OR_VENDOR_INDEPENDENT.
vnd2vendor = {
None: GENERIC_OR_VENDOR_INDEPENDENT,
}
vnd2vendor.update(edtlib.load_vendor_prefixes_txt(vendor_prefixes))
logger.info('found %d vendor prefixes in %s', len(vnd2vendor) - 1,
vendor_prefixes)
if logger.isEnabledFor(logging.DEBUG):
logger.debug('vnd2vendor=%s', pprint.pformat(vnd2vendor))
return vnd2vendor
def init_vnd2bindings(self, bindings):
# Take a 'vnd2vendor' map and a list of bindings and return a dict
# mapping 'vnd' vendor prefixes prefixes to lists of bindings. The
# bindings in each list are sorted by compatible. The keys in the
# return value are sorted by vendor name.
#
# Special cases:
#
# - The 'None' key maps to bindings with no vendor prefix
# in their compatibles, like 'gpio-keys'. This is the first key.
# - The (UNKNOWN_VENDOR,) key maps to bindings whose compatible
# has a vendor prefix that exists, but is not known,
# like 'somethingrandom,device'. This is the last key.
# Get an unsorted dict mapping vendor prefixes to lists of bindings.
unsorted = defaultdict(list)
generic_bindings = []
unknown_vendor_bindings = []
for binding in bindings:
vnd = compatible_vnd(binding.compatible)
if vnd is None:
generic_bindings.append(binding)
elif vnd in self.vnd2vendor:
unsorted[vnd].append(binding)
else:
unknown_vendor_bindings.append(binding)
# Key functions for sorting.
def vnd_key(vnd):
return self.vnd2vendor[vnd].casefold()
def binding_key(binding):
return binding.compatible
# Sort the bindings for each vendor by compatible.
# Plain dicts are sorted in CPython 3.6+, which is what we
# support, so the return dict's keys are in the same
# order as vnd2vendor.
#
# The unknown-vendor bindings being inserted as a 1-tuple key is a
# hack for convenience that ensures they won't collide with a
# known vendor. The code that consumes the dict below handles
# this.
vnd2bindings = {
None: sorted(generic_bindings, key=binding_key)
}
for vnd in sorted(unsorted, key=vnd_key):
vnd2bindings[vnd] = sorted(unsorted[vnd], key=binding_key)
vnd2bindings[(UNKNOWN_VENDOR,)] = sorted(unknown_vendor_bindings,
key=binding_key)
if logger.isEnabledFor(logging.DEBUG):
logger.debug('vnd2bindings: %s', pprint.pformat(vnd2bindings))
return vnd2bindings
def init_vnd2ref_target(self):
# The return value, vnd2ref_target, is a dict mapping vendor
# prefixes to ref targets for their relevant sections in this
# file, with these special cases:
#
# - The None key maps to the ref target for bindings with no
# vendor prefix in their compatibles, like 'gpio-keys'
# - The (UNKNOWN_VENDOR,) key maps to the ref target for bindings
# whose compatible has a vendor prefix that is not recognized.
vnd2ref_target = {}
for vnd in self.vnd2bindings:
if vnd is None:
vnd2ref_target[vnd] = 'dt_no_vendor'
elif isinstance(vnd, str):
vnd2ref_target[vnd] = f'dt_vendor_{vnd}'
else:
assert vnd == (UNKNOWN_VENDOR,), vnd
vnd2ref_target[vnd] = 'dt_unknown_vendor'
return vnd2ref_target
def main():
args = parse_args()
setup_logging(args.verbose)
bindings = load_bindings(args.dts_roots)
base_binding = load_base_binding()
vnd_lookup = VndLookup(args.vendor_prefixes, bindings)
dump_content(bindings, base_binding, vnd_lookup, args.out_dir)
def parse_args():
# Parse command line arguments from sys.argv.
parser = argparse.ArgumentParser()
parser.add_argument('-v', '--verbose', default=0, action='count',
help='increase verbosity; may be given multiple times')
parser.add_argument('--vendor-prefixes', required=True,
help='vendor-prefixes.txt file path')
parser.add_argument('--dts-root', dest='dts_roots', action='append',
help='''additional DTS root directory as it would
be set in DTS_ROOTS''')
parser.add_argument('out_dir', help='output files are generated here')
return parser.parse_args()
def setup_logging(verbose):
if verbose >= 2:
log_level = logging.DEBUG
elif verbose == 1:
log_level = logging.INFO
else:
log_level = logging.ERROR
logging.basicConfig(format='%(filename)s:%(levelname)s: %(message)s',
level=log_level)
def load_bindings(dts_roots):
# Get a list of edtlib.Binding objects from searching 'dts_roots'.
if not dts_roots:
sys.exit('no DTS roots; use --dts-root to specify at least one')
binding_files = []
for dts_root in dts_roots:
binding_files.extend(glob.glob(f'{dts_root}/dts/bindings/**/*.yaml',
recursive=True))
bindings = edtlib.bindings_from_paths(binding_files, ignore_errors=True)
num_total = len(bindings)
# Remove bindings from the 'vnd' vendor, which is not a real vendor,
# but rather a stand-in we use for examples and tests when a real
# vendor would be inappropriate.
bindings = [binding for binding in bindings if
compatible_vnd(binding.compatible) != 'vnd']
logger.info('found %d bindings (ignored %d) in this dts_roots list: %s',
len(bindings), num_total - len(bindings), dts_roots)
return bindings
def load_base_binding():
# Make a Binding object for base.yaml.
#
# This helps separate presentation for properties common to all
# nodes from node-specific properties.
base_yaml = ZEPHYR_BASE / 'dts' / 'bindings' / 'base' / 'base.yaml'
base_includes = {"pm.yaml": os.fspath(ZEPHYR_BASE / 'dts' / 'bindings' / 'base'/ 'pm.yaml')}
if not base_yaml.is_file():
sys.exit(f'Expected to find base.yaml at {base_yaml}')
return edtlib.Binding(os.fspath(base_yaml), base_includes, require_compatible=False,
require_description=False)
def dump_content(bindings, base_binding, vnd_lookup, out_dir):
# Dump the generated .rst files for a vnd2bindings dict.
# Files are only written if they are changed. Existing .rst
# files which would not be written by the 'vnd2bindings'
# dict are removed.
out_dir = Path(out_dir)
setup_bindings_dir(bindings, out_dir)
write_bindings_rst(vnd_lookup, out_dir)
write_orphans(bindings, base_binding, vnd_lookup, out_dir)
def setup_bindings_dir(bindings, out_dir):
# Make a set of all the Path objects we will be creating for
# out_dir / bindings / {binding_path}.rst. Delete all the ones that
# shouldn't be there. Make sure the bindings output directory
# exists.
paths = set()
bindings_dir = out_dir / 'bindings'
logger.info('making output subdirectory %s', bindings_dir)
bindings_dir.mkdir(parents=True, exist_ok=True)
for binding in bindings:
paths.add(bindings_dir / binding_filename(binding))
for dirpath, _, filenames in os.walk(bindings_dir):
for filename in filenames:
path = Path(dirpath) / filename
if path not in paths:
logger.info('removing unexpected file %s', path)
path.unlink()
def write_bindings_rst(vnd_lookup, out_dir):
# Write out_dir / bindings.rst, the top level index of bindings.
string_io = io.StringIO()
print_block(f'''\
.. _devicetree_binding_index:
Bindings index
##############
This page documents the available devicetree bindings.
See {zref('dt-bindings')} for an introduction to the Zephyr bindings
file format.
Vendor index
************
This section contains an index of hardware vendors.
Click on a vendor's name to go to the list of bindings for
that vendor.
.. rst-class:: rst-columns
''', string_io)
for vnd in vnd_lookup.vnd2bindings:
print(f'- :ref:`{vnd_lookup.target(vnd)}`', file=string_io)
print_block('''\
Bindings by vendor
******************
This section contains available bindings, grouped by vendor.
Within each group, bindings are listed by the "compatible" property
they apply to, like this:
**Vendor name (vendor prefix)**
.. rst-class:: rst-columns
- <compatible-A>
- <compatible-B> (on <bus-name> bus)
- <compatible-C>
- ...
The text "(on <bus-name> bus)" appears when bindings may behave
differently depending on the bus the node appears on.
For example, this applies to some sensor device nodes, which may
appear as children of either I2C or SPI bus nodes.
''', string_io)
for vnd, bindings in vnd_lookup.vnd2bindings.items():
if isinstance(vnd, tuple):
title = vnd[0]
else:
title = vnd_lookup.vendor(vnd).strip()
if isinstance(vnd, str):
title += f' ({vnd})'
underline = '=' * len(title)
print_block(f'''\
.. _{vnd_lookup.target(vnd)}:
{title}
{underline}
.. rst-class:: rst-columns
''', string_io)
for binding in bindings:
print(f'- :ref:`{binding_ref_target(binding)}`', file=string_io)
print(file=string_io)
write_if_updated(out_dir / 'bindings.rst', string_io.getvalue())
def write_orphans(bindings, base_binding, vnd_lookup, out_dir):
# Write out_dir / bindings / foo / binding_page.rst for each binding
# in 'bindings', along with any "disambiguation" pages needed when a
# single compatible string can be handled by multiple bindings.
#
# These files are 'orphans' in the Sphinx sense: they are not in
# any toctree.
logging.info('updating :orphan: files for %d bindings', len(bindings))
num_written = 0
# First, figure out which compatibles map to multiple bindings. We
# need this information to decide which of the generated files for
# a compatible are "disambiguation" pages that point to per-bus
# binding pages, and which ones aren't.
compat2bindings = defaultdict(list)
for binding in bindings:
compat2bindings[binding.compatible].append(binding)
dup_compat2bindings = {compatible: bindings for compatible, bindings
in compat2bindings.items() if len(bindings) > 1}
# Next, write the per-binding pages. These contain the
# per-compatible targets for compatibles not in 'dup_compats'.
# We'll finish up by writing per-compatible "disambiguation" pages
# for copmatibles in 'dup_compats'.
# Names of properties in base.yaml.
base_names = set(base_binding.prop2specs.keys())
for binding in bindings:
string_io = io.StringIO()
print_binding_page(binding, base_names, vnd_lookup,
dup_compat2bindings, string_io)
written = write_if_updated(out_dir / 'bindings' /
binding_filename(binding),
string_io.getvalue())
if written:
num_written += 1
# Generate disambiguation pages for dup_compats.
compatibles_dir = out_dir / 'compatibles'
setup_compatibles_dir(dup_compat2bindings.keys(), compatibles_dir)
for compatible in dup_compat2bindings:
string_io = io.StringIO()
print_compatible_disambiguation_page(
compatible, dup_compat2bindings[compatible], string_io)
written = write_if_updated(compatibles_dir /
compatible_filename(compatible),
string_io.getvalue())
if written:
num_written += 1
logging.info('done writing :orphan: files; %d files needed updates',
num_written)
def print_binding_page(binding, base_names, vnd_lookup, dup_compats,
string_io):
# Print the rst content for 'binding' to 'string_io'. The
# 'dup_compats' argument should support membership testing for
# compatibles which have multiple associated bindings; if
# 'binding.compatible' is not in it, then the ref target for the
# entire compatible is generated in this page as well.
# :orphan:
#
# .. ref_target:
#
# Title [(on <bus> bus)]
# ######################
if binding.on_bus:
on_bus_title = f' (on {binding.on_bus} bus)'
else:
on_bus_title = ''
compatible = binding.compatible
title = f'{compatible}{on_bus_title}'
underline = '#' * len(title)
if compatible not in dup_compats:
# If this binding is the only one that handles this
# compatible, point the ".. dtcompatible:" directive straight
# to this page. There's no need for disambiguation.
dtcompatible = f'.. dtcompatible:: {binding.compatible}'
else:
# This compatible is handled by multiple bindings;
# its ".. dtcompatible::" should be in a disambiguation page
# instead.
dtcompatible = ''
print_block(f'''\
:orphan:
.. raw:: html
<!--
FIXME: do not limit page width until content uses another representation
format other than tables
-->
<style>.wy-nav-content {{ max-width: none; !important }}</style>
{dtcompatible}
.. _{binding_ref_target(binding)}:
{title}
{underline}
''', string_io)
# Vendor: <link-to-vendor-section>
vnd = compatible_vnd(compatible)
print('Vendor: '
f':ref:`{vnd_lookup.vendor(vnd)} <{vnd_lookup.target(vnd)}>`\n',
file=string_io)
# Binding description.
if binding.bus:
bus_help = f'These nodes are "{binding.bus}" bus nodes.'
else:
bus_help = ''
print_block(f'''\
Description
***********
{bus_help}
''', string_io)
print(to_code_block(binding.description.strip()), file=string_io)
# Properties.
print_block('''\
Properties
**********
''', string_io)
print_top_level_properties(binding, base_names, string_io)
print_child_binding_properties(binding, string_io)
# Specifier cells.
#
# This presentation isn't particularly nice. Perhaps something
# better can be done for future work.
if binding.specifier2cells:
print_block('''\
Specifier cell names
********************
''', string_io)
for specifier, cells in binding.specifier2cells.items():
print(f'- {specifier} cells: {", ".join(cells)}',
file=string_io)
def print_top_level_properties(binding, base_names, string_io):
# Print the RST for top level properties for 'binding' to 'string_io'.
#
# The 'base_names' set contains all the base.yaml properties.
def prop_table(filter_fn, deprecated):
# Get a properly formatted and indented table of properties.
specs = [prop_spec for prop_spec in binding.prop2specs.values()
if filter_fn(prop_spec)]
indent = ' ' * 14
if specs:
temp_io = io.StringIO()
print_property_table(specs, temp_io, deprecated=deprecated)
return textwrap.indent(temp_io.getvalue(), indent)
return indent + '(None)'
def node_props_filter(prop_spec):
return prop_spec.name not in base_names and not prop_spec.deprecated
def deprecated_node_props_filter(prop_spec):
return prop_spec.name not in base_names and prop_spec.deprecated
def base_props_filter(prop_spec):
return prop_spec.name in base_names
if binding.child_binding:
print_block('''\
Top level properties
====================
''', string_io)
if binding.prop2specs:
if binding.child_binding:
print_block(f'''
These property descriptions apply to "{binding.compatible}"
nodes themselves. This page also describes child node
properties in the following sections.
''', string_io)
print_block(f'''\
.. tabs::
.. group-tab:: Node specific properties
Properties not inherited from the base binding file.
{prop_table(node_props_filter, False)}
.. group-tab:: Deprecated node specific properties
Deprecated properties not inherited from the base binding file.
{prop_table(deprecated_node_props_filter, False)}
.. group-tab:: Base properties
Properties inherited from the base binding file, which defines
common properties that may be set on many nodes. Not all of these
may apply to the "{binding.compatible}" compatible.
{prop_table(base_props_filter, True)}
''', string_io)
else:
print('No top-level properties.\n', file=string_io)
def print_child_binding_properties(binding, string_io):
# Prints property tables for all levels of nesting of child
# bindings.
level = 1
child = binding.child_binding
while child is not None:
if level == 1:
level_string = 'Child'
elif level == 2:
level_string = 'Grandchild'
else:
level_string = f'Level {level} child'
if child.prop2specs:
title = f'{level_string} node properties'
underline = '=' * len(title)
print(f'{title}\n{underline}\n', file=string_io)
print_property_table(child.prop2specs.values(), string_io,
deprecated=True)
child = child.child_binding
level += 1
def print_property_table(prop_specs, string_io, deprecated=False):
# Writes a table of properties based on 'prop_specs', an iterable
# of edtlib.PropertySpec objects, to 'string_io'.
#
# If 'deprecated' is true and the property is deprecated, an extra
# line is printed mentioning that fact. We allow this to be turned
# off for tables where all properties are deprecated, so it's
# clear from context.
# Table header.
print_block('''\
.. list-table::
:widths: 1 1 4
:header-rows: 1
* - Name
- Type
- Details
''', string_io)
def to_prop_table_row(prop_spec):
# Get a multiline string for a PropertySpec table row.
# The description column combines the description field,
# along with things like the default value or enum values.
#
# The property 'description' field from the binding may span
# one or multiple lines. We try to come up with a nice
# presentation for each.
details = ''
raw_prop_descr = prop_spec.description
if raw_prop_descr:
details += to_code_block(raw_prop_descr)
if prop_spec.required:
details += '\n\nThis property is **required**.'
if prop_spec.default:
details += f'\n\nDefault value: ``{prop_spec.default}``'
if prop_spec.const:
details += f'\n\nConstant value: ``{prop_spec.const}``'
elif prop_spec.enum:
details += ('\n\nLegal values: ' +
', '.join(f'``{repr(val)}``' for val in
prop_spec.enum))
if prop_spec.name in DETAILS_IN_IMPORTANT_PROPS:
details += (f'\n\nSee {zref("dt-important-props")} for more '
'information.')
if deprecated and prop_spec.deprecated:
details += '\n\nThis property is **deprecated**.'
return f"""\
* - ``{prop_spec.name}``
- ``{prop_spec.type}``
- {textwrap.indent(details, ' ' * 7).lstrip()}
"""
# Print each row.
for prop_spec in prop_specs:
print(to_prop_table_row(prop_spec), file=string_io)
def setup_compatibles_dir(compatibles, compatibles_dir):
# Make a set of all the Path objects we will be creating for
# out_dir / copmatibles / {compatible_path}.rst. Delete all the ones that
# shouldn't be there. Make sure the compatibles output directory
# exists.
logger.info('making output subdirectory %s', compatibles_dir)
compatibles_dir.mkdir(parents=True, exist_ok=True)
paths = set(compatibles_dir / compatible_filename(compatible)
for compatible in compatibles)
for path in compatibles_dir.iterdir():
if path not in paths:
logger.info('removing unexpected file %s', path)
path.unlink()
def print_compatible_disambiguation_page(compatible, bindings, string_io):
# Print the disambiguation page for 'compatible', which can be
# handled by any of the bindings in 'bindings', to 'string_io'.
assert len(bindings) > 1, (compatible, bindings)
underline = '#' * len(compatible)
output_list = '\n '.join(f'- :ref:`{binding_ref_target(binding)}`'
for binding in bindings)
print_block(f'''\
:orphan:
.. dtcompatible:: {compatible}
{compatible}
{underline}
The devicetree compatible ``{compatible}`` may be handled by any
of the following bindings:
{output_list}
''', string_io)
def print_block(block, string_io):
# Helper for dedenting and printing a triple-quoted RST block.
# (Just a block of text, not necessarily just a 'code-block'
# directive.)
print(textwrap.dedent(block), file=string_io)
def to_code_block(s, indent=0):
# Converts 's', a string, to an indented rst .. code-block::. The
# 'indent' argument is a leading indent for each line in the code
# block, in spaces.
indent = indent * ' '
return ('.. code-block:: none\n\n' +
textwrap.indent(s, indent + ' ') + '\n')
def compatible_vnd(compatible):
# Get the vendor prefix for a compatible string 'compatible'.
#
# For example, compatible_vnd('foo,device') is 'foo'.
#
# If 'compatible' has no comma (','), None is returned.
if ',' not in compatible:
return None
return compatible.split(',', 1)[0]
def compatible_filename(compatible):
# Name of the per-compatible disambiguation page within the
# out_dir / compatibles directory.
return f'{compatible}.rst'
def zref(target, text=None):
# Make an appropriate RST :ref:`text <target>` or :ref:`target`
# string to a zephyr documentation ref target 'target', and return
# it.
#
# By default, the bindings docs are in the main Zephyr
# documentation, but this script supports putting them in a
# separate Sphinx doc set. Since we also link to Zephyr
# documentation from the generated content, we have an environment
# variable based escape hatch for putting the target in the zephyr
# doc set.
#
# This relies on intersphinx:
# https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html
docset = os.environ.get('GEN_DEVICETREE_REST_ZEPHYR_DOCSET', '')
if docset.strip():
target = f'{docset}:{target}'
if text:
return f':ref:`{text} <{target}>`'
return f':ref:`{target}`'
def binding_filename(binding):
# Returns the output file name for a binding relative to the
# directory containing documentation for all bindings. It does
# this by stripping off the '.../dts/bindings/' prefix common to
# all bindings files in a DTS_ROOT directory.
#
# For example, for .../zephyr/dts/bindings/base/base.yaml, this
# would return 'base/base.yaml'.
#
# Hopefully that's unique across roots. If not, we'll need to
# update this function.
as_posix = Path(binding.path).as_posix()
dts_bindings = 'dts/bindings/'
idx = as_posix.rfind(dts_bindings)
if idx == -1:
raise ValueError(f'binding path has no {dts_bindings}: {binding.path}')
# Cut past dts/bindings, strip off the .yaml, and replace with
# .rst.
return as_posix[idx + len(dts_bindings):-4] + 'rst'
def binding_ref_target(binding):
# Return the sphinx ':ref:' target name for a binding.
stem = Path(binding.path).stem
return 'dtbinding_' + re.sub('[/,-]', '_', stem)
def write_if_updated(path, s):
# gen_helpers.write_if_updated() wrapper that handles logging and
# creating missing parents, as needed.
if not path.parent.is_dir():
path.parent.mkdir(parents=True)
written = gen_helpers.write_if_updated(path, s)
logger.debug('%s %s', 'wrote' if written else 'did NOT write', path)
return written
if __name__ == '__main__':
main()
sys.exit(0)