# Copyright 2020 Johannes Hauschild, MIT license # This file is maintained at https://github.com/jhauschild/sphinx_cfg_options import re from collections import namedtuple import docutils from docutils import nodes from docutils.parsers import rst from docutils.parsers.rst import directives from docutils.statemachine import StringList import sphinx from sphinx.domains import Domain, Index, ObjType from sphinx.domains.std import StandardDomain from sphinx.errors import NoUri from sphinx.roles import XRefRole from sphinx.directives import ObjectDescription from sphinx.util.nodes import make_id, make_refnode from sphinx.util.docutils import new_document from sphinx import addnodes from sphinx.util.docutils import SphinxDirective from sphinx.util import logging logger = logging.getLogger(__name__) # ConfigEntry is used in CfgDomain.data['config'] ConfigEntry = namedtuple( 'ConfigEntry', "fullname, dispname, docname, anchor, master, nolist, includes, source, line") # OptionEntry is used in CfgDomain.data['config2options'] OptionEntry = namedtuple( 'OptionEntry', "fullname, dispname, config, docname, anchor, context, " "default, summary, summarycropped, source, line") # ObjectsEntry is returned by Domain.get_objects() ObjectsEntry = namedtuple('ObjectsEntry', "name, dispname, typ, docname, anchor, prio") # IndexEntry is retured by Index.generate() IndexEntry = namedtuple('IndexEntry', "name, subtype, docname, anchor, extra, qualifier, descr") option_header_re = re.compile(r"([\w.]+)\s*(?::\s*([^=]*))?(?:=\s*(\S+.*)\s*)?$") option_header_re_comma_sep = re.compile( r"([\w.]+(?:\s*,\s*[\w.]+)*)\s*(?::\s*([^=]*))?(?:=\s*(\S+.*)\s*)?$") directive_re = re.compile("^..\s*\w+\s*::") class cfgconfig(nodes.General, nodes.Element): """A node to be replaced by a list of options for a given `config`. The replacement happens in :meth:`ConfigNodeProcessor.process`.""" def __init__(self, config, context): super().__init__('') self.config = config self.context = context class CfgConfig(ObjectDescription): """Directive for ``.. cfg:config``, documenting a `config` = collection of options.""" objtype = "config" required_arguments = 1 optional_arguments = 0 option_spec = { 'noindex': directives.flag, 'nolist': directives.flag, 'noparse': directives.flag, 'master': directives.flag, 'context': directives.unchanged, 'include': directives.unchanged, } def handle_signature(self, sig, signode): fullname = sig signode += addnodes.desc_annotation('config ', 'config ') signode += addnodes.desc_name(sig, '', nodes.Text(sig)) return fullname def add_target_and_index(self, fullname, sig, signode): node_id = make_id(self.env, self.state.document, 'cfg-config', fullname) signode['ids'].append(node_id) self.state.document.note_explicit_target(signode) includes = [fullname] # a config always includes itself for incl in self.options.get('include', "").split(','): incl = incl.strip() if incl and incl not in includes: includes.append(incl) master = 'master' in self.options source, line = self.state_machine.get_source_and_line() config_entry = ConfigEntry(fullname=fullname, dispname=sig, docname=self.env.docname, anchor=node_id, master=master, nolist='nolist' in self.options, includes=includes, source=source, line=line) self.env.domaindata['cfg']['config'].append(config_entry) def before_content(self): if self.config.cfg_options_parse_numpydoc_style_options and 'noparse' not in self.options: self.parse_numpydoc_style_options() # save context if self.names: self.env.ref_context['cfg:config'] = self.names[-1] self.env.ref_context['cfg:in-config'] = True if 'context' in self.options: self.env.ref_context['cfg:context'] = context = self.options['context'] super().before_content() def transform_content(self, contentnode): config = self.env.ref_context['cfg:config'] if 'nolist' not in self.options: contentnode.insert(0, cfgconfig(config, self.env.ref_context['cfg:context'])) else: par = nodes.paragraph('') par += nodes.Text("See ") par += addnodes.pending_xref(config, nodes.Text(config), refdomain='cfg', reftype='config', reftarget=config) par += nodes.Text(" for a list of further options.") contentnode.insert(0, par) super().transform_content(contentnode) def after_content(self): if 'cfg:config' in self.env.ref_context: del self.env.ref_context['cfg:config'] del self.env.ref_context['cfg:in-config'] super().after_content() def run(self): context_name = self.env.temp_data.get('object', None) if isinstance(context_name, tuple): context_name = context_name[0] self.env.ref_context['cfg:context'] = context_name return super().run() def parse_numpydoc_style_options(self): self.env.app.emit('cfg_options-parse_config', self) self.content.disconnect() # avoid screwing up the parsing of the parent N = len(self.content) comma_sep = self.config.cfg_options_parse_comma_sep_names if comma_sep: header_re = option_header_re_comma_sep else: header_re = option_header_re # i = index in the lines indents = [_get_indent(line) for line in self.content] field_begin = [i for i, indent in enumerate(indents) if indent == 0] for field_beg, field_end in reversed(list(zip(field_begin, field_begin[1:] + [N]))): field_beg_line = self.content[field_beg] if directive_re.match(field_beg_line): continue # ignore other directives m = header_re.match(field_beg_line) if m is None: source, line = self.content.info(field_beg) location = "{0!s}:{1!s}".format(source, line) logger.warning("can't parse config option header-line %s", repr(field_beg_line), location=location) continue next_indent = " " # default indent, if no non-empty lines follow for j in range(field_beg + 1, field_end): if indents[j] > 0: next_indent = self.content[j][:indents[j]] break names, typ, default = m.groups() if comma_sep: # name is ','-spearated list of names. replace = [] names = names.split(',') directive = ".. cfg:option :: " replace = [directive + names[0]] for name in names[1:]: replace.append(" " * len(directive) + name) else: replace = [".. cfg:option :: " + names] if typ is not None and typ.strip(): replace.append(next_indent + ":type: " + typ) if default: replace.append(next_indent + ":default: " + default) replace.append(next_indent) field_beg_view = self.content[field_beg:field_beg + 1] field_beg_view *= len(replace) field_beg_view.data[:] = replace self.content.insert( field_end, StringList([next_indent], items=[self.content.info(field_end - 1)])) self.content[field_beg:field_beg + 1] = field_beg_view def _get_indent(line): for i, c in enumerate(line): if not c.isspace(): return i return -1 def _parse_inline(state, line, info): source = StringList([line], items=[info]) node = nodes.paragraph() state.nested_parse(source, 0, node) par = node[0] assert isinstance(node, nodes.paragraph) par = node[0] assert isinstance(node, nodes.paragraph) return par.children class CfgConfigOptions(CfgConfig): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.options['noindex'] = None def before_content(self): super().before_content() self.env.ref_context['cfg:in-config'] = False def run(self): index, desc = super().run() assert isinstance(desc, addnodes.desc) desc_content = desc[1] assert isinstance(desc_content, addnodes.desc_content) return desc_content.children[1:] #don't include the summary table/list/reference class CfgOption(ObjectDescription): """Directive for ``.. cfg:option``, documenting an option.""" objtype = "option" option_spec = { 'noindex': directives.flag, 'context': directives.unchanged, 'config': directives.unchanged, 'type': directives.unchanged, 'default': directives.unchanged, } def handle_signature(self, sig, signode): name = sig config = self.options.get('config', self.env.ref_context.get('cfg:config', "")) if not config: logger.warning("config option with unknown config", location=signode) config = "UNKNOWN" fullname = config + '.' + name signode += addnodes.desc_annotation('option ', 'option ') if not self.env.ref_context.get('cfg:in-config', False): signode += addnodes.pending_xref(sig, addnodes.desc_addname(config, config), refdomain='cfg', reftype='config', reftarget=config) signode += addnodes.desc_addname('', '.') signode += addnodes.desc_name(sig, '', nodes.Text(sig)) typ = self.options.get('type') if typ: type_node = addnodes.desc_annotation(': ', ': ') info = self.content.parent.info(1) # might be off by a few lines... type_node.extend(_parse_inline(self.state, typ, info)) signode += type_node defaultvalue = self.options.get('default') if defaultvalue: val_node = addnodes.desc_annotation(' = ', ' = ') val_node += nodes.literal(defaultvalue, defaultvalue) signode += val_node return fullname, config def add_target_and_index(self, name_config, sig, signode): fullname, config = name_config context = self.options.get('context', self.env.ref_context.get('cfg:context', None)) node_id = make_id(self.env, self.state.document, 'cfg-option', fullname) signode['ids'].append(node_id) self.state.document.note_explicit_target(signode) if 'noindex' not in self.options: source, line = self.state_machine.get_source_and_line() summary = self.content[0] if len(self.content) > 1 or len(summary) > 80: summary = summary[:75] cropped = True else: cropped = False option_entry = OptionEntry( fullname=fullname, dispname=sig, config=config, docname=self.env.docname, anchor=node_id, context=context, default=self.options.get('default', ""), summary=summary, summarycropped=cropped, source=source, line=line, ) config_entries = self.env.domaindata['cfg']['config2options'].setdefault(config, []) config_entries.append(option_entry) class CfgCurrentConfig(SphinxDirective): """ This directive is just to tell Sphinx that we're documenting options from the given config, but links to config won't lead here. """ has_content = False required_arguments = 1 optional_arguments = 0 final_argument_whitespace = False option_spec = {} def run(self): configname = self.arguments[0].strip() if configname == 'None': self.env.ref_context.pop('cfg:config', None) else: self.env.ref_context['cfg:config'] = configname return [] class ConfigNodeProcessor: def __init__(self, app, doctree, docname): self.env = app.builder.env self.builder = app.builder self.domain = app.env.get_domain('cfg') self.docname = docname self.process(doctree) def process(self, doctree): for node in doctree.traverse(cfgconfig): config = node.config context = node.context options = self.domain.config_options[config] if self.builder.config.cfg_options_summary is None: new_content = [] elif len(options) == 0: new_content = [nodes.Text("[No options defined for this config]")] elif self.builder.config.cfg_options_summary == "table": new_content = self.create_summary_table(config, context, options) elif self.builder.config.cfg_options_summary == "list": new_content = [self.create_option_reference(o, config, context) for o in options] if len(new_content) > 1: listnode = nodes.bullet_list() for entry in new_content: listnode += nodes.list_item('', entry) new_content = [listnode] else: raise ValueError("unknown value for config option `cfg_options_summary`.") node.replace_self(new_content) def create_summary_table(self, config, context, options): default_column = self.builder.config.cfg_options_default_in_summary_table table_spec = addnodes.tabular_col_spec() table = nodes.table("", classes=["longtable"]) if default_column: table_spec['spec'] = r'\X{1}{4}\X{1}{4}\X{2}{4}' group = nodes.tgroup('', cols=3) group.append(nodes.colspec('', colwidth=20)) group.append(nodes.colspec('', colwidth=20)) group.append(nodes.colspec('', colwidth=60)) else: table_spec['spec'] = r'\X{1}{4}\X{2}{4}' group = nodes.tgroup('', cols=2) group.append(nodes.colspec('', colwidth=25)) group.append(nodes.colspec('', colwidth=75)) table.append(group) if self.builder.config.cfg_options_table_add_header: header = nodes.thead('') group.append(header) row = nodes.row() row += nodes.entry("", nodes.Text("option")) if default_column: row += nodes.entry("", nodes.Text("default")) row += nodes.entry("", nodes.Text("summary")) header.append(row) body = nodes.tbody('') group.append(body) for opt in options: body += self.create_option_reference_table_row(opt, config, context) return [table_spec, table] def create_option_reference_table_row(self, option, config, context): row = nodes.row("") par = self.create_option_reference(option, config, context) row += nodes.entry("", par) if self.builder.config.cfg_options_default_in_summary_table: par = nodes.paragraph() if option.default: par += nodes.literal(option.default, option.default) row += nodes.entry("", par) par = nodes.paragraph() par += nodes.Text(option.summary) if option.summarycropped: par += self.make_refnode(option.docname, option.anchor, nodes.Text(" [...]")) row += nodes.entry("", par) return row def create_option_reference(self, option, config, context): par = nodes.paragraph() innernode = addnodes.literal_strong(option.dispname, option.dispname) par += self.make_refnode(option.docname, option.anchor, innernode) if option.config != config: par += nodes.Text(" (from ") par += self._make_config_xref(option.config) par += nodes.Text(")") if option.context is not None: opt_context = option.context if opt_context.startswith(context): opt_context = opt_context[len(context):] if opt_context: par += nodes.Text(" in ") par += addnodes.literal_emphasis(option.context, option.context) return par def make_refnode(self, docname, anchor, innernode): try: refnode = make_refnode(self.builder, self.docname, docname, anchor, innernode) except NoUri: # ignore if no URI can be determined, e.g. for LaTeX output refnode = innernode return refnode def _make_config_xref(self, config): node = nodes.Text(config, config) match = [(obj_entry.docname, obj_entry.anchor) for obj_entry in self.domain.get_objects() if obj_entry.name == config and obj_entry.typ == 'config'] if len(match) > 0: docname, anchor = match[0] node = self.make_refnode(docname, anchor, node) return node class CfgOptionIndex(Index): name = 'option' localname = 'Config Option Index' shortname = 'Config Option' def generate(self, docnames=None): config_options = self.domain.all_config_options.copy() content = [] dummy_option = OptionEntry(*([""] * 11)) for k in sorted(config_options.keys(), key=lambda x: x.upper()): options = config_options[k] if len(options) == 0: content.append((k, [])) continue index_list = [] last_name = "" for opt, next_opt in zip(options, options[1:] + [dummy_option]): name = opt.dispname if name != last_name: if name == next_opt.dispname: index_list.append( IndexEntry(name, 1, opt.docname, opt.anchor, "mulitple definitions", "", "")) subtype = 2 else: subtype = 0 ind_entry = IndexEntry(name=opt.dispname, subtype=subtype, docname=opt.docname, anchor=opt.anchor, extra=opt.context, qualifier='', descr=opt.config) index_list.append(ind_entry) last_name = name content.append((k, index_list)) return (content, True) class CfgConfigIndex(Index): name = 'config' localname = 'Config Index' shortname = 'Config Index' def generate(self, docnames=None): master_configs = self.domain.master_configs data_configs = self.domain.data['config'] content = {} for key in sorted(master_configs.keys(), key=lambda k: k.upper()): index_list = content.setdefault(key[0].upper(), []) data = [config for config in data_configs if config.fullname == key] master = master_configs[key] if len(data) > 1: index_list.append( IndexEntry(master.dispname, 1, master.docname, master.anchor, "master", "includes", ', '.join(master.includes))) for config in data: index_list.append( IndexEntry(config.dispname, 2, config.docname, config.anchor, "", "includes", ', '.join(master.includes))) else: index_list.append( IndexEntry(master.dispname, 0, master.docname, master.anchor, "", "includes", ', '.join(master.includes))) res = [(k, content[k]) for k in sorted(content.keys())] return (res, True) class CfgDomain(Domain): name = 'cfg' label = 'Parameter Configs' obj_types = { 'config': ObjType('config', 'config'), 'option': ObjType('option', 'option'), } roles = { 'config': XRefRole(), 'option': XRefRole(), } directives = { 'config': CfgConfig, 'configoptions': CfgConfigOptions, 'currentconfig': CfgCurrentConfig, 'option': CfgOption, } indices = { CfgConfigIndex, CfgOptionIndex, } initial_data = { 'config': [], # ConfigEntry 'config2options': {}, # config_name -> List[OptionEntry] } def clear_doc(self, docname): self.data['config'] = [entry for entry in self.data['config'] if entry.docname != docname] for config_name, entries_list in self.data['config2options'].items(): filered_entries = [entry for entry in entries_list if entry.docname != docname] self.data['config2options'][config_name] = filered_entries def get_objects(self): for config_entry in self.data['config']: yield ObjectsEntry(config_entry.fullname, config_entry.dispname, 'config', config_entry.docname, config_entry.anchor, prio=0 if config_entry.master else 1) for option_list in self.data['config2options'].values(): for option_entry in option_list: yield ObjectsEntry(option_entry.fullname, option_entry.dispname, 'config', option_entry.docname, option_entry.anchor, prio=1) def resolve_xref(self, env, fromdocname, builder, typ, target, node, contnode): if not target: return None if typ == "config": config = self.master_configs.get(target, None) if config is None: return None return make_refnode(builder, fromdocname, config.docname, config.anchor, contnode, config.dispname) elif typ == "option": config_options = self.config_options split = target.split('.') if len(split) < 2: return None for i in range(1, len(split)): config, entry_name = '.'.join(split[:i]), '.'.join(split[i:]) for option_entry in config_options.get(config, []): if option_entry.dispname == entry_name: # match! return make_refnode(builder, fromdocname, option_entry.docname, option_entry.anchor, contnode, option_entry.dispname) return None return None @property def master_configs(self): """dict config_name -> ConfigEntry, with recursive `includes`.""" if not hasattr(self, '_master_configs'): self._build_master_configs() return self._master_configs @property def config_options(self): """dict config_name -> List[OptionEntry], taking into account recursive `includes`. If `cfg_options_unique` is True, the list is filtered to include each option name only once. """ if not hasattr(self, '_config_options'): self._build_config_options() return self._config_options @property def all_config_options(self): """same as `config_options`""" if not hasattr(self, '_all_config_options'): self._build_config_options() return self._all_config_options def _build_master_configs(self): """build recursive configs from "flat" configs in self.data""" self._master_configs = master_configs = {} data_config = self.data['config'] # collect master configs for config_entry in data_config: if config_entry.master: other = master_configs.get(config_entry.fullname, None) if other: logger.warning( "two 'cfg:config' objects %s with ':master:' " "in %s, line %d and %s, line %d", config_entry.fullname, config_entry.source, config_entry.line, other.source, other.line) master_configs[config_entry.fullname] = config_entry # if no master is given: master = first defined entry without :nolist: option for config_entry in data_config: if not config_entry.nolist: master_configs.setdefault(config_entry.fullname, config_entry) # unless we don't even have an entry without :nolist: for config_entry in data_config: master_configs.setdefault(config_entry.fullname, config_entry) # collect the includes from other entries in `data_config` # and make sure that we only have valid includes for config_entry in data_config: name = config_entry.fullname master = master_configs[name] for incl in config_entry.includes[:]: if incl not in master_configs: logger.warning( "config '%s' defined in %s, line %d includes " "unknown (not indexed) config '%s'", name, config_entry.source, config_entry.line, incl) try: master.includes.remove(incl) except ValueError: pass # not in list: okay.. elif incl not in master.includes: master.includes.append(incl) # make includes recursive if self.env.config.cfg_options_recursive_includes: handled_recursive = set([]) for config in master_configs.keys(): self._set_recursive_include(config, handled_recursive) always_include = self.env.config.cfg_options_always_include for incl in always_include: for master in master_configs.values(): if incl not in master.includes: master.includes.append(incl) return master_configs def _build_config_options(self): master_configs = self.master_configs self._config_options = config_options = {} data_config2options = self.data['config2options'] config_names = set(master_configs.keys()).union(set(data_config2options.keys())) self._all_config_options = {} for config in config_names: includes = [config] master_config = master_configs.get(config, None) if master_config: includes = master_config.includes else: # config not in master_config, i.e. no config of that name indexed # => config likely only defined through an option directive! for option in data_config2options.get(config, []): assert option.config == config logger.warning( "`cfg:option` '%s' in %s, line %d, belongs to a" " non-indexed, unknown config %s (-> Typo?)", option.dispname, option.source, option.line, option.config) prio = dict((incl, i) for i, incl in enumerate(includes)) options = [] for config_incl in includes: options.extend(data_config2options.get(config_incl, [])) def sort_priority(option_entry): return (option_entry.dispname.lower(), prio[option_entry.config]) options = sorted(options, key=sort_priority) self._all_config_options[config] = options if self.env.config.cfg_options_unique: new_options = [] last = "" for option in options: if option.dispname != last: new_options.append(option) last = option.dispname options = new_options config_options[config] = options def _set_recursive_include(self, config, handled_recursive): includes = self.master_configs[config].includes if config in handled_recursive: return includes handled_recursive.add(config) # before doing it: safeguard for cyclic includes new_includes = [config] for sub in includes: if sub not in new_includes: new_includes.append(sub) subincludes = self._set_recursive_include(sub, handled_recursive) for subincl in subincludes: if subincl not in new_includes: new_includes.append(subincl) includes[:] = new_includes return new_includes def setup(app): app.add_event('cfg_options-parse_config') app.add_config_value('cfg_options_recursive_includes', True, 'html') app.add_config_value('cfg_options_parse_numpydoc_style_options', True, 'html') app.add_config_value('cfg_options_parse_comma_sep_names', False, 'html') app.add_config_value('cfg_options_summary', "table", 'html') app.add_config_value('cfg_options_table_add_header', True, 'html') app.add_config_value('cfg_options_default_in_summary_table', True, 'html') app.add_config_value('cfg_options_unique', True, 'html') app.add_config_value('cfg_options_always_include', [], 'html') app.add_domain(CfgDomain) app.add_node(cfgconfig) app.connect('doctree-resolved', ConfigNodeProcessor) StandardDomain.initial_data['labels']['cfg-config-index'] =\ ('cfg-config', '', 'Config Index') StandardDomain.initial_data['labels']['cfg-option-index'] =\ ('cfg-option', '', 'Config-Options Index') return {'version': '0.1'}