esphome-docs/schema_doc.py

1016 lines
37 KiB
Python
Raw Normal View History

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:"
2021-03-09 02:53:04 +01:00
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",
2021-03-09 02:53:04 +01:00
"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",
},
2021-03-09 02:53:04 +01:00
"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):
2021-07-22 23:38:38 +02:00
# 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"]:
2021-07-22 23:38:38 +02:00
# 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)
2021-03-09 02:53:04 +01:00
# 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]
2021-03-09 02:53:04 +01:00
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(
2021-07-22 23:38:38 +02:00
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)
2021-03-09 02:53:04 +01:00
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
2021-03-09 02:53:04 +01:00
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
2021-03-09 02:53:04 +01:00
if node.astext() == CONFIGURATION_VARIABLES:
if not self.props:
self.find_props_previous_title()
if not self.props:
raise ValueError(
2021-03-09 02:53:04 +01:00
f'Found a "{CONFIGURATION_VARIABLES}" entry for unknown object after {self.previous_title_text}'
)
self.accept_props = True
2021-03-09 02:53:04 +01:00
raise nodes.SkipChildren
def depart_Text(self, node):
pass
def visit_paragraph(self, node):
2021-03-09 02:53:04 +01:00
if node.astext() == CONFIGURATION_VARIABLES:
if not self.props and not self.multi_component:
self.find_props_previous_title()
2021-03-09 02:53:04 +01:00
if not self.props and not self.multi_component:
raise ValueError(
2021-03-09 02:53:04 +01:00
f'Found a "{CONFIGURATION_VARIABLES}" entry for unknown object after {self.previous_title_text}'
)
self.accept_props = True
2021-03-09 02:53:04 +01:00
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:
2021-07-22 23:38:38 +02:00
# 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:
2021-07-22 23:38:38 +02:00
# Create docs for common properties when descriptions are overridden
2021-03-09 02:53:04 +01:00
# in the most specific component.
if k in [
"id",
"name",
"internal",
# i2c
"address",
"i2c_id",
# polling component
"update_interval",
# uart
"uart_id",
2021-07-22 23:38:38 +02:00
# 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",
]:
2021-03-09 02:53:04 +01:00
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)