diff options
Diffstat (limited to 'doc/arm/_ext')
-rw-r--r-- | doc/arm/_ext/iscconf.py | 605 | ||||
-rw-r--r-- | doc/arm/_ext/mergegrammar.py | 63 | ||||
-rw-r--r-- | doc/arm/_ext/namedconf.py | 35 | ||||
-rw-r--r-- | doc/arm/_ext/rndcconf.py | 36 |
4 files changed, 739 insertions, 0 deletions
diff --git a/doc/arm/_ext/iscconf.py b/doc/arm/_ext/iscconf.py new file mode 100644 index 0000000..b5bd966 --- /dev/null +++ b/doc/arm/_ext/iscconf.py @@ -0,0 +1,605 @@ +############################################################################ +# Copyright (C) Internet Systems Consortium, Inc. ("ISC") +# +# SPDX-License-Identifier: MPL-2.0 +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, you can obtain one at https://mozilla.org/MPL/2.0/. +# +# See the COPYRIGHT file distributed with this work for additional +# information regarding copyright ownership. +############################################################################ + +""" +Sphinx domains for ISC configuration files. + +Use setup() to install new Sphinx domains for ISC configuration files. + +This extension is based on combination of two Sphinx extension tutorials: +https://www.sphinx-doc.org/en/master/development/tutorials/todo.html +https://www.sphinx-doc.org/en/master/development/tutorials/recipe.html +""" + +from collections import namedtuple + +from docutils.parsers.rst import Directive, directives +from docutils import nodes + +from sphinx import addnodes +from sphinx.directives import ObjectDescription +from sphinx.domains import Domain +from sphinx.roles import XRefRole +from sphinx.util import logging +from sphinx.util.nodes import make_refnode + +import checkgrammar + + +logger = logging.getLogger(__name__) + + +def split_csv(argument, required): + argument = argument or "" + outlist = list(filter(len, (s.strip() for s in argument.split(",")))) + if required and not outlist: + raise ValueError( + "a non-empty list required; provide at least one value or remove" + " this option" + ) + return outlist + + +# pylint: disable=too-many-statements +def domain_factory(domainname, domainlabel, todolist, grammar): + """ + Return parametrized Sphinx domain object. + @param domainname Name used when referencing domain in .rst: e.g. namedconf + @param confname Humand-readable name for texts, e.g. named.conf + @param todolist A placeholder object which must be pickable. + See StatementListDirective. + """ + + class StatementListDirective(Directive): + """A custom directive to generate list of statements. + It only installs placeholder which is later replaced by + process_statementlist_nodes() callback. + """ + + option_spec = { + "filter_blocks": lambda arg: split_csv(arg, required=True), + "filter_tags": lambda arg: split_csv(arg, required=True), + } + + def run(self): + placeholder = todolist("") + placeholder["isc_filter_tags"] = set(self.options.get("filter_tags", [])) + placeholder["isc_filter_blocks"] = set( + self.options.get("filter_blocks", []) + ) + return [placeholder] + + class ISCConfDomain(Domain): + """ + Custom Sphinx domain for ISC config. + Provides .. statement:: directive to define config statement and + .. statementlist:: to generate summary tables. + :ref:`statementname` works as usual. + + See https://www.sphinx-doc.org/en/master/extdev/domainapi.html + """ + + class StatementDirective(ObjectDescription): + """ + A custom directive that describes a statement, + e.g. max-cache-size. + """ + + has_content = True + required_arguments = 1 + option_spec = { + "tags": lambda arg: split_csv(arg, required=False), + # one-sentece description for use in summary tables + "short": directives.unchanged_required, + "suppress_grammar": directives.flag, + } + + @property + def isc_name(self): + names = self.get_signatures() + if len(names) != 1: + raise NotImplementedError( + "statements with more than one name are not supported", names + ) + return names[0] + + def handle_signature(self, sig, signode): + signode += addnodes.desc_name(text=sig) + return sig + + def add_target_and_index(self, _name_cls, sig, signode): + signode["ids"].append(domainname + "-statement-" + sig) + + iscconf = self.env.get_domain(domainname) + iscconf.add_statement( + sig, self.isc_tags, self.isc_short, self.isc_short_node, self.lineno + ) + + @property + def isc_tags(self): + return set(self.options.get("tags", [])) + + @property + def isc_short(self): + return self.options.get("short", "") + + @property + def isc_short_node(self): + """Short description parsed from rst to docutils node""" + return self.parse_nested_str(self.isc_short) + + def format_path(self, path): + assert path[0] == "_top" + if len(path) == 1: + return "topmost" + return ".".join(path[1:]) + + def format_paths(self, paths): + zone_types = set() + nozone_paths = [] + for path in paths: + try: + zone_idx = path.index("zone") + zone_type_txt = path[zone_idx + 1] + if zone_type_txt.startswith("type "): + zone_types.add(zone_type_txt[len("type ") :]) + else: + assert zone_type_txt == "in-view" + zone_types.add(zone_type_txt) + except (ValueError, IndexError): + nozone_paths.append(path) + condensed_paths = nozone_paths[:] + if zone_types: + condensed_paths.append( + ("_top", "zone (" + ", ".join(sorted(zone_types)) + ")") + ) + condensed_paths = sorted(condensed_paths, key=len) + return list(self.format_path(path) for path in condensed_paths) + + def format_blocks(self, grammar_blocks): + """Generate node with list of all allowed blocks""" + blocks = nodes.paragraph() + blocks += nodes.strong(text="Blocks: ") + blocks += nodes.Text(", ".join(self.format_paths(grammar_blocks))) + return blocks + + def format_grammar(self, list_blocks, grammar_grp): + """ + Generate grammar description node, optionally with list of + blocks accepting this particular grammar. + Example: Grammar (block1, block2): grammar; + """ + grammarnode = nodes.paragraph() + if list_blocks: + separator = " " + paths = ", ".join( + self.format_paths(variant.path for variant in grammar_grp) + ) + else: + separator = "" + paths = "" + subgrammar = grammar_grp[0].subgrammar + subgrammar_txt = checkgrammar.pformat_grammar(subgrammar).strip() + grammar_txt = subgrammar.get("_pprint_name", self.isc_name) + if subgrammar_txt != ";": + grammar_txt += " " + grammar_txt += subgrammar_txt + if "\n" in grammar_txt.strip(): + nodetype = nodes.literal_block + else: + nodetype = nodes.literal + grammarnode += nodes.strong(text=f"Grammar{separator}{paths}: ") + grammarnode += nodetype(text=grammar_txt) + return grammarnode + + def format_warnings(self, flags): + """Return node with a warning box about deprecated and + experimental options""" + warn = nodes.warning() + if "deprecated" in flags: + warn += nodes.paragraph( + text=( + "This option is deprecated and will be removed in a future" + " version of BIND." + ) + ) + if "experimental" in flags: + warn += nodes.paragraph( + text="This option is experimental and subject to change." + ) + return warn + + def parse_nested_str(self, instr): + """Parse string as nested rst syntax and produce a node""" + raw = nodes.paragraph(text=instr) + parsed = nodes.paragraph() + self.state.nested_parse(raw, self.content_offset, parsed) + return parsed + + def transform_content(self, contentnode: addnodes.desc_content) -> None: + """autogenerate content from structured data""" + self.workaround_transform_content = True + if self.isc_short: + contentnode.insert(0, self.isc_short_node) + if self.isc_tags: + tags = nodes.paragraph() + tags += nodes.strong(text="Tags: ") + tags += nodes.Text(", ".join(self.isc_tags)) + contentnode.insert(0, tags) + + iscconf = self.env.get_domain(domainname) + + name = self.isc_name + if name not in iscconf.statement_blocks: + return # not defined in grammar, nothing to render + + blocks = self.format_blocks(iscconf.statement_blocks[name]) + contentnode.insert(0, blocks) + + grammars = iscconf.statement_grammar_groups[name] + multi_grammar = len(grammars) > 1 + union_flags = set() + for grammar_grp in grammars: + for one_grammar_dict in grammar_grp: + union_flags = union_flags.union( + set(one_grammar_dict.subgrammar.get("_flags", [])) + ) + if "suppress_grammar" in self.options: + continue + grammarnode = self.format_grammar(multi_grammar, grammar_grp) + contentnode.insert(0, grammarnode) + + warn = self.format_warnings(union_flags) + if len(warn): + contentnode.insert(0, warn) + + def __init__(self, *args, **kwargs): + """Compability with Sphinx < 3.0.0""" + self.workaround_transform_content = False + super().__init__(*args, **kwargs) + + def run(self): + """Compability with Sphinx < 3.0.0""" + nodelist = super().run() + if not self.workaround_transform_content: + # get access to "contentnode" created inside super.run() + self.transform_content(nodelist[1][-1]) + return nodelist + + name = domainname + label = domainlabel + + directives = { + "statement": StatementDirective, + "statementlist": StatementListDirective, + } + + roles = {"ref": XRefRole(warn_dangling=True)} + initial_data = { + # name -> {"tags": [list of tags], ...}; see add_statement() + "statements": {}, + } + + indices = {} # no custom indicies + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.grammar = grammar + self.statement_blocks = checkgrammar.statement2block(grammar, ["_top"]) + self.statement_grammar_groups = checkgrammar.diff_statements( + self.grammar, self.statement_blocks + ) + + def get_objects(self): + """ + Sphinx API: + Iterable of Sphinx object descriptions (tuples defined in the API). + """ + for obj in self.data["statements"].values(): + yield tuple( + obj[key] + for key in [ + "fullname", + "signature", + "label", + "docname", + "anchor", + "priority", + ] + ) + + # pylint: disable=too-many-arguments + def resolve_xref(self, env, fromdocname, builder, typ, target, node, contnode): + """ + Sphinx API: + Resolve the pending_xref *node* with the given typ and target. + """ + try: + obj = self.data["statements"][self.get_statement_name(target)] + except KeyError: + return None + + refnode = make_refnode( + builder, + fromdocname, + obj["docname"], + obj["anchor"], + contnode, + obj["anchor"], + ) + return refnode + + def resolve_any_xref(self, env, fromdocname, builder, target, node, contnode): + """ + Sphinx API: + Raising NotImplementedError uses fall-back bassed on resolve_xref. + """ + raise NotImplementedError + + @staticmethod + def log_statement_overlap(new, old): + assert new["fullname"] == old["fullname"] + logger.warning( + "duplicite detected! %s previously defined at %s:%d", + new["fullname"], + old["filename"], + old["lineno"], + location=(new["docname"], new["lineno"]), + ) + + def get_statement_name(self, signature): + return "{}.{}.{}".format(domainname, "statement", signature) + + def add_statement(self, signature, tags, short, short_node, lineno): + """ + Add a new statement to the domain data structures. + No visible effect. + """ + name = self.get_statement_name(signature) + anchor = "{}-statement-{}".format(domainname, signature) + + new = { + "tags": tags, + "short": short, + "short_node": short_node, + "filename": self.env.doc2path(self.env.docname), + "lineno": lineno, + # Sphinx API + "fullname": name, # internal name + "signature": signature, # display name + "label": domainlabel + " statement", # description for index + "docname": self.env.docname, + "anchor": anchor, + "priority": 1, # search priority + } + + if name in self.data["statements"]: + self.log_statement_overlap(new, self.data["statements"][name]) + self.data["statements"][name] = new + + def clear_doc(self, docname): + """ + Sphinx API: like env-purge-doc event, but in a domain. + + Remove traces of a document in the domain-specific inventories. + """ + self.data["statements"] = dict( + { + key: obj + for key, obj in self.data["statements"].items() + if obj["docname"] != docname + } + ) + + def merge_domaindata(self, docnames, otherdata): + """Sphinx API: Merge in data regarding *docnames* from a different + domaindata inventory (coming from a subprocess in parallel builds). + + @param otherdata is self.data equivalent from another process + """ + old = self.data["statements"] + new = otherdata["statements"] + for name in set(old).intersection(set(new)): + self.log_statement_overlap(new[name], old[name]) + old.update(new) + + def check_consistency(self): + """Sphinx API""" + defined_statements = set( + obj["signature"] for obj in self.data["statements"].values() + ) + statements_in_grammar = set(self.statement_blocks) + missing_statement_sigs = statements_in_grammar.difference( + defined_statements + ) + for missing in missing_statement_sigs: + grammars = self.statement_grammar_groups[missing] + if len(grammars) == 1: + flags = grammars[0][0].subgrammar.get("_flags", []) + if ("obsolete" in flags) or ("test only" in flags): + continue + + logger.warning( + "statement %s is defined in %s grammar but is not described" + " using .. statement:: directive", + missing, + domainlabel, + ) + + extra_statement_sigs = defined_statements.difference(statements_in_grammar) + for extra in extra_statement_sigs: + fullname = self.get_statement_name(extra) + desc = self.data["statements"][fullname] + logger.warning( + ".. statement:: %s found but matching definition in %s grammar is" + " missing", + extra, + domainlabel, + location=(desc["docname"], desc["lineno"]), + ) + + @classmethod + def process_statementlist_nodes(cls, app, doctree): + """ + Replace todolist objects (placed into document using + .. statementlist::) with automatically generated table + of statements. + """ + + def gen_replacement_table(acceptable_blocks, acceptable_tags): + table_header = [ + TableColumn("ref", "Statement"), + TableColumn("short_node", "Description"), + ] + tag_header = [] + if len(acceptable_tags) != 1: + # tags column only if tag filter is not applied + tag_header = [ + TableColumn("tags_txt", "Tags"), + ] + + table_b = DictToDocutilsTableBuilder(table_header + tag_header) + table_b.append_iterable( + sorted( + filter( + lambda item: ( + ( + not acceptable_tags + or item["tags"].intersection(acceptable_tags) + ) + and ( + not acceptable_blocks + or item["block_names"].intersection( + acceptable_blocks + ) + ) + ), + iscconf.list_all(), + ), + key=lambda x: x["fullname"], + ) + ) + return table_b.get_docutils() + + env = app.builder.env + iscconf = env.get_domain(cls.name) + + for node in doctree.traverse(todolist): + acceptable_tags = node["isc_filter_tags"] + acceptable_blocks = node["isc_filter_blocks"] + node.replace_self( + gen_replacement_table(acceptable_blocks, acceptable_tags) + ) + + def list_all(self): + for statement in self.data["statements"].values(): + sig = statement["signature"] + block_names = set( + path[-1] for path in self.statement_blocks.get(sig, []) + ) + tags_txt = ", ".join(statement["tags"]) + + refpara = nodes.inline() + refnode = addnodes.pending_xref( + sig, + reftype="statement", + refdomain=domainname, + reftarget=sig, + refwarn=True, + ) + refnode += nodes.Text(sig) + refpara += refnode + + copy = statement.copy() + copy["block_names"] = block_names + copy["ref"] = refpara + copy["tags_txt"] = tags_txt + yield copy + + return ISCConfDomain + + +# source dict key: human description +TableColumn = namedtuple("TableColumn", ["dictkey", "description"]) + + +class DictToDocutilsTableBuilder: + """generate docutils table""" + + def __init__(self, header): + """@param header: [ordered list of TableColumn]s""" + self.header = header + self.table = nodes.table() + self.table["classes"] += ["colwidths-auto"] + self.returned = False + # inner nodes of the table + self.tgroup = nodes.tgroup(cols=len(self.header)) + for _ in range(len(self.header)): + # ignored because of colwidths-auto, but must be present + colspec = nodes.colspec(colwidth=1) + self.tgroup.append(colspec) + self.table += self.tgroup + self._gen_header() + + self.tbody = nodes.tbody() + self.tgroup += self.tbody + + def _gen_header(self): + thead = nodes.thead() + + row = nodes.row() + for column in self.header: + entry = nodes.entry() + entry += nodes.paragraph(text=column.description) + row += entry + + thead.append(row) + self.tgroup += thead + + def append_iterable(self, objects): + """Append rows for each object (dict), ir order. + Extract column values from keys listed in self.header.""" + for obj in objects: + row = nodes.row() + for column in self.header: + entry = nodes.entry() + value = obj[column.dictkey] + if isinstance(value, str): + value = nodes.paragraph(text=value) + else: + value = value.deepcopy() + entry += value + row += entry + self.tbody.append(row) + + def get_docutils(self): + # guard against table reuse - that's most likely an error + assert not self.returned + self.returned = True + return self.table + + +def setup(app, domainname, confname, docutilsplaceholder, grammar): + """ + Install new parametrized Sphinx domain. + """ + + Conf = domain_factory(domainname, confname, docutilsplaceholder, grammar) + app.add_domain(Conf) + app.connect("doctree-read", Conf.process_statementlist_nodes) + + return { + "version": "0.1", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/doc/arm/_ext/mergegrammar.py b/doc/arm/_ext/mergegrammar.py new file mode 100644 index 0000000..b0500eb --- /dev/null +++ b/doc/arm/_ext/mergegrammar.py @@ -0,0 +1,63 @@ +############################################################################ +# Copyright (C) Internet Systems Consortium, Inc. ("ISC") +# +# SPDX-License-Identifier: MPL-2.0 +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, you can obtain one at https://mozilla.org/MPL/2.0/. +# +# See the COPYRIGHT file distributed with this work for additional +# information regarding copyright ownership. +############################################################################ + +# Depends on CWD - Sphinx plugin + +import json +from pathlib import Path + +import parsegrammar + + +def read_zone(): + zone_grammars = {} + for file in Path("../misc/").glob("*.zoneopt"): + # in-view is not really a zone type + if file.stem == "in-view": + zone_type = "in-view" + else: + zone_type = f"type {file.stem}" + + with file.open(encoding="ascii") as fp: + zonegrammar = parsegrammar.parse_mapbody(fp) + assert len(zonegrammar) == 1 + assert "zone" in zonegrammar + zone_grammars[zone_type] = zonegrammar["zone"] + zone_grammars[zone_type]["_pprint_name"] = "zone" + + return {"zone": {"_mapbody": zone_grammars, "_ignore_this_level": True}} + + +def read_main(): + with Path("../misc/options").open(encoding="ascii") as fp: + optgrammar = parsegrammar.parse_mapbody(fp) + return optgrammar + + +def combine(): + zones = read_zone() + assert zones + rest = read_main() + assert rest + rest.update(zones) + + # this is a terrible hack + # but cfg_test cannot print zone grammars inside view + rest["view"]["_mapbody"].update(zones) + + return rest + + +if __name__ == "__main__": + full_grammar = combine() + print(json.dumps(full_grammar)) diff --git a/doc/arm/_ext/namedconf.py b/doc/arm/_ext/namedconf.py new file mode 100644 index 0000000..bcf4a0c --- /dev/null +++ b/doc/arm/_ext/namedconf.py @@ -0,0 +1,35 @@ +############################################################################ +# Copyright (C) Internet Systems Consortium, Inc. ("ISC") +# +# SPDX-License-Identifier: MPL-2.0 +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, you can obtain one at https://mozilla.org/MPL/2.0/. +# +# See the COPYRIGHT file distributed with this work for additional +# information regarding copyright ownership. +############################################################################ + +""" +Sphinx domain "namedconf". See iscconf.py for details. + +""" +from docutils import nodes + +import iscconf +import mergegrammar + + +class ToBeReplacedStatementList(nodes.General, nodes.Element): + """ + Placeholder, does nothing, but must be picklable + (= cannot be in generated class). + """ + + +def setup(app): + grammar = mergegrammar.combine() + return iscconf.setup( + app, "namedconf", "named.conf", ToBeReplacedStatementList, grammar + ) diff --git a/doc/arm/_ext/rndcconf.py b/doc/arm/_ext/rndcconf.py new file mode 100644 index 0000000..cac10e2 --- /dev/null +++ b/doc/arm/_ext/rndcconf.py @@ -0,0 +1,36 @@ +############################################################################ +# Copyright (C) Internet Systems Consortium, Inc. ("ISC") +# +# SPDX-License-Identifier: MPL-2.0 +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, you can obtain one at https://mozilla.org/MPL/2.0/. +# +# See the COPYRIGHT file distributed with this work for additional +# information regarding copyright ownership. +############################################################################ + +""" +Sphinx domain "rndcconf". See iscconf.py for details. +""" + +from docutils import nodes + +import iscconf +import parsegrammar + + +class ToBeReplacedStatementList(nodes.General, nodes.Element): + """ + Placeholder, does nothing, but must be picklable + (= cannot be in a generated class). + """ + + +def setup(app): + with open("../misc/rndc.grammar", encoding="utf-8") as filein: + grammar = parsegrammar.parse_mapbody(filein) + return iscconf.setup( + app, "rndcconf", "rndc.conf", ToBeReplacedStatementList, grammar + ) |