Skip to content

Instantly share code, notes, and snippets.

@awstanley
Created August 9, 2022 09:16
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save awstanley/e2a20979a7365fdd852b2b8bc2e516db to your computer and use it in GitHub Desktop.
Save awstanley/e2a20979a7365fdd852b2b8bc2e516db to your computer and use it in GitHub Desktop.
Sphinx extension for ESPHome documentation.
"""
ESPHome Sphinx Extension.
This extension provides ESPHome documentation specific. While it may be a good starting
point for other projects, the esoteric nature of the included code is designed to assist
in automating the linking and indexing of components, devices, and blueprints.
Because Sphinx internals are foreign to most people looking through this, the documentation
in this file will verbose. (Future me thanks you for leaving them here too.)
Quick and dirty usage guide:
* Component support:
* Definition: `.. esphome::component-definition::`
* Reference: `:esphome:component:`component-category:component-alias``
* Index: `.. esphome:index:: Components`
* Device support:
* Definition: `.. esphome::device-definition::`
* Reference: `:esphome:device:`device-category:device-alias``
* Index: `.. esphome:index:: Devices`
* Cookbook support:
* Definition: `.. esphome::cookbook-entry::`
* Reference: `:esphome:recipe:`ble-itag``
* Index: `.. esphome:index:: Cookbook`
Further notes:
* `sphinx.addnodes.pending_xref` doesn't quite solve the problem due to domain data;
* TOC image parsing is limited to things in /images/<name>.
"""
from sphinx.transforms import SphinxContentsFilter
from os import path
import os
from docutils.parsers.rst.states import Inliner
from docutils.parsers.rst import Directive, directives
from docutils.nodes import Element, Node, system_message
from docutils import nodes, utils
from multiprocessing.sharedctypes import Value
from itertools import zip_longest
from sphinx.domains import Domain
from sphinx.environment.adapters.toctree import TocTree
from sphinx.roles import XRefRole
from sphinx.util.docutils import SphinxDirective, SphinxRole
from sphinx.util.nodes import make_refnode
from sphinx.util import logging
from sphinx import addnodes
from typing import (TYPE_CHECKING, Dict, List, Tuple, Type, TypeVar, cast)
N = TypeVar('N')
if TYPE_CHECKING:
from sphinx.environment import BuildEnvironment
from sphinx.builders import Builder
from sphinx.config import Config
# Enable proper logging instead of just using print all the time.
logger = logging.getLogger(__name__)
"""
Constants are defined here to keep things cleaner internally.
The aim is to remove the potential for mistakes in copying.
"""
# Common
_ESPHOME_DOMAIN = 'esphome' # .. <domain>:
_INDEX_DIRECTIVE = 'index' # Index directive (common)
_ALIAS_KEY = 'alias'
_DOCNAME_KEY = 'docname'
_DISPNAME_KEY = 'dispname'
_DESCRIPTOR_KEY = 'descriptor' # Descriptor
_TOC_KEY = 'toc'
_DOMAIN_KEY = 'domain'
_CATEGORY_KEY = 'category'
_SECTION_KEY = 'section'
_FRIENDLY_NAME = 'friendly_name' # Friendly name
_TOC_GROUP = 'toc_group' # Table of Contents group.
_TOC_IMAGE = 'toc_image' # Table of Contents image
# Configuration/Env work used by this extension.
_ENV_CONFIGURATION_KEY = 'esphome'
# Component related
_COMPONENT_DIRECTIVE_DEFINE = 'component-definition'
_COMPONENT_DIRECTIVE = 'component'
_COMPONENT_KEY = 'component'
_COMPONENTS_KEY = 'components' # Storage category
_COMPONENTS_TOC_KEY = 'components_toc' # Storage category
# Device related
_DEVICE_DIRECTIVE_DEFINE = 'device-definition'
_DEVICE_DIRECTIVE = 'device'
_DEVICE_KEY = 'device' # Storage category
_DEVICE_TOC_KEY = 'device_toc' # Storage category
# Cookbook related
_COOKBOOK_KEY = 'cookbook' # Storage key
_COOKBOOK_DIRECTIVE_DEFINE = 'cookbook-entry'
_COOKBOOK_DIRECTIVE = 'recipe'
_COOKBOOK_DEVICES = 'devices'
_COOKBOOK_COMPONENTS = 'components'
_COOKBOOK_TABLE_KEY = 'Cookbook'
_RECIPE_DEVICES_HEADING = 'Devices used in this Recipe'
_RECIPE_COMPONENTS_HEADING = 'Components used in this Recipe'
# Dummy element for fast searching.
class esphome_node(nodes.Inline, nodes.TextElement):
pass
def visit_esphome_node(self, node):
pass
def depart_esphome_node(self, node):
pass
def _image_table_generator(env, docname, builder, cols, data_key, category_key):
"""Single category table generator."""
# Domain (usually 'esphome')
domain = env.get_domain(_ESPHOME_DOMAIN)
# For this to work it uses a {cat: {name: obj}} structure.
data_parent = domain.data[data_key]
data = []
if category_key == None:
data = data_parent # Cookbook
else:
if category_key not in data_parent:
return []
data = data_parent[category_key]
data_keys = list(data.keys())
data_keys.sort()
items = []
for key in data_keys:
row = data[key]
name = row[_FRIENDLY_NAME]
page = row[_DOCNAME_KEY]
image = row[_TOC_IMAGE]
item = {
"name": name.strip(),
"link": builder.get_relative_uri(docname, page),
"image": "/images/{}".format(image),
}
if _DESCRIPTOR_KEY in row:
item["category"] = row[_DESCRIPTOR_KEY]
else:
item["category"] = ""
items.append(item)
table = nodes.table()
table["classes"].append("table-center")
table["classes"].append("colwidths-given")
# Set up column specifications based on widths
tgroup = nodes.tgroup(cols=cols)
table += tgroup
tgroup.extend(nodes.colspec(colwidth=1) for _ in range(cols))
tbody = nodes.tbody()
tgroup += tbody
rows = []
for value in zip_longest(fillvalue=None, *([iter(items)] * cols)):
trow = nodes.row()
for cell in value:
entry = nodes.entry()
if cell is None:
entry += nodes.paragraph()
trow += entry
continue
name = cell["name"]
link = cell["link"]
image = cell["image"]
reference_node = nodes.reference(refuri=link)
img = nodes.image(uri=directives.uri(image), alt=name)
img["classes"].append("component-image")
reference_node += img
para = nodes.paragraph()
para += reference_node
entry += para
trow += entry
rows.append(trow)
trow = nodes.row()
for cell in value:
entry = nodes.entry()
if cell is None:
entry += nodes.paragraph()
trow += entry
continue
name = cell["name"]
link = cell["link"]
ref = nodes.reference(name, name, refuri=link)
para = nodes.paragraph()
para += ref
entry += para
cat_text = cell["category"]
if cat_text:
cat = nodes.paragraph(text=cat_text)
entry += cat
trow += entry
rows.append(trow)
tbody.extend(rows)
return [table]
_TABLE_TYPES = {
'Devices': {
_DOMAIN_KEY: _ESPHOME_DOMAIN,
_TOC_KEY: _DEVICE_TOC_KEY,
_CATEGORY_KEY: _CATEGORY_KEY,
_SECTION_KEY: 'devices',
},
'Components': {
_DOMAIN_KEY: _ESPHOME_DOMAIN,
_TOC_KEY: _COMPONENTS_TOC_KEY,
_CATEGORY_KEY: _CATEGORY_KEY,
_SECTION_KEY: 'components',
},
_COOKBOOK_TABLE_KEY: {
_DOMAIN_KEY: _ESPHOME_DOMAIN,
_TOC_KEY: _COOKBOOK_KEY,
_SECTION_KEY: 'cookbook'
}
}
class IndexTableDirective(SphinxDirective):
has_content = True
required_arguments = 1
option_spec = {
# Columns
"columns": directives.positive_int,
# Friendly name is required
_TOC_GROUP: directives.unchanged,
}
def run(self):
arg0 = self.arguments[0]
if arg0 not in _TABLE_TYPES:
raise AttributeError("IndexTableDirective requires a type.")
cols = self.options.get("columns", 3)
toc_group = ''
attributes = {
'esphome': 'image_table',
'table_type': arg0,
'columns': cols,
}
if _TOC_GROUP in self.options:
toc_group = self.options.get(_TOC_GROUP, '')
if toc_group != '':
attributes['toc_group'] = toc_group
targetnode = esphome_node('', **attributes)
return [targetnode]
def _common_role_worker(category, instance, subdomain, domain=_ESPHOME_DOMAIN):
attributes = {
'esphome': 'role',
'subdomain': subdomain,
'category': category,
'instance': instance,
}
targetnode = esphome_node('', **attributes)
return [targetnode], []
class ComponentRole(SphinxRole):
"""A custom role for enabling cross-reference work for ESPHome components."""
# Sphinx doesn't provide env or state to SphinxRole.
def __call__(self, name: str, rawtext: str, text: str, lineno: int,
inliner: Inliner, options: Dict = {}, content: List[str] = []
) -> Tuple[List[Node], List[system_message]]:
fragments = text.split(":") # category, target
if len(fragments) == 2:
return _common_role_worker(fragments[0], fragments[1], _COMPONENT_KEY, _ESPHOME_DOMAIN)
else:
raise ValueError("fragment length should be exactly 2")
class DeviceRole(SphinxRole):
"""A custom role for enabling cross-reference work for ESPHome devices."""
def __call__(self, name: str, rawtext: str, text: str, lineno: int,
inliner: Inliner, options: Dict = {}, content: List[str] = []
) -> Tuple[List[Node], List[system_message]]:
fragments = text.split(":") # category, target
if len(fragments) == 2:
return _common_role_worker(fragments[0], fragments[1], _DEVICE_KEY, _ESPHOME_DOMAIN)
else:
raise ValueError("fragment length should be exactly 2")
class CookbookRole(SphinxRole):
"""A custom role for enabling cross-reference work for ESPHome recipes/cookbook entries."""
def __call__(self, name: str, rawtext: str, text: str, lineno: int,
inliner: Inliner, options: Dict = {}, content: List[str] = []
) -> Tuple[List[Node], List[system_message]]:
# Nothing remotely special, just the name of the recipe.
return _common_role_worker('', text.strip().rstrip(), _COOKBOOK_KEY, _ESPHOME_DOMAIN)
class CookbookDefinitionDirective(Directive):
"""A custom directive for handling cookbook/recipe additions.
By way of example:
```rst
.. esphome:cookbook-entry::
:alias: ble-itag
:friendly_name: ESP32 BLE iTag Button
:toc_image: esp32_ble_itag.jpg
:devices: espressif:esp32
:components: core:ble-client,sensor:ble-client
```
This should be put at the bottom of the page, below `See Also`.
It will turn into a list containing devices and components links.
"""
has_content = True
required_arguments = 0
optional_arguments = 0
option_spec = {
# Aliasing key
_ALIAS_KEY: directives.unchanged_required,
# Display name
_FRIENDLY_NAME: directives.unchanged_required,
# List group (programmatic alias) - optional here
_TOC_GROUP: directives.unchanged,
# Image for TOC
_TOC_IMAGE: directives.unchanged_required,
# Optional comma separated list of devices in the form of:
# <device_category>:<device_alias>
_COOKBOOK_DEVICES: directives.unchanged,
# Optional comma separated list of components in the form of:
# <component_category>:<component_alias>
_COOKBOOK_COMPONENTS: directives.unchanged,
}
def run(self):
if _ALIAS_KEY in self.options\
and _FRIENDLY_NAME in self.options\
and _TOC_IMAGE in self.options:
# Alias is used for attributes
alias = self.options.get(_ALIAS_KEY).strip().rstrip()
# Get the ESPHome domain.
esphome = self.state.document.settings.env.get_domain(_ESPHOME_DOMAIN)
devices_raw = self.options.get(_COOKBOOK_DEVICES, '')
devices = []
if devices_raw != '':
for device in devices_raw.split(','):
devices.append(device.strip().rstrip())
components_raw = self.options.get(_COOKBOOK_COMPONENTS, '')
components = []
if components_raw != '':
for component in components_raw.split(','):
components.append(component.strip().rstrip())
# TOC Group is used for attributes
toc_group = self.options.get(_TOC_GROUP, '').strip().rstrip()
# Add the component (paranoid strip and rstrip)
esphome.add_cookbook_recipe(alias,
self.options.get(_FRIENDLY_NAME).strip().rstrip(),
toc_group,
self.options.get(_TOC_IMAGE).strip().rstrip(),
devices,
components
)
# Store an entry for replacement.
attributes = {
'esphome': 'cookbook',
'category': toc_group,
'instance': alias,
}
return [esphome_node('', **attributes)]
else:
return []
class ComponentDefinitionDirective(Directive):
"""A custom directive for handling component addition.
This directive is within the `esphome` domain.
By way of example:
```rst
.. esphome:component-definition::
:alias: analog_threshold
:category: binary_sensor
:friendly_name: Analog Threshold
:toc_group: Binary Sensor
:toc_image: analog_threshold.svg
```
The TOC group is the 'pretty' name.
"""
has_content = True
required_arguments = 0
optional_arguments = 0
option_spec = {
# Aliasing key
_ALIAS_KEY: directives.unchanged_required,
# Type (subdirectory/path)
_CATEGORY_KEY: directives.unchanged_required,
# Display name
_FRIENDLY_NAME: directives.unchanged_required,
# List group (programmatic alias)
_TOC_GROUP: directives.unchanged_required,
# Image for TOC
_TOC_IMAGE: directives.unchanged_required,
# Descriptor ('category' under the old system)
# e.g. 'Temperature'
_DESCRIPTOR_KEY: directives.unchanged,
}
def run(self):
if _ALIAS_KEY in self.options\
and _CATEGORY_KEY in self.options\
and _FRIENDLY_NAME in self.options\
and _TOC_GROUP in self.options\
and _TOC_IMAGE in self.options:
# Get the ESPHome domain.
esphome = self.state.document.settings.env.get_domain(_ESPHOME_DOMAIN)
# Add the component (paranoid strip and rstrip)
esphome.add_component(self.options.get(_ALIAS_KEY).strip().rstrip(),
self.options.get(_CATEGORY_KEY).strip().rstrip(),
self.options.get(_FRIENDLY_NAME).strip().rstrip(),
self.options.get(_TOC_GROUP).strip().rstrip(),
self.options.get(_TOC_IMAGE).strip().rstrip(),
self.options.get(_DESCRIPTOR_KEY, '').strip().rstrip()
)
return []
class DeviceDefinitionDirective(Directive):
"""A custom component for handling device addition.
This directive is within the `esphome` domain.
By way of example:
```rst
.. esphome:device-definition::
:alias: esp32
:category: espressif
:friendly_name: Generic ESP32
:toc_group: Espressif
:toc_image: esp32.svg
```
The TOC group is the 'pretty' name.
"""
has_content = True
required_arguments = 0
optional_arguments = 0
option_spec = {
# Aliasing key
_ALIAS_KEY: directives.unchanged_required,
# Type (subdirectory/path)
_CATEGORY_KEY: directives.unchanged_required,
# Display name
_FRIENDLY_NAME: directives.unchanged_required,
# List group (programmatic alias)
_TOC_GROUP: directives.unchanged_required,
# Image for TOC
_TOC_IMAGE: directives.unchanged_required,
# Descriptor ('category' under the old system)
# e.g. 'Temperature'
_DESCRIPTOR_KEY: directives.unchanged,
}
def run(self):
if _ALIAS_KEY in self.options\
and _CATEGORY_KEY in self.options\
and _FRIENDLY_NAME in self.options\
and _TOC_GROUP in self.options\
and _TOC_IMAGE in self.options:
# Get the ESPHome domain.
esphome = self.state.document.settings.env.get_domain(_ESPHOME_DOMAIN)
# Add the component (paranoid strip and rstrip)
esphome.add_device(self.options.get(_ALIAS_KEY).strip().rstrip(),
self.options.get(_CATEGORY_KEY).strip().rstrip(),
self.options.get(_FRIENDLY_NAME).strip().rstrip(),
self.options.get(_TOC_GROUP).strip().rstrip(),
self.options.get(_TOC_IMAGE).strip().rstrip(),
self.options.get(_DESCRIPTOR_KEY, '').strip().rstrip(),
)
return []
class ESPHomeDomain(Domain):
"""A custom domain to handle ESPHome documentation needs."""
name = _ESPHOME_DOMAIN
label = 'ESPHome Component'
roles = {
'ref': XRefRole(),
_COMPONENT_DIRECTIVE: ComponentRole(),
_DEVICE_DIRECTIVE: DeviceRole(),
_COOKBOOK_DIRECTIVE: CookbookRole(),
}
directives = {
_COMPONENT_DIRECTIVE_DEFINE: ComponentDefinitionDirective,
_COOKBOOK_DIRECTIVE_DEFINE: CookbookDefinitionDirective,
_DEVICE_DIRECTIVE_DEFINE: DeviceDefinitionDirective,
_INDEX_DIRECTIVE: IndexTableDirective,
}
indices = {}
initial_data = {
# Components: category -> {name -> object}
_COMPONENTS_KEY: {}, # Useful for subpage filtration.
# Components: toc-category -> {name -> object}
_COMPONENTS_TOC_KEY: {},
# Devices: device_path -> {name -> object}
_DEVICE_KEY: {}, # Useful for subpage filtration.
# Devices: device_path -> {name -> object}
_DEVICE_TOC_KEY: {},
# Cookbook: recipe -> information.
_COOKBOOK_KEY: {},
}
def get_full_qualified_name(self, node):
"""Gets the path to the internally registered type.
This needs to be validated if the system is expanded.
"""
return '{}.{}'.format(_ESPHOME_DOMAIN, node.arguments[0])
def get_objects(self):
"""Required for search functionality, so must yield appropriately or it all breaks."""
components_by_category = self.data[_COMPONENTS_KEY]
for category_key in components_by_category:
components = components_by_category[category_key]
component_keys = list(components.keys())
component_keys.sort()
for component_key in component_keys:
component = components[component_key]
# fullname, dispname, type, docname, anchor, prio
yield((
component[_ALIAS_KEY], # fullname
component[_DISPNAME_KEY], # dispname
_COMPONENT_KEY, # type
component[_DOCNAME_KEY], # docname
component[_ALIAS_KEY].replace("_", "-").replace(" ", "-"), # anchor
0, # priority of 0
))
devices_by_type = self.data[_DEVICE_KEY]
for category_key in devices_by_type:
devices = devices_by_type[category_key]
device_keys = list(devices.keys())
device_keys.sort()
for device_key in device_keys:
device = devices[device_key]
# fullname, dispname, type, docname, anchor, prio
yield((
device[_ALIAS_KEY], # fullname
device[_DISPNAME_KEY], # dispname
_DEVICE_KEY, # type
device[_DOCNAME_KEY], # docname
device[_ALIAS_KEY].replace("_", "-").replace(" ", "-"), # anchor
0, # priority of 0
))
def resolve_xref(self, env, fromdocname, builder, typ, target, node,
contnode):
match = [(docname, anchor)
for name, sig, typ, docname, anchor, prio
in self.get_objects() if sig == target]
if len(match) > 0:
todocname = match[0][0]
targ = match[0][1]
return make_refnode(builder, fromdocname, todocname, targ,
contnode, targ)
else:
return None
def add_component(self, alias, component_type, friendly_name, toc_group, toc_image, descriptor):
"""Add a component to the domain."""
# Build the component.
component = {
_DISPNAME_KEY: '{}.{}'.format('component', alias),
_ALIAS_KEY: alias,
_CATEGORY_KEY: component_type,
_FRIENDLY_NAME: friendly_name,
_TOC_GROUP: toc_group,
_TOC_IMAGE: toc_image,
_DOCNAME_KEY: self.env.docname,
_DESCRIPTOR_KEY: descriptor,
}
# Store the component using default information.
if component_type not in self.data[_COMPONENTS_KEY]:
self.data[_COMPONENTS_KEY][component_type] = {}
self.data[_COMPONENTS_KEY][component_type][alias] = component
# Store the component in the toc_group.
if toc_group not in self.data[_COMPONENTS_TOC_KEY]:
self.data[_COMPONENTS_TOC_KEY][toc_group] = {}
self.data[_COMPONENTS_TOC_KEY][toc_group][alias] = component
def add_device(self, alias, category, friendly_name, toc_group, toc_image, descriptor):
"""Add a device to the domain."""
# Build the device.
device = {
_DISPNAME_KEY: '{}.{}'.format('device', alias),
_ALIAS_KEY: alias,
_CATEGORY_KEY: category,
_FRIENDLY_NAME: friendly_name,
_TOC_GROUP: toc_group,
_TOC_IMAGE: toc_image,
_DOCNAME_KEY: self.env.docname,
_DESCRIPTOR_KEY: descriptor,
}
# Store the component using default information.
if category not in self.data[_DEVICE_KEY]:
self.data[_DEVICE_KEY][category] = {}
self.data[_DEVICE_KEY][category][alias] = device
# Store the component in the toc_group.
if toc_group not in self.data[_DEVICE_TOC_KEY]:
self.data[_DEVICE_TOC_KEY][toc_group] = {}
self.data[_DEVICE_TOC_KEY][toc_group][alias] = device
# TODO: Store by board type and/or chipset?
def add_cookbook_recipe(self, alias, friendly_name, toc_group, toc_image, devices, components):
recipe = {
_DISPNAME_KEY: '{}.{}'.format('recipe', alias),
_ALIAS_KEY: alias,
_FRIENDLY_NAME: friendly_name,
_TOC_GROUP: toc_group,
_TOC_IMAGE: toc_image,
_COOKBOOK_DEVICES: devices,
_DOCNAME_KEY: self.env.docname,
_COOKBOOK_COMPONENTS: components,
}
self.data[_COOKBOOK_KEY][alias] = recipe
# TODO: Attempt back-reference to devices/components?
def _image_post_process(app, doctree, fromdocname):
"""Follow the pattern of the image collector to avoid hacking things up too far.
Remember to add images to the environment before using this or nothing will work.
"""
# Find all images
for node in doctree.findall(nodes.image):
# mimetype = image
# * forces selection (skip)
# ? is for non-local (skip)
candidates: Dict[str, str] = {}
node['candidates'] = candidates
imguri = node['uri']
if imguri.startswith('data:'):
candidates['?'] = imguri
continue
elif imguri.find('://') != -1:
candidates['?'] = imguri
continue
# Get relative form.
node['uri'], _ = app.env.relfn2path(imguri, fromdocname)
candidates['*'] = node['uri']
# Ensure unique name.
for imgpath in candidates.values():
app.env.dependencies[fromdocname].add(imgpath)
if not os.access(path.join(app.srcdir, imgpath), os.R_OK):
logger.warning('image file not readable: %s' % imgpath,
location=node, type='image', subtype='not_readable')
continue
app.env.images.add_file(fromdocname, imgpath)
def _on_doctree_resolved(app, doctree, fromdocname):
"""Activates when the doctree-resolved event/signal is raised.
This is where pending esphome-sphinx nodes are updated against the final doctree.
"""
env = app.builder.env
requires_image_update = False
requires_toc_update = False
domain = env.get_domain(_ESPHOME_DOMAIN)
# Prevent a fault downstream if this is empty.
if not hasattr(env, _ENV_CONFIGURATION_KEY):
setattr(env, _ENV_CONFIGURATION_KEY, [])
# Replace all esphome_node instances.
for node in doctree.findall(esphome_node):
attributes = node.attributes
if 'esphome' in attributes:
requires_toc_update = True
if attributes['esphome'] == 'role':
if attributes['subdomain'] == _COOKBOOK_KEY:
try:
data_collection = domain.data[attributes['subdomain']]
data_instance = data_collection[attributes['instance']]
print(data_instance)
title = data_instance[_FRIENDLY_NAME]
target = app.builder.get_relative_uri(fromdocname, data_instance[_DOCNAME_KEY])
reference_node = nodes.reference(title, utils.unescape(title), refuri=target, **{})
node.replace_self(reference_node)
except KeyError as e:
logger.warning('esphome role node missing key: %s' % e,
location=node, type='esphome-role', subtype='keyerror')
node.replace_self([])
else:
try:
data_collection = domain.data[attributes['subdomain']]
data_category = data_collection[attributes['category']]
data_instance = data_category[attributes['instance']]
title = data_instance[_FRIENDLY_NAME]
target = app.builder.get_relative_uri(fromdocname, data_instance[_DOCNAME_KEY])
reference_node = nodes.reference(title, utils.unescape(title), refuri=target, **{})
node.replace_self(reference_node)
except KeyError as e:
logger.warning('esphome role node missing key: %s' % e,
location=node, type='esphome-role', subtype='keyerror')
node.replace_self([])
elif attributes['esphome'] == 'cookbook':
# Cookbook listing.
replacements = []
# Cookbook
cookbook = domain.data[_COOKBOOK_KEY]
# Get components
# `nodes.list_item` is not an instance of nodes.TextElement, so it will throw
# an error if you remove the nodes.line() (which is the safest TextElement to use).
if attributes['instance'] in cookbook:
recipe = cookbook[attributes['instance']]
if len(recipe[_COOKBOOK_DEVICES]) > 0:
recipe_devices = []
for device in recipe[_COOKBOOK_DEVICES]:
fragments = device.split(":")
if len(fragments) == 2:
if fragments[0] in domain.data[_DEVICE_KEY]:
category = domain.data[_DEVICE_KEY][fragments[0]]
if fragments[1] in category:
recipe_devices.append(category[fragments[1]])
if len(recipe_devices) > 0:
section = nodes.section(ids=['cookbook-definition-devices'])
section += nodes.title(_RECIPE_DEVICES_HEADING, _RECIPE_DEVICES_HEADING)
list_object = nodes.bullet_list()
for device in recipe_devices:
target = app.builder.get_relative_uri(fromdocname, device[_DOCNAME_KEY])
text = nodes.line()
text += nodes.reference(device[_FRIENDLY_NAME], utils.unescape(device[_FRIENDLY_NAME]), refuri=target, **{})
list_item = nodes.list_item()
list_item += text
list_object += list_item
section += list_object
replacements.append(section)
if len(recipe[_COOKBOOK_COMPONENTS]) > 0:
recipe_components = []
for component in recipe[_COOKBOOK_COMPONENTS]:
fragments = component.split(":")
if len(fragments) == 2:
if fragments[0] in domain.data[_COMPONENTS_KEY]:
category = domain.data[_COMPONENTS_KEY][fragments[0]]
if fragments[1] in category:
recipe_components.append(category[fragments[1]])
if len(recipe_components) > 0:
section = nodes.section(ids=['cookbook-definition-components'])
section += nodes.title(_RECIPE_COMPONENTS_HEADING, _RECIPE_COMPONENTS_HEADING)
list_object = nodes.bullet_list()
for component in recipe_components:
target = app.builder.get_relative_uri(fromdocname, component[_DOCNAME_KEY])
text = nodes.line()
text += nodes.reference(component[_FRIENDLY_NAME], utils.unescape(component[_FRIENDLY_NAME]), refuri=target, **{})
list_item = nodes.list_item()
list_item += text
list_object += list_item
section += list_object
replacements.append(section)
node.replace_self(replacements)
elif attributes['esphome'] == 'image_table':
requires_image_update = True
if attributes['table_type'] == _COOKBOOK_TABLE_KEY:
table_type = _TABLE_TYPES[attributes['table_type']]
data_key = table_type[_TOC_KEY] # TOC key
node.replace_self(
_image_table_generator(env,
fromdocname,
app.builder,
attributes['columns'],
data_key,
None)
)
else:
if 'toc_group' in attributes:
table_type = _TABLE_TYPES[attributes['table_type']]
data_key = table_type[_TOC_KEY] # TOC key
node.replace_self(
_image_table_generator(env,
fromdocname,
app.builder,
attributes['columns'],
data_key,
attributes['toc_group'])
)
else:
replacements = []
table_type = _TABLE_TYPES[attributes['table_type']]
data_key = table_type[_TOC_KEY] # TOC key
toc_groups = list(domain.data[data_key].keys())
toc_groups.sort()
for toc_group in toc_groups:
# toc group is not anchor-safe, fix this
anchor_friendly_id = '{}-{}'.format(
table_type[_SECTION_KEY],
toc_group
).lower().replace(' ', '-').replace('_', '-').strip().rstrip() # paranoia
section = nodes.section(ids=[anchor_friendly_id]) # Create a container.
section += nodes.title(toc_group, toc_group)
section += _image_table_generator(env,
fromdocname,
app.builder,
attributes['columns'],
data_key,
toc_group)
replacements.append(section)
node.replace_self(replacements)
else:
# Shouldn't be here.
node.replace_self([])
if requires_image_update:
# If any image work is done, we neded to fix it. This isn't ideal.
_image_post_process(app, doctree, fromdocname)
if requires_toc_update:
# Only slightly tweaked from the Sphinx source:
# sphinx/environment/collectors/toctree.py#L52
numentries = [0]
def traverse_in_section(node: Element, cls: Type[N]) -> List[N]:
"""Like traverse(), but stay within the same section."""
result: List[N] = []
if isinstance(node, cls):
result.append(node)
for child in node.children:
if isinstance(child, nodes.section):
continue
elif isinstance(child, nodes.Element):
result.extend(traverse_in_section(child, cls))
return result
def build_toc(node: Element, depth: int = 1) -> nodes.bullet_list:
entries: List[Element] = []
for sectionnode in node:
if isinstance(sectionnode, nodes.section):
title = sectionnode[0]
visitor = SphinxContentsFilter(doctree)
title.walkabout(visitor)
nodetext = visitor.get_entry_text()
if not numentries[0]:
anchorname = ''
else:
anchorname = '#' + sectionnode['ids'][0]
numentries[0] += 1
reference = nodes.reference(
'', '', internal=True, refuri=fromdocname,
anchorname=anchorname, *nodetext)
para = addnodes.compact_paragraph('', '', reference)
item: Element = nodes.list_item('', para)
sub_item = build_toc(sectionnode, depth + 1)
if sub_item:
item += sub_item
entries.append(item)
elif isinstance(sectionnode, addnodes.only):
onlynode = addnodes.only(expr=sectionnode['expr'])
blist = build_toc(sectionnode, depth)
if blist:
onlynode += blist.children
entries.append(onlynode)
elif isinstance(sectionnode, nodes.Element):
for toctreenode in traverse_in_section(sectionnode,
addnodes.toctree):
item = toctreenode.copy()
entries.append(item)
TocTree(app.env).note(fromdocname, toctreenode)
if entries:
return nodes.bullet_list('', *entries)
return None
toc = build_toc(doctree)
if toc:
app.env.tocs[fromdocname] = toc
else:
app.env.tocs[fromdocname] = nodes.bullet_list('')
app.env.toc_num_entries[fromdocname] = numentries[0]
def setup(app):
# Creates/installs the domain for directives and roles.
app.add_domain(ESPHomeDomain)
app.add_node(esphome_node,
html=(visit_esphome_node, depart_esphome_node),
latex=(visit_esphome_node, depart_esphome_node),
text=(visit_esphome_node, depart_esphome_node))
# Document tree is resolved: now safe to update the stubs.
# https://www.sphinx-doc.org/en/master/extdev/appapi.html#event-doctree-resolved
app.connect('doctree-resolved', _on_doctree_resolved)
return {
'version': '0.1',
'parallel_read_safe': True,
'parallel_write_safe': True,
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment