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`` # - ``other value`` # 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)