mirror of
https://github.com/esphome/esphome-docs.git
synced 2024-12-26 17:27:47 +01:00
1016 lines
37 KiB
Python
1016 lines
37 KiB
Python
import re
|
|
import json
|
|
import urllib
|
|
|
|
from typing import MutableMapping
|
|
from sphinx.util import logging
|
|
from docutils import nodes
|
|
|
|
SCHEMA_PATH = "schema.json"
|
|
CONFIGURATION_VARIABLES = "Configuration variables:"
|
|
PIN_CONFIGURATION_VARIABLES = "Pin configuration variables:"
|
|
COMPONENT_HUB = "Component/Hub"
|
|
|
|
props_missing = 0
|
|
props_verified = 0
|
|
props_documented = 0
|
|
|
|
|
|
def setup(app):
|
|
"""Setup connects events to the sitemap builder"""
|
|
|
|
import os
|
|
|
|
if not os.path.isfile(SCHEMA_PATH):
|
|
logger = logging.getLogger(__name__)
|
|
logger.info(f"{SCHEMA_PATH} not found. Not documenting schema.")
|
|
else:
|
|
app.connect("doctree-resolved", doctree_resolved)
|
|
app.connect("build-finished", build_finished)
|
|
|
|
f = open(SCHEMA_PATH, "r", encoding="utf-8-sig")
|
|
str = f.read()
|
|
app.jschema = json.loads(str)
|
|
|
|
return {"version": "1.0.0", "parallel_read_safe": True, "parallel_write_safe": True}
|
|
|
|
|
|
def find_component(jschema, component):
|
|
return jschema["properties"].get(component)
|
|
|
|
|
|
def find_platform_component(jschema, platform, component):
|
|
platform_items = jschema["properties"][platform].get("items")
|
|
if not platform_items:
|
|
return None
|
|
ar = platform_items["allOf"]
|
|
for p in ar:
|
|
if "if" in p:
|
|
if p["if"]["properties"]["platform"]["const"] == component:
|
|
return p
|
|
|
|
|
|
def doctree_resolved(app, doctree, docname):
|
|
if docname == "components/index":
|
|
# nothing useful here
|
|
return
|
|
try:
|
|
handle_component(app, doctree, docname)
|
|
|
|
except Exception as e:
|
|
err_str = f"In {docname}: {str(e)}"
|
|
logger = logging.getLogger(__name__)
|
|
logger.warning(err_str)
|
|
|
|
|
|
PLATFORMS_TITLES = {
|
|
"Sensor": "sensor",
|
|
"Binary Sensor": "binary_sensor",
|
|
"Text Sensor": "text_sensor",
|
|
"Output": "output",
|
|
"Cover": "cover",
|
|
"Climate": "climate",
|
|
"CAN Bus": "canbus",
|
|
"Stepper": "stepper",
|
|
}
|
|
|
|
CUSTOM_DOCS = {
|
|
"guides/automations": {
|
|
"Global Variables": "properties/globals",
|
|
},
|
|
"guides/configuration-types": {
|
|
"Color": "properties/color",
|
|
"Pin Schema": [
|
|
"definitions/PIN.INPUT_INTERNAL",
|
|
"definitions/PIN.OUTPUT_INTERNAL",
|
|
],
|
|
},
|
|
"components/binary_sensor/index": {
|
|
"Binary Sensor Filters": "binary_sensor.FILTER_REGISTRY",
|
|
},
|
|
"components/climate/ir_climate": {
|
|
"IR Remote Climate": [
|
|
"properties/climate/coolix",
|
|
"properties/climate/daikin",
|
|
"properties/climate/fujitsu_general",
|
|
"properties/climate/mitsubishi",
|
|
"properties/climate/tcl112",
|
|
"properties/climate/toshiba",
|
|
"properties/climate/yashima",
|
|
"properties/climate/whirlpool",
|
|
"properties/climate/climate_ir_lg",
|
|
"properties/climate/hitachi_ac344",
|
|
]
|
|
},
|
|
"components/display/index": {
|
|
"Images": "properties/image",
|
|
"Drawing Static Text": "properties/font",
|
|
"Color": "properties/color",
|
|
"Animation": "properties/animation",
|
|
},
|
|
"components/light/index": {
|
|
"Base Light Configuration": [
|
|
"definitions/light.ADDRESSABLE_LIGHT_SCHEMA",
|
|
"definitions/light.BINARY_LIGHT_SCHEMA",
|
|
"definitions/light.BRIGHTNESS_ONLY_LIGHT_SCHEMA",
|
|
"definitions/light.LIGHT_SCHEMA",
|
|
],
|
|
"Light Effects": "light.EFFECTS_REGISTRY",
|
|
},
|
|
"components/light/fastled": {
|
|
"Clockless": "properties/light/fastled_clockless",
|
|
"SPI": "properties/light/fastled_spi",
|
|
},
|
|
"components/mcp230xx": {
|
|
PIN_CONFIGURATION_VARIABLES: [
|
|
"definitions/PIN.INPUT_mcp23xxx",
|
|
"definitions/PIN.OUTPUT_mcp23xxx",
|
|
]
|
|
},
|
|
"components/mqtt": {
|
|
"MQTT Component Base Configuration": "definitions/CONFIG.MQTT_COMMAND_COMPONENT_SCHEMA",
|
|
},
|
|
"components/output/index": {
|
|
"Base Output Configuration": "definitions/output.FLOAT_OUTPUT_SCHEMA",
|
|
},
|
|
"components/remote_transmitter": {
|
|
"Remote Transmitter Actions": "definitions/REMOTE_BASE.BASE_REMOTE_TRANSMITTER_SCHEMA",
|
|
},
|
|
"components/sensor/index": {
|
|
"Sensor Filters": "sensor.FILTER_REGISTRY",
|
|
},
|
|
"components/time": {
|
|
"Home Assistant Time Source": "properties/time/homeassistant",
|
|
"SNTP Time Source": "properties/time/sntp",
|
|
"GPS Time Source": "properties/time/gps",
|
|
"DS1307 Time Source": "properties/time/ds1307",
|
|
},
|
|
"components/wifi": {
|
|
"Connecting to Multiple Networks": "definitions/wifi-networks",
|
|
"Enterprise Authentication": "definitions/wifi-networks/eap",
|
|
},
|
|
"custom/custom_component": {
|
|
"Generic Custom Component": "properties/custom_component"
|
|
},
|
|
}
|
|
|
|
|
|
def get_node_title(node):
|
|
return list(node.traverse(nodes.title))[0].astext()
|
|
|
|
|
|
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.json_base_config = None
|
|
self.title_id = None
|
|
self.props_section_title = None
|
|
self.find_registry = None
|
|
self.section_level = 0
|
|
if self.path[0] == "components":
|
|
if len(self.path) == 2: # root component, e.g. dfplayer, logger
|
|
component = docname[11:]
|
|
self.json_component = app.jschema["properties"].get(component)
|
|
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.
|
|
|
|
component = self.path[2]
|
|
|
|
if component == "ssd1331":
|
|
component = "ssd1331_spi"
|
|
|
|
self.platform = self.path[1]
|
|
if component == "index":
|
|
# these are e.g. sensor, binary sensor etc.
|
|
p = self.platform.replace(" ", "_").lower()
|
|
self.json_component = find_component(
|
|
self.app.jschema, self.platform
|
|
)
|
|
self.json_base_config = self.app.jschema["definitions"].get(
|
|
p + "." + p.upper() + "_SCHEMA"
|
|
)
|
|
|
|
else:
|
|
self.json_component = find_component(self.app.jschema, component)
|
|
|
|
self.json_platform_component = find_platform_component(
|
|
self.app.jschema, self.platform, component
|
|
)
|
|
|
|
self.custom_doc = CUSTOM_DOCS.get(docname)
|
|
|
|
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.props_level = 0
|
|
|
|
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 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.json_platform_component["markdownDescription"] = description
|
|
elif self.json_component:
|
|
self.json_component["markdownDescription"] = description
|
|
|
|
if self.json_base_config:
|
|
self.json_component = self.json_base_config
|
|
|
|
# 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:
|
|
self.props = self.find_props(
|
|
self.json_platform_component
|
|
if self.json_platform_component
|
|
else self.json_component
|
|
)
|
|
|
|
if not self.props:
|
|
# get props for base components, Sensor, Binary Sensor, Light, etc.
|
|
|
|
if len(self.path) == 2:
|
|
# "#/definitions/schema_canbus.CONFIG_SCHEMA"
|
|
self.json_base_config = self.app.jschema["definitions"].get(
|
|
f"{self.path[1]}.{self.path[1].upper()}_SCHEMA"
|
|
)
|
|
if self.json_base_config:
|
|
self.props = self.find_props(self.json_base_config)
|
|
|
|
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 (
|
|
isinstance(r, list)
|
|
or r.startswith("properties")
|
|
or r.startswith("definitions")
|
|
):
|
|
return
|
|
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 "interval" in title_text:
|
|
title_text = title_text
|
|
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
|
|
# TODO: add same markdown description to each?
|
|
|
|
return
|
|
|
|
json_component = self.find_component(self.custom_doc[title_text])
|
|
if not json_component:
|
|
return
|
|
|
|
json_component["markdownDescription"] = self.getMarkdownParagraph(
|
|
node.parent
|
|
)
|
|
self.props_section_title = title_text
|
|
self.props = self.find_props(json_component)
|
|
|
|
return
|
|
|
|
if 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 = find_component(self.app.jschema, self.path[-1])
|
|
if json_component:
|
|
self.props = self.find_props(json_component)
|
|
|
|
json_component["markdownDescription"] = self.getMarkdownParagraph(
|
|
node.parent
|
|
)
|
|
|
|
# mark this to retrieve components instead of platforms
|
|
self.is_component_hub = True
|
|
|
|
if title_text == CONFIGURATION_VARIABLES:
|
|
if not self.props and self.multi_component is None:
|
|
raise ValueError(
|
|
f'Found a "{CONFIGURATION_VARIABLES}": title after {self.previous_title_text}. Unknown object.'
|
|
)
|
|
|
|
if 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.jschema, 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[
|
|
"markdownDescription"
|
|
] = self.getMarkdownParagraph(node.parent)
|
|
|
|
else:
|
|
json_component = find_component(self.app.jschema, component)
|
|
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
|
|
json_component["markdownDescription"] = self.getMarkdownParagraph(
|
|
node.parent
|
|
)
|
|
|
|
# Title is description of platform component, those ends with Sensor, Binary Sensor, Cover, etc.
|
|
if (
|
|
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]
|
|
component_name = self.path[-1]
|
|
self.props_section_title = self.path[-1] + " " + 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
|
|
c = find_platform_component(
|
|
self.app.jschema, platform_name, component_name.lower()
|
|
)
|
|
if c:
|
|
self.json_platform_component = c
|
|
|
|
c["markdownDescription"] = self.getMarkdownParagraph(node.parent)
|
|
|
|
# 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")
|
|
|
|
if 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
|
|
if len(split_text) == 2:
|
|
# some components are several components in a single platform doc
|
|
# e.g. ttp229 binary_sensor has two different named components.
|
|
component_name = split_text[0].lower().replace(".", "")
|
|
if component_name.lower() == self.platform:
|
|
return
|
|
c = find_component(self.app.jschema, component_name)
|
|
if c:
|
|
self.json_component = c
|
|
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
|
|
c = find_platform_component(
|
|
self.app.jschema, self.path[1], component_name
|
|
)
|
|
if c:
|
|
self.json_platform_component = c
|
|
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
|
|
|
|
if 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]
|
|
|
|
# handles Time / on_time
|
|
c = self.json_base_config or self.json_component
|
|
if c:
|
|
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
|
|
|
|
if title_text == PIN_CONFIGURATION_VARIABLES:
|
|
self.multi_component = []
|
|
if self.app.jschema["definitions"].get(f"PIN.INPUT_{self.path[-1]}"):
|
|
self.multi_component.append(f"definitions/PIN.INPUT_{self.path[-1]}")
|
|
if self.app.jschema["definitions"].get(f"PIN.OUTPUT_{self.path[-1]}"):
|
|
self.multi_component.append(f"definitions/PIN.OUTPUT_{self.path[-1]}")
|
|
self.accept_props = True
|
|
self.filled_props = False
|
|
self.props = None
|
|
if len(self.multi_component) == 0:
|
|
raise ValueError(
|
|
f'Found a "{PIN_CONFIGURATION_VARIABLES}" entry but could not find pin schema'
|
|
)
|
|
|
|
if 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]
|
|
|
|
if self.props:
|
|
ref = self.props.get(key)
|
|
if ref:
|
|
ref = self.get_ref(ref)
|
|
if ref:
|
|
self.props = self.find_props(ref)
|
|
return
|
|
|
|
registry_name = 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
|
|
|
|
if title_text == PIN_CONFIGURATION_VARIABLES:
|
|
self.multi_component = []
|
|
if self.app.jschema["definitions"].get(f"PIN.INPUT_{self.path[-1]}"):
|
|
self.multi_component.append(f"definitions/PIN.INPUT_{self.path[-1]}")
|
|
if self.app.jschema["definitions"].get(f"PIN.OUTPUT_{self.path[-1]}"):
|
|
self.multi_component.append(f"definitions/PIN.OUTPUT_{self.path[-1]}")
|
|
self.accept_props = True
|
|
self.filled_props = False
|
|
self.props = None
|
|
if len(self.multi_component) == 0:
|
|
raise ValueError(
|
|
f'Found a "{PIN_CONFIGURATION_VARIABLES}" entry but could not find pin schema'
|
|
)
|
|
|
|
def find_registry_prop(self, registry_name, key, description):
|
|
registry = self.app.jschema["definitions"][registry_name]["anyOf"]
|
|
for item in registry:
|
|
if "$ref" in item:
|
|
item = self.get_ref(item)
|
|
if key in item["properties"]:
|
|
item["properties"][key]["markdownDescription"] = description
|
|
self.props = self.find_props(item["properties"][key])
|
|
|
|
return
|
|
raise ValueError(f"Cannot find {registry_name} {key}")
|
|
|
|
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 node.astext() == CONFIGURATION_VARIABLES:
|
|
if not self.props:
|
|
self.find_props_previous_title()
|
|
if not self.props:
|
|
raise ValueError(
|
|
f'Found a "{CONFIGURATION_VARIABLES}" 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 node.astext() == CONFIGURATION_VARIABLES:
|
|
if not self.props and not self.multi_component:
|
|
self.find_props_previous_title()
|
|
if not self.props and not self.multi_component:
|
|
raise ValueError(
|
|
f'Found a "{CONFIGURATION_VARIABLES}" 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.props_level = self.props_level + 1
|
|
if self.current_prop and self.props and self.props_level > 1:
|
|
|
|
# if '$ref' in self.props[self.current_prop]:
|
|
# if self.props[self.current_prop]['$ref'].endswith('_SCHEMA'):
|
|
# nowhere put this props info...
|
|
# raise nodes.SkipChildren
|
|
|
|
# this can be list of values, list of subproperties
|
|
|
|
# deep configs
|
|
# e.g. wifi manual_ip, could also be a enum list
|
|
|
|
# if this prop has a reference
|
|
prop = self.props[self.current_prop]
|
|
if isinstance(prop, dict):
|
|
if "$ref" in prop:
|
|
ref = self.get_ref(prop)
|
|
self.prop_stack.append(self.props)
|
|
self.props = self.find_props(ref)
|
|
self.accept_props = True
|
|
elif "properties" in prop:
|
|
self.prop_stack.append(self.props)
|
|
self.props = prop["properties"]
|
|
elif (
|
|
"anyOf" in prop
|
|
and len(prop["anyOf"]) > 0
|
|
and isinstance(self.get_ref(prop["anyOf"][0]), dict)
|
|
and "$ref" in self.get_ref(prop["anyOf"][0])
|
|
):
|
|
ref = self.get_ref(prop["anyOf"][0])
|
|
self.prop_stack.append(self.props)
|
|
self.props = self.find_props(ref)
|
|
else:
|
|
# TODO: if the list items are formatted like:
|
|
# - ``value`` <optional description>
|
|
# - ``other value`` <optional description>
|
|
# then we could ensure these are enum values (or populate enum values double check.)
|
|
# Currently some enum values are also in the **value** format.
|
|
if (
|
|
# most likely an enum, the values are most likely retrieved from ESPHome validation schema
|
|
"enum" in prop
|
|
# or custom components has list of sensors/binary sensors, etc.
|
|
or (
|
|
prop.get("markdownDescription", "").startswith("**list**")
|
|
and self.docname.endswith("/custom")
|
|
)
|
|
):
|
|
raise nodes.SkipChildren
|
|
# nowhere put this props info...
|
|
# otherwise inner bullet list will be interpreted as property list
|
|
logger = logging.getLogger(__name__)
|
|
logger.info(
|
|
f"In {self.docname} / {self.previous_title_text} property {self.current_prop} a unknown info sub bullet list."
|
|
)
|
|
raise nodes.SkipChildren
|
|
else:
|
|
# nowhere put this props info...
|
|
# otherwise inner bullet list will be interpreted as property list
|
|
raise nodes.SkipChildren
|
|
|
|
if not self.props and self.multi_component is None:
|
|
raise nodes.SkipChildren
|
|
|
|
def depart_bullet_list(self, node):
|
|
self.props_level = self.props_level - 1
|
|
if len(self.prop_stack) > 0:
|
|
self.props = self.prop_stack.pop()
|
|
self.filled_props = True
|
|
|
|
def visit_list_item(self, node):
|
|
if self.accept_props and self.props:
|
|
self.filled_props = True
|
|
try:
|
|
self.current_prop = self.update_prop(node, self.props)
|
|
except Exception as e:
|
|
raise ValueError(f"In '{self.previous_title_text}' {str(e)}")
|
|
|
|
elif self.multi_component:
|
|
# update prop for each component
|
|
for c in self.multi_component:
|
|
props = self.find_props(self.find_component(c))
|
|
self.current_prop = self.update_prop(node, props)
|
|
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
|
|
|
|
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(
|
|
r"(\(((\*\*Required\*\*)|(\*Optional\*))(,\s(.*))*)\):\s",
|
|
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],
|
|
)
|
|
markdown += f"\n\n*See also: [{self.props_section_title}]({url})*"
|
|
return markdown
|
|
|
|
def update_prop(self, node, props):
|
|
prop_name = None
|
|
|
|
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 None
|
|
|
|
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:
|
|
raise ValueError(f'Property format error. Missing ": " in {raw}')
|
|
|
|
# Example properties formats are:
|
|
# **name** (**Required**, string): Long Description...
|
|
# **name** (*Optional*, string): Long Description... Defaults to ``value``.
|
|
# **name** (*Optional*): Long Description... Defaults to ``value``.
|
|
|
|
ntr = re.search(
|
|
r"\* \*\*(\w*)\*\*\s(\(((\*\*Required\*\*)|(\*Optional\*))(,\s(.*))*)\):\s",
|
|
name_type,
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
if ntr:
|
|
prop_name = ntr.group(1)
|
|
param_type = ntr.group(7)
|
|
else:
|
|
s2 = re.search(
|
|
r"\* \*\*(\w*)\*\*\s(\(((\*\*Required\*\*)|(\*Optional\*))(,\s(.*))*)\):\s",
|
|
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"\* \*\*(\w*)\*\*:\s", name_type)
|
|
prop_name = s3.group(1)
|
|
param_type = None
|
|
else:
|
|
raise ValueError(f"Invalid property format: {node.rawsource}")
|
|
|
|
k = str(prop_name)
|
|
jprop = props.get(k)
|
|
if not jprop:
|
|
# 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",
|
|
"color_correct",
|
|
# display
|
|
"lambda",
|
|
"dither",
|
|
"pages",
|
|
"rotation",
|
|
# spi
|
|
"spi_id",
|
|
"cs_pin",
|
|
# output (binary/float output)
|
|
"inverted",
|
|
"power_supply",
|
|
# climate
|
|
"receiver_id",
|
|
]:
|
|
jprop = props[k] = {}
|
|
else:
|
|
raise ValueError(f"Cannot find property {k}")
|
|
|
|
desc = markdown[markdown.index(": ") + 2 :].strip()
|
|
if param_type:
|
|
desc = "**" + param_type + "**: " + desc
|
|
|
|
jprop["markdownDescription"] = desc
|
|
global props_documented
|
|
props_documented = props_documented + 1
|
|
|
|
return prop_name
|
|
|
|
def get_ref(self, node):
|
|
ref = node.get("$ref")
|
|
if ref and ref.startswith("#/definitions/"):
|
|
return self.app.jschema["definitions"][ref[14:]]
|
|
|
|
def find_component(self, component_path):
|
|
path = component_path.split("/")
|
|
if path[0] not in ("properties", "definitions"):
|
|
return None
|
|
json_component = self.app.jschema[path[0]][path[1]]
|
|
|
|
if len(path) > 2:
|
|
# a property path
|
|
props = self.find_props(json_component)
|
|
if props:
|
|
json_component = props.get(path[2])
|
|
else:
|
|
# a platform sub element
|
|
json_component = find_platform_component(
|
|
self.app.jschema, path[1], path[2]
|
|
)
|
|
return json_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 can be handled
|
|
- An update is done in a typed schema
|
|
"""
|
|
|
|
def __init__(self, visitor, component):
|
|
self.visitor = visitor
|
|
self.component = component
|
|
self.store = self._get_props(component)
|
|
self.parent = None
|
|
|
|
def _get_props(self, component):
|
|
# find properties
|
|
if "then" in component:
|
|
component = component["then"]
|
|
props = component.get("properties")
|
|
ref = None
|
|
if not props:
|
|
arr = component.get("anyOf", component.get("allOf"))
|
|
if not arr:
|
|
if "$ref" in component:
|
|
return self._get_props(self.visitor.get_ref(component))
|
|
return None
|
|
for x in arr:
|
|
props = x.get("properties")
|
|
if not ref:
|
|
ref = self.visitor.get_ref(x)
|
|
if props:
|
|
break
|
|
if not props and ref:
|
|
props = self._get_props(ref)
|
|
return props
|
|
|
|
def __getitem__(self, key):
|
|
if self.store and key in self.store:
|
|
return self.store[key]
|
|
|
|
if "then" in self.component:
|
|
# check if it's typed
|
|
schemas = self.component["then"].get("allOf")
|
|
if (
|
|
isinstance(schemas, list)
|
|
and "properties" in schemas[0]
|
|
and "type" in schemas[0]["properties"]
|
|
):
|
|
for s in schemas:
|
|
if "then" in s:
|
|
props = self._get_props(s.get("then"))
|
|
if key in props:
|
|
return SetObservable(
|
|
props[key],
|
|
setitem_callback=self._update_typed,
|
|
inner_key=key,
|
|
)
|
|
return # key not found
|
|
|
|
# check if it's a registry and need to reset store
|
|
# e.g. remote_receiver binary sensor
|
|
if "$ref" in self.component["then"]:
|
|
ref = self.visitor.get_ref(self.component["then"])
|
|
prop_set = ref.get("anyOf")
|
|
if isinstance(prop_set, list):
|
|
for k in prop_set:
|
|
if "$ref" in k:
|
|
k = self.visitor.get_ref(k)
|
|
if key in k["properties"]:
|
|
self.store = k["properties"]
|
|
return self.store[key]
|
|
|
|
def _update_typed(self, inner_key, key, value):
|
|
# Make sure we update all types
|
|
if "then" in self.component:
|
|
schemas = self.component["then"].get("allOf")
|
|
assert "type" in schemas[0].get("properties")
|
|
for s in schemas:
|
|
if "then" in s:
|
|
props = self._get_props(s.get("then"))
|
|
if inner_key in props:
|
|
props[inner_key][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):
|
|
return len(self.store) if self.store else 0
|
|
|
|
def find_props(self, component):
|
|
props = self.Props(self, component)
|
|
|
|
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
|
|
|
|
v = SchemaGeneratorVisitor(app, doctree, docname)
|
|
doctree.walkabout(v)
|
|
|
|
|
|
def build_finished(app, exception):
|
|
# TODO: create report of missing descriptions
|
|
|
|
f = open(SCHEMA_PATH, "w")
|
|
f.write(json.dumps(app.jschema))
|
|
|
|
str = f"Documented: {props_documented}"
|
|
logger = logging.getLogger(__name__)
|
|
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, *args, **kwargs):
|
|
super(SetObservable, self).__init__(value, *args, **kwargs)
|
|
self._setitem_callback = setitem_callback
|
|
self.inner_key = inner_key
|
|
|
|
def __setitem__(self, key, value):
|
|
if self._setitem_callback:
|
|
self._setitem_callback(self.inner_key, key, value)
|
|
super(SetObservable, self).__setitem__(key, value)
|