esphome-docs/schema_doc.py
Guillermo Ruffino c305a836f9
fixes docs for 202310 (#3251)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2023-10-11 00:13:24 +00:00

1276 lines
48 KiB
Python

from genericpath import exists
import re
import json
import urllib
from typing import MutableMapping
from sphinx.util import logging
from docutils import nodes
# Instructions for building
# you must have checked out this project in the same folder of
# esphome and esphome-vscode so the SCHEMA_PATH below can find the source schemas
# This file is not processed by default as extension unless added.
# To add this extension from command line use:
# -Dextensions=github,seo,sitemap,components,schema_doc"
# also for improve performance running old version
# -d_build/.doctrees-schema
# will put caches in another dir and not overwrite the ones without schema
SCHEMA_PATH = "../esphome-vscode/server/src/schema/"
CONFIGURATION_VARIABLES = "Configuration variables:"
CONFIGURATION_OPTIONS = "Configuration options:"
PIN_CONFIGURATION_VARIABLES = "Pin configuration variables:"
COMPONENT_HUB = "Component/Hub"
JSON_DUMP_PRETTY = True
class Statistics:
props_documented = 0
enums_good = 0
enums_bad = 0
statistics = Statistics()
logger = logging.getLogger(__name__)
def setup(app):
import os
if not os.path.isfile(SCHEMA_PATH + "esphome.json"):
logger.info(f"{SCHEMA_PATH} not found. Not documenting schema.")
return
app.connect("doctree-resolved", doctree_resolved)
app.connect("build-finished", build_finished)
app.files = {}
return {"version": "1.0.0", "parallel_read_safe": True, "parallel_write_safe": True}
def find_platform_component(app, platform, component):
file_data = get_component_file(app, component)
return file_data[f"{component}.{platform}"]["schemas"]["CONFIG_SCHEMA"]
def doctree_resolved(app, doctree, docname):
if docname == "components/index":
# nothing useful here
return
handle_component(app, doctree, docname)
PLATFORMS_TITLES = {
"Sensor": "sensor",
"Binary Sensor": "binary_sensor",
"Text Sensor": "text_sensor",
"Output": "output",
"Cover": "cover",
"Button": "button",
"Select": "select",
"Fan": "fan",
"Lock": "lock",
"Number": "number",
"Climate": "climate",
"CAN Bus": "canbus",
"Stepper": "stepper",
"Switch": "switch",
"I²C": "i2c",
"Media Player": "media_player",
"Microphone": "microphone",
"Speaker": "speaker",
"Alarm Control Panel": "alarm_control_panel",
}
CUSTOM_DOCS = {
"guides/automations": {
"Global Variables": "globals.schemas.CONFIG_SCHEMA",
},
"guides/configuration-types": {
"Pin Schema": [
"esp32.pin.schema",
"esp8266.pin.schema",
],
},
"components/binary_sensor/index": {
"Binary Sensor Filters": "binary_sensor.registry.filter",
},
"components/canbus": {
"_LoadSchema": False,
"Base CAN Bus Configuration": "canbus.schemas.CANBUS_SCHEMA",
},
"components/climate/climate_ir": {"_LoadSchema": False, "IR Remote Climate": []},
"components/display/index": {
"Images": "image.schemas.CONFIG_SCHEMA",
"Fonts": "font.schemas.CONFIG_SCHEMA",
"Color": "color.schemas.CONFIG_SCHEMA",
"Animation": "animation.schemas.CONFIG_SCHEMA",
},
"components/light/index": {
"Base Light Configuration": [
"light.schemas.ADDRESSABLE_LIGHT_SCHEMA",
"light.schemas.BINARY_LIGHT_SCHEMA",
"light.schemas.BRIGHTNESS_ONLY_LIGHT_SCHEMA",
"light.schemas.LIGHT_SCHEMA",
],
"Light Effects": "light.registry.effects",
},
"components/light/fastled": {
"_LoadSchema": False,
"Clockless": "fastled_clockless.platform.light.schemas.CONFIG_SCHEMA",
"SPI": "fastled_spi.platform.light.schemas.CONFIG_SCHEMA",
},
"components/binary_sensor/ttp229": {
"_LoadSchema": False,
},
"components/mcp230xx": {
"_LoadSchema": False,
PIN_CONFIGURATION_VARIABLES: "mcp23xxx.pin",
},
"components/mqtt": {
"MQTT Component Base Configuration": "core.schemas.MQTT_COMMAND_COMPONENT_SCHEMA",
"MQTTMessage": "mqtt.schemas.MQTT_MESSAGE_BASE",
},
"components/output/index": {
"Base Output Configuration": "output.schemas.FLOAT_OUTPUT_SCHEMA",
},
"components/remote_transmitter": {
"Remote Transmitter Actions": "remote_base.schemas.BASE_REMOTE_TRANSMITTER_SCHEMA",
},
"components/sensor/index": {
"Sensor Filters": "sensor.registry.filter",
},
"components/time": {
"_LoadSchema": False,
"Base Time Configuration": "time.schemas.TIME_SCHEMA",
"on_time Trigger": "time.schemas.TIME_SCHEMA.schema.config_vars.on_time.schema",
"Home Assistant Time Source": "homeassistant.platform.time.schemas.CONFIG_SCHEMA",
"SNTP Time Source": "sntp.platform.time.schemas.CONFIG_SCHEMA",
"GPS Time Source": "gps.platform.time.schemas.CONFIG_SCHEMA",
"DS1307 Time Source": "ds1307.platform.time.schemas.CONFIG_SCHEMA",
},
"components/wifi": {
"Connecting to Multiple Networks": "wifi.schemas.CONFIG_SCHEMA.schema.config_vars.networks.schema",
"Enterprise Authentication": "wifi.schemas.EAP_AUTH_SCHEMA",
},
"custom/custom_component": {
"Generic Custom Component": "custom_component.schemas.CONFIG_SCHEMA"
},
"components/esp32": {
"Arduino framework": "esp32.schemas.CONFIG_SCHEMA.schema.config_vars.framework.types.arduino",
"ESP-IDF framework": "esp32.schemas.CONFIG_SCHEMA.schema.config_vars.framework.types.esp-idf",
},
"components/sensor/airthings_ble": {
"_LoadSchema": False,
},
"components/sensor/radon_eye_ble": {
"_LoadSchema": False,
},
"components/sensor/xiaomi_ble": {
"_LoadSchema": False,
},
"components/sensor/xiaomi_miscale2": {
"_LoadSchema": False,
},
"components/mcp23Sxx": {
"_LoadSchema": False,
},
"components/display/lcd_display": {"_LoadSchema": False},
"components/display/ssd1306": {"_LoadSchema": False},
"components/display/ssd1322": {"_LoadSchema": False},
"components/display/ssd1325": {"_LoadSchema": False},
"components/display/ssd1327": {"_LoadSchema": False},
"components/display/ssd1351": {"_LoadSchema": False},
"components/copy": {"_LoadSchema": False},
"components/display_menu/index": {
"Display Menu": "display_menu_base.schemas.DISPLAY_MENU_BASE_SCHEMA",
"Select": "display_menu_base.schemas.MENU_TYPES.schema.config_vars.items.types.select",
"Menu": "display_menu_base.schemas.MENU_TYPES.schema.config_vars.items.types.menu",
"Number": "display_menu_base.schemas.MENU_TYPES.schema.config_vars.items.types.number",
"Switch": "display_menu_base.schemas.MENU_TYPES.schema.config_vars.items.types.switch",
"Custom": "display_menu_base.schemas.MENU_TYPES.schema.config_vars.items.types.custom",
},
"components/display_menu/lcd_menu": {
"LCD Menu": "lcd_menu.schemas.CONFIG_SCHEMA",
},
"components/alarm_control_panel/index": {
"Base Alarm Control Panel Configuration": "template.alarm_control_panel.schemas.CONFIG_SCHEMA",
},
"components/vbus": {
"custom VBus sensors": "vbus.platform.sensor.schemas.CONFIG_SCHEMA.types.custom",
"custom VBus binary sensors": "vbus.platform.binary_sensor.schemas.CONFIG_SCHEMA.types.custom",
},
"components/spi": {
"Generic SPI device component:": "spi_device.schemas.CONFIG_SCHEMA"
},
"components/libretiny": {"LibreTiny Platform": "bk72xx.schemas.CONFIG_SCHEMA"},
}
REQUIRED_OPTIONAL_TYPE_REGEX = r"(\(((\*\*Required\*\*)|(\*Optional\*))(,\s(.*))*)\):\s"
def get_node_title(node):
return list(node.traverse(nodes.title))[0].astext()
def read_file(fileName):
f = open(SCHEMA_PATH + fileName + ".json", "r", encoding="utf-8-sig")
str = f.read()
return json.loads(str)
def is_config_vars_title(title_text):
return title_text == CONFIGURATION_VARIABLES or title_text == CONFIGURATION_OPTIONS
class SchemaGeneratorVisitor(nodes.NodeVisitor):
def __init__(self, app, doctree, docname):
nodes.NodeVisitor.__init__(self, doctree)
self.app = app
self.doctree = doctree
self.docname = docname
self.path = docname.split("/")
self.json_component = None
self.props = None
self.platform = None
self.json_platform_component = None
self.title_id = None
self.props_section_title = None
self.find_registry = None
self.component = None
self.section_level = 0
self.file_schema = None
self.custom_doc = CUSTOM_DOCS.get(docname)
if self.path[0] == "components":
if len(self.path) == 2: # root component, e.g. dfplayer, logger
self.component = docname[11:]
if not self.custom_doc or self.custom_doc.get("_LoadSchema", True):
self.file_schema = get_component_file(app, self.component)
self.json_component = self.file_schema[self.component]["schemas"][
"CONFIG_SCHEMA"
]
elif self.path[1] == "display_menu": # weird folder naming
if self.path[2] == "index":
# weird component name mismatch
self.component = "display_menu_base"
else:
self.component = self.path[2]
self.file_schema = get_component_file(app, self.component)
self.json_component = self.file_schema[self.component]["schemas"][
"CONFIG_SCHEMA"
]
else: # sub component, e.g. output/esp8266_pwm
# components here might have a core / hub, eg. dallas, ads1115
# and then they can be a binary_sensor, sensor, etc.
self.platform = self.path[1]
self.component = self.path[2]
if self.component == "ssd1331":
self.component = "ssd1331_spi"
if not self.custom_doc or self.custom_doc.get("_LoadSchema", True):
if self.component == "index":
# these are e.g. sensor, binary sensor etc.
self.component = self.platform.replace(" ", "_").lower()
self.file_schema = get_component_file(app, self.component)
self.json_component = self.file_schema[self.component][
"schemas"
].get(self.component.upper() + "_SCHEMA")
pass
else:
self.json_component = get_component_file(app, self.component)
self.json_platform_component = find_platform_component(
app, self.platform, self.component
)
self.previous_title_text = "No title"
self.is_component_hub = False
# used in custom_docs when titles are mapped to array of components, this
# allows for same configuration text be applied to different json schemas
self.multi_component = None
# a stack for props, used when there are nested props to save high level props.
self.prop_stack = []
# The prop just filled in, used when there are nested props and need to know which
# want to dig
self.current_prop = None
# self.filled_props used to know when any prop is added to props,
# we dont invalidate props on exiting bullet lists but just when entering a new title
self.filled_props = False
# Found a Configuration variables: heading, this is to increase docs consistency
self.accept_props = False
self.bullet_list_level = 0
def set_component_description(self, description, componentName, platformName=None):
if platformName is not None:
platform = get_component_file(self.app, platformName)
platform[platformName]["components"][componentName.lower()][
"docs"
] = description
else:
core = get_component_file(self.app, "esphome")["core"]
if componentName in core["components"]:
core["components"][componentName]["docs"] = description
elif componentName in core["platforms"]:
core["platforms"][componentName]["docs"] = description
else:
if componentName != "display_menu_base":
raise ValueError(
"Cannot set description for component " + componentName
)
def visit_document(self, node):
# ESPHome page docs follows strict formatting guidelines which allows
# for docs to be parsed directly into yaml schema
if self.docname in ["components/sensor/binary_sensor_map"]:
# temporarily not supported
raise nodes.SkipChildren
if self.docname in ["components/climate/climate_ir"]:
# not much to do on the visit to the document, component will be found by title
return
if len(list(node.traverse(nodes.paragraph))) == 0:
# this is empty, not much to do
raise nodes.SkipChildren
self.props_section_title = get_node_title(node)
# Document first paragraph is description of this thing
description = self.getMarkdownParagraph(node)
if self.json_platform_component:
self.set_component_description(description, self.component, self.platform)
elif self.json_component:
self.set_component_description(description, self.component)
# for most components / platforms get the props, this allows for a less restrictive
# first title on the page
if self.json_component or self.json_platform_component:
if is_component_file(
self.app,
self.component,
):
self.props = self.find_props(
self.json_platform_component
if self.json_platform_component
else self.json_component,
True,
)
def visit_table(self, node):
if (
self.docname == "components/climate/climate_ir"
and len(CUSTOM_DOCS["components/climate/climate_ir"]["IR Remote Climate"])
== 0
):
# figure out multi components from table
table_rows = node[0][4]
for row in table_rows:
components_paths = [
components + ".platform.climate.schemas.CONFIG_SCHEMA"
for components in row[1].astext().split("\n")
]
CUSTOM_DOCS["components/climate/climate_ir"][
"IR Remote Climate"
] += components_paths
def depart_document(self, node):
pass
def visit_section(self, node):
self.section_level += 1
section_title = get_node_title(node)
if self.custom_doc and section_title in self.custom_doc:
r = self.custom_doc[section_title]
if ".registry." in r:
self.find_registry = r
def depart_section(self, node):
self.section_level -= 1
if self.section_level == 1:
self.find_registry = None
def unknown_visit(self, node):
pass
def unknown_departure(self, node):
pass
def visit_title(self, node):
title_text = node.astext()
if self.custom_doc is not None and title_text in self.custom_doc:
if isinstance(self.custom_doc[title_text], list):
self.multi_component = self.custom_doc[title_text]
self.filled_props = False
self.props = None
desc = self.getMarkdownParagraph(node.parent)
for c in self.multi_component:
if len(c.split(".")) == 2:
self.set_component_description(desc, c.split(".")[0])
return
json_component = self.find_component(self.custom_doc[title_text])
if not json_component:
return
if self.json_component is None:
self.json_component = json_component
parts = self.custom_doc[title_text].split(".")
if parts[0] not in ["core", "remote_base"] and parts[-1] != "pin":
if parts[1] == "platform":
self.set_component_description(
self.getMarkdownParagraph(node.parent), parts[0], parts[2]
)
else:
self.set_component_description(
self.getMarkdownParagraph(node.parent),
parts[0],
)
self.props_section_title = title_text
self.props = self.find_props(json_component)
return
elif title_text == COMPONENT_HUB:
# here comes docs for the component, make sure we have props of the component
# Needed for e.g. ads1115
self.props_section_title = f"{self.path[-1]} {title_text}"
json_component = self.get_component_schema(
self.path[-1] + ".CONFIG_SCHEMA"
).get("schema", {})
if json_component:
self.props = self.find_props(json_component)
self.set_component_description(
self.getMarkdownParagraph(node.parent), self.path[-1]
)
# mark this to retrieve components instead of platforms
self.is_component_hub = True
elif is_config_vars_title(title_text):
if not self.props and self.multi_component is None:
raise ValueError(
f'Found a "{title_text}": title after {self.previous_title_text}. Unknown object.'
)
elif title_text == "Over SPI" or title_text == "Over I²C":
suffix = "_spi" if "SPI" in title_text else "_i2c"
# these could be platform components, like the display's ssd1306
# but also there are components which are component/hub
# and there are non platform components with the SPI/I2C versions,
# like pn532, those need to be marked with the 'Component/Hub' title
component = self.path[-1] + suffix
self.props_section_title = self.path[-1] + " " + title_text
if self.platform is not None and not self.is_component_hub:
json_platform_component = find_platform_component(
self.app, self.platform, component
)
if not json_platform_component:
raise ValueError(
f"Cannot find platform {self.platform} component '{component}' after found title: '{title_text}'."
)
self.props = self.find_props(json_platform_component)
# Document first paragraph is description of this thing
json_platform_component["docs"] = self.getMarkdownParagraph(node.parent)
else:
json_component = self.get_component_schema(
component + ".CONFIG_SCHEMA"
).get("schema", {})
if not json_component:
raise ValueError(
f"Cannot find component '{component}' after found title: '{title_text}'."
)
self.props = self.find_props(json_component)
# Document first paragraph is description of this thing
self.set_component_description(
self.getMarkdownParagraph(node.parent), component
)
# Title is description of platform component, those ends with Sensor, Binary Sensor, Cover, etc.
elif (
len(
list(
filter(
lambda x: title_text.endswith(x), list(PLATFORMS_TITLES.keys())
)
)
)
> 0
):
if title_text in PLATFORMS_TITLES:
# this omits the name of the component, but we know the platform
platform_name = PLATFORMS_TITLES[title_text]
if self.path[-1] == "index":
component_name = self.path[-2]
else:
component_name = self.path[-1]
self.props_section_title = component_name + " " + title_text
else:
# # title first word is the component name
# component_name = title_text.split(" ")[0]
# # and the rest is the platform
# platform_name = PLATFORMS_TITLES.get(
# title_text[len(component_name) + 1 :]
# )
# if not platform_name:
# # Some general title which does not locate a component directly
# return
# self.props_section_title = title_text
for t in PLATFORMS_TITLES:
if title_text.endswith(t):
component_name = title_text[
0 : len(title_text) - len(t) - 1
].replace(" ", "_")
platform_name = PLATFORMS_TITLES[t]
if not platform_name:
# Some general title which does not locate a component directly
return
self.props_section_title = title_text
if not is_component_file(self.app, component_name):
return
c = find_platform_component(self.app, platform_name, component_name.lower())
if c:
self.json_platform_component = c
self.set_component_description(
self.getMarkdownParagraph(node.parent),
component_name,
platform_name,
)
# Now fill props for the platform element
try:
self.props = self.find_props(self.json_platform_component)
except KeyError:
raise ValueError("Cannot find platform props")
elif title_text.endswith("Component") or title_text.endswith("Bus"):
# if len(path) == 3 and path[2] == 'index':
# # skip platforms index, e.g. sensors/index
# continue
split_text = title_text.split(" ")
self.props_section_title = title_text
# some components are several components in a single platform doc
# e.g. ttp229 binary_sensor has two different named components.
component_name = (
"_".join(split_text[:-1]).lower().replace(".", "").replace("i²c", "i2c")
)
if component_name != self.platform and is_component_file(
self.app, component_name
):
f = get_component_file(self.app, component_name)
# Document first paragraph is description of this thing
description = self.getMarkdownParagraph(node.parent)
if component_name in f:
self.set_component_description(description, component_name)
c = f[component_name]
if c:
self.json_component = c["schemas"]["CONFIG_SCHEMA"]
try:
self.props = self.find_props(self.json_component)
self.multi_component = None
except KeyError:
raise ValueError(
"Cannot find props for component " + component_name
)
return
# component which are platforms in doc, used by: stepper and canbus, lcd_pcf8574
elif f"{component_name}.{self.path[1]}" in f:
self.set_component_description(
description, component_name, self.path[1]
)
self.json_platform_component = f[
f"{component_name}.{self.path[1]}"
]["schemas"]["CONFIG_SCHEMA"]
try:
self.props = self.find_props(self.json_platform_component)
except KeyError:
raise ValueError(
f"Cannot find props for platform {self.path[1]} component {self.component_name}"
)
return
elif title_text.endswith("Trigger"):
# Document first paragraph is description of this thing
description = self.getMarkdownParagraph(node.parent)
split_text = title_text.split(" ")
if len(split_text) != 2:
return
key = split_text[0]
if (
not self.props or not self.props.typed
): # props are right for typed components so far
c = self.json_component
if c:
if self.component in c:
c = c[self.component]["schemas"][
self.component.upper() + "_SCHEMA"
]
trigger_schema = self.find_props(c).get(key)
if trigger_schema is not None:
self.props = self.find_props(trigger_schema)
self.props_section_title = title_text
elif title_text == PIN_CONFIGURATION_VARIABLES:
self.component = self.find_component(self.path[-1] + ".pin")
self.props = self.find_props(self.component)
self.accept_props = True
if not self.component:
raise ValueError(
f'Found a "{PIN_CONFIGURATION_VARIABLES}" entry but could not find pin schema'
)
elif title_text.endswith("Action") or title_text.endswith("Condition"):
# Document first paragraph is description of this thing
description = self.getMarkdownParagraph(node.parent)
split_text = title_text.split(" ")
if len(split_text) != 2:
return
key = split_text[0]
component_parts = split_text[0].split(".")
if len(component_parts) == 3:
try:
cv = get_component_file(self.app, component_parts[1])[
component_parts[1] + "." + component_parts[0]
][split_text[1].lower()][component_parts[2]]
except KeyError:
logger.warn(
f"In {self.docname} cannot found schema of {title_text}"
)
cv = None
if cv is not None:
cv["docs"] = description
self.props = self.find_props(cv.get("schema", {}))
elif len(component_parts) == 2:
registry_name = ".".join(
[component_parts[0], "registry", split_text[1].lower()]
)
key = component_parts[1]
self.find_registry_prop(registry_name, key, description)
else:
registry_name = f"core.registry.{split_text[1].lower()}"
# f"automation.{split_text[1].upper()}_REGISTRY"
self.find_registry_prop(registry_name, key, description)
if self.section_level == 3 and self.find_registry:
name = title_text
if name.endswith(" Effect"):
name = title_text[: -len(" Effect")]
if name.endswith(" Light"):
name = name[: -len(" Light")]
key = name.replace(" ", "_").replace(".", "").lower()
description = self.getMarkdownParagraph(node.parent)
self.find_registry_prop(self.find_registry, key, description)
self.props_section_title = title_text
def get_component_schema(self, name):
parts = name.split(".")
schema_file = get_component_file(self.app, parts[0])
if parts[1] == "registry":
schema = schema_file.get(parts[0], {}).get(parts[2], {})
elif len(parts) == 3:
schema = (
schema_file.get(f"{parts[0]}.{parts[1]}")
.get("schemas", {})
.get(parts[2], {})
)
else:
schema = schema_file.get(parts[0], {}).get("schemas", {}).get(parts[1], {})
return schema
def get_component_config_var(self, name, key):
c = self.get_component_schema(name)
if key in c:
return c[key]
if "config_vars" not in c:
return c
if key in c["config_vars"]:
return c["config_vars"][c]
def find_registry_prop(self, registry_name, key, description):
c = self.get_component_schema(registry_name)
if key in c:
cv = c[key]
if cv is not None:
cv["docs"] = description
self.props = self.find_props(cv.get("schema", {}))
def depart_title(self, node):
if self.filled_props:
self.filled_props = False
self.props = None
self.current_prop = None
self.accept_props = False
self.multi_component = None
self.previous_title_text = node.astext()
self.title_id = node.parent["ids"][0]
def find_props_previous_title(self):
comp = self.json_component or self.json_platform_component
if comp:
props = self.find_props(comp)
if self.previous_title_text in props:
prop = props[self.previous_title_text]
if prop:
self.props = self.find_props(prop)
else:
# return fake dict so better errors are printed
self.props = {"__": "none"}
def visit_Text(self, node):
if self.multi_component:
return
if is_config_vars_title(node.astext()):
if not self.props:
self.find_props_previous_title()
if not self.props:
raise ValueError(
f'Found a "{node.astext()}" entry for unknown object after {self.previous_title_text}'
)
self.accept_props = True
raise nodes.SkipChildren
def depart_Text(self, node):
pass
def visit_paragraph(self, node):
if is_config_vars_title(node.astext()):
if not self.props and not self.multi_component:
self.find_props_previous_title()
if not self.props and not self.multi_component:
logger.info(
f"In {self.docname} / {self.previous_title_text} found a {node.astext()} title and there are no props."
)
# raise ValueError(
# f'Found a "{node.astext()}" entry for unknown object after {self.previous_title_text}'
# )
self.accept_props = True
raise nodes.SkipChildren
def depart_paragraph(self, node):
pass
def visit_bullet_list(self, node):
self.bullet_list_level = self.bullet_list_level + 1
if (
self.current_prop
and (self.props or self.multi_component)
and self.bullet_list_level > 1
):
self.prop_stack.append((self.current_prop, node))
self.accept_props = True
return
if not self.props and self.multi_component is None:
raise nodes.SkipChildren
def depart_bullet_list(self, node):
self.bullet_list_level = self.bullet_list_level - 1
if len(self.prop_stack) > 0:
stack_prop, stack_node = self.prop_stack[-1]
if stack_node == node:
self.prop_stack.pop()
self.filled_props = True
self.current_prop = stack_prop
def visit_list_item(self, node):
if self.accept_props and self.props:
self.filled_props = True
self.current_prop, found = self.update_prop(node, self.props)
if self.current_prop and not found:
logger.info(
f"In '{self.docname} {self.previous_title_text} Cannot find property {self.current_prop}"
)
elif self.multi_component:
# update prop for each component
found_any = False
self.current_prop = None
for c in self.multi_component:
props = self.find_props(self.find_component(c))
self.current_prop, found = self.update_prop(node, props)
if self.current_prop and found:
found_any = True
if self.current_prop and not found_any:
logger.info(
f"In '{self.docname} {self.previous_title_text} Cannot find property {self.current_prop}"
)
self.filled_props = True
def depart_list_item(self, node):
pass
def visit_literal(self, node):
raise nodes.SkipChildren
def depart_literal(self, node):
pass
def getMarkdown(self, node):
from markdown import Translator
t = Translator(
urllib.parse.urljoin(self.app.config.html_baseurl, self.docname + ".html"),
self.doctree,
)
node.walkabout(t)
return t.output.strip("\n")
def getMarkdownParagraph(self, node):
paragraph = list(node.traverse(nodes.paragraph))[0]
markdown = self.getMarkdown(paragraph)
param_type = None
# Check if there is type information for this item
try:
name_type = markdown[: markdown.index(": ") + 2]
ntr = re.search(
REQUIRED_OPTIONAL_TYPE_REGEX,
name_type,
re.IGNORECASE,
)
if ntr:
param_type = ntr.group(6)
if param_type:
markdown = (
f"**{param_type}**: {markdown[markdown.index(': ') + 2 :]}"
)
except ValueError:
# ': ' not found
pass
title = list(node.traverse(nodes.title))[0]
if len(title) > 0:
url = urllib.parse.urljoin(
self.app.config.html_baseurl,
self.docname + ".html#" + title.parent["ids"][0],
)
if (
self.props_section_title is not None
and self.props_section_title.endswith(title.astext())
):
markdown += f"\n\n*See also: [{self.props_section_title}]({url})*"
else:
markdown += f"\n\n*See also: [{self.getMarkdown(title)}]({url})*"
return markdown
def update_prop(self, node, props):
prop_name = None
for s_prop, n in self.prop_stack:
inner = props.get(s_prop)
if inner is not None and "schema" in inner:
props = self.Props(self, inner["schema"])
elif inner is not None and inner.get("type") == "typed":
# this is used in external_components
props = self.Props(self, inner)
elif inner is not None and inner.get("type") == "enum":
enum_raw = self.getMarkdown(node)
# the regex allow the format to have either a ":" or a " -" as the value / docs separator, value must be in `back ticks`
# also description is optional
enum_match = re.search(
r"\* `([^`]*)`((:| -) (.*))*", enum_raw, re.IGNORECASE
)
if enum_match:
enum_value = enum_match.group(1)
enum_docs = enum_match.group(4)
found = False
for name in inner["values"]:
if enum_value.upper().replace(" ", "_") == str(name).upper():
found = True
if enum_docs:
enum_docs = enum_docs.strip()
if inner["values"][name] is None:
inner["values"][name] = {"docs": enum_docs}
else:
inner["values"][name]["docs"] = enum_docs
statistics.props_documented += 1
statistics.enums_good += 1
if not found:
logger.info(
f"In '{self.docname} {self.previous_title_text} Property {s_prop} cannot find enum value {enum_value}"
)
else:
statistics.enums_bad += 1
logger.info(
f"In '{self.docname} {self.previous_title_text} Property {s_prop} unexpected enum member description format"
)
else:
# nothing to do?
return prop_name, False
raw = node.rawsource # this has the full raw rst code for this property
if not raw.startswith("**"):
# not bolded, most likely not a property definition,
# usually texts like 'All properties from...' etc
return prop_name, False
markdown = self.getMarkdown(node)
markdown += f"\n\n*See also: [{self.props_section_title}]({urllib.parse.urljoin(self.app.config.html_baseurl, self.docname +'.html#'+self.title_id)})*"
try:
name_type = markdown[: markdown.index(": ") + 2]
except ValueError:
logger.info(
f"In '{self.docname} {self.previous_title_text} Property format error. Missing ': ' in {raw}'"
)
return prop_name, False
# Example properties formats are:
# **prop_name** (**Required**, string): Long Description...
# **prop_name** (*Optional*, string): Long Description... Defaults to ``value``.
# **prop_name** (*Optional*): Long Description... Defaults to ``value``.
# **prop_name** can be a list of names separated by / e.g. **name1/name2** (*Optional*) see climate/pid/ threshold_low/threshold_high
PROP_NAME_REGEX = r"\*\*(\w*(?:/\w*)*)\*\*"
FULL_ITEM_PROP_NAME_TYPE_REGEX = (
r"\* " + PROP_NAME_REGEX + r"\s" + REQUIRED_OPTIONAL_TYPE_REGEX
)
ntr = re.search(
FULL_ITEM_PROP_NAME_TYPE_REGEX,
name_type,
re.IGNORECASE,
)
if ntr:
prop_name = ntr.group(1)
param_type = ntr.group(7)
else:
s2 = re.search(
FULL_ITEM_PROP_NAME_TYPE_REGEX,
markdown,
re.IGNORECASE,
)
if s2:
# this is e.g. when a property has a list inside, and the list inside are the options.
# just validate **prop_name**
s3 = re.search(r"\* " + PROP_NAME_REGEX + r"*:\s", name_type)
if s3 is not None:
prop_name = s3.group(1)
else:
logger.info(
f"In '{self.docname} {self.previous_title_text} Invalid list format: {node.rawsource}"
)
param_type = None
else:
logger.info(
f"In '{self.docname} {self.previous_title_text} Invalid property format: {node.rawsource}"
)
return prop_name, False
prop_names = str(prop_name)
for k in prop_names.split("/"):
config_var = props.get(k)
if not config_var:
# Create docs for common properties when descriptions are overridden
# in the most specific component.
if k in [
"id",
"name",
"internal",
# i2c
"address",
"i2c_id",
# polling component
"update_interval",
# uart
"uart_id",
# light
"effects",
"gamma_correct",
"default_transition_length",
"flash_transition_length",
"color_correct",
# display
"lambda",
"pages",
"rotation",
# spi
"spi_id",
"cs_pin",
# output (binary/float output)
"inverted",
"power_supply",
# climate
"receiver_id",
]:
config_var = props[k] = {}
else:
if self.path[1] == "esphome" and k in [
# deprecated esphome
"platform",
"board",
"arduino_version",
"esp8266_restore_from_flash",
]:
return prop_name, True
return prop_name, False
desc = markdown[markdown.index(": ") + 2 :].strip()
if param_type:
desc = "**" + param_type + "**: " + desc
config_var["docs"] = desc
statistics.props_documented += 1
return prop_name, True
def find_component(self, component_path):
path = component_path.split(".")
file_content = get_component_file(self.app, path[0])
if path[1] == "platform":
path[2] = f"{path[0]}.{path[2]}"
path = path[2:]
component = file_content
for p in path:
component = component.get(p, {})
return component
class Props(MutableMapping):
"""Smarter props dict.
Props are mostly a dict, however some constructs have two issues:
- An update is intended on an element which does not own a property, but it is based
on an schema that does have the property, those cases are handled by examining the extended
"""
def __init__(self, visitor, component, fail_silently=False):
self.visitor = visitor
self.component = component
self.store = self._get_props(component, fail_silently)
self.parent = None
self.typed = self.component.get("type") == "typed"
def _get_props(self, component, fail_silently):
# component is a 'schema' dict which has 'config_vars' and 'extends'
if not (
"config_vars" in component
or "extends" in component
or len(component) == 0
or component.get("type") == "typed"
):
if fail_silently:
return None
raise ValueError("Unexpected component data to get props")
props = component.get("config_vars")
return props
def _find_extended(self, component, key):
for extended in component.get("extends", []):
c = self.visitor.get_component_schema(extended)
if c.get("type") == "typed":
p = self.visitor.Props(self.visitor, c)
return p[key]
schema = c.get("schema", {})
for k, cv in schema.get("config_vars", {}).items():
if k == key:
return SetObservable(
cv,
setitem_callback=self._set_extended,
inner_key=key,
original_dict=schema.get("config_vars"),
)
ex1 = self._find_extended(schema, key)
if ex1:
return ex1
def _set_extended(self, inner_key, original_dict, key, value):
original_dict[inner_key][key] = value
def _iter_extended(self, component):
for extended in component.get("extends", []):
schema = self.visitor.get_component_schema(extended).get("schema", {})
for s in self._iter_extended(schema):
yield s
yield schema
def __getitem__(self, key):
if self.store and key in self.store:
return self.store[key]
extended = self._find_extended(self.component, key)
if extended is not None:
return extended
if self.component.get("type") == "typed":
return SetObservable(
{key: {"type": "string"}},
setitem_callback=self._set_typed,
inner_key=key,
original_dict={},
)
def _set_typed(self, inner_key, original_dict, key, value):
if inner_key == self.component.get("typed_key", "type"):
self.component[key] = value
else:
for tk, tv in self.component["types"].items():
for cv_k, cv_v in tv["config_vars"].items():
if cv_k == inner_key:
cv_v[key] = value
def __setitem__(self, key, value):
self.store[key] = value
def __delitem__(self, key):
self.store.pop(key)
def __iter__(self):
return iter(self.store)
def __len__(self):
len_extended = 0
if self.component.get("type"):
types = self.component.get("types")
for t, tv in types.items():
for s in self._iter_extended(types.get(t, {})):
len_extended += len(s.get("config_vars", {}))
len_extended += len(tv.get("config_vars", {}))
return len_extended
for s in self._iter_extended(self.component):
len_extended += len(s.get("config_vars", {}))
return len_extended + (len(self.store) if self.store else 0)
def find_props(self, component, fail_silently=False):
if component.get("type") in ["trigger", "schema"]:
# can have schema
if "schema" not in component:
return None
component = component.get("schema")
props = self.Props(self, component, fail_silently)
if props:
self.filled_props = False
self.accept_props = False
self.current_prop = None
return props
def handle_component(app, doctree, docname):
path = docname.split("/")
if path[0] == "components":
pass
elif docname not in CUSTOM_DOCS:
return
try:
v = SchemaGeneratorVisitor(app, doctree, docname)
doctree.walkabout(v)
except Exception as e:
err_str = f"In {docname}.rst: {str(e)}"
# if you put a breakpoint here get call-stack in the console by entering
# import traceback
# traceback.print_exc()
logger.warning(err_str)
def build_finished(app, exception):
# TODO: create report of missing descriptions
for fname, contents in app.files.items():
f = open(SCHEMA_PATH + fname + ".json", "w", newline="\n")
if JSON_DUMP_PRETTY:
f.write(json.dumps(contents, indent=2))
else:
f.write(json.dumps(contents, separators=(",", ":")))
str = f"Documented: {statistics.props_documented} Enums: {statistics.enums_good}/{statistics.enums_bad}"
logger.info(str)
class SetObservable(dict):
"""
a MyDict is like a dict except that when you set an item, before
doing so it will call a callback function that was passed in when the
MyDict instance was created
"""
def __init__(
self,
value,
setitem_callback=None,
inner_key=None,
original_dict=None,
*args,
**kwargs,
):
super(SetObservable, self).__init__(value, *args, **kwargs)
self._setitem_callback = setitem_callback
self.inner_key = inner_key
self.original_dict = original_dict
def __setitem__(self, key, value):
if self._setitem_callback:
self._setitem_callback(self.inner_key, self.original_dict, key, value)
super(SetObservable, self).__setitem__(key, value)
def is_component_file(app: SchemaGeneratorVisitor, component):
if component == "core" or component == "automation":
component = "esphome"
return exists(SCHEMA_PATH + component + ".json")
def get_component_file(app: SchemaGeneratorVisitor, component):
if component == "core" or component == "automation":
component = "esphome"
if component not in app.files:
app.files[component] = read_file(component)
return app.files[component]