# Copyright (c) 2020-now by the Zeek Project. See LICENSE for details. """ The Spicy domain for Sphinx. """ import os.path import subprocess from docutils import nodes from docutils.parsers.rst import directives from sphinx import addnodes, version_info from sphinx.directives import ObjectDescription from sphinx.directives.code import CodeBlock, LiteralInclude from sphinx.domains import Domain, ObjType from sphinx.locale import _ from sphinx.roles import XRefRole from sphinx.util.console import darkgreen, red from sphinx.util.nodes import logging, make_refnode def setup(Sphinx): Sphinx.add_domain(SpicyDomain) logger = logging.getLogger(__name__) # Wrapper for creating a tuple for index nodes, staying backwards # compatible to Sphinx < 1.4: def make_index_tuple(indextype, indexentry, targetname, targetname2): if version_info >= (1, 4, 0, "", 0): return (indextype, indexentry, targetname, targetname2, None) else: return (indextype, indexentry, targetname, targetname2) class SpicyGeneric(ObjectDescription): def add_target_and_index(self, name, sig, signode): targetname = self.objtype + "-" + name if targetname not in self.state.document.ids: signode["names"].append(targetname) signode["ids"].append(targetname) signode["first"] = not self.names self.state.document.note_explicit_target(signode) objects = self.env.domaindata["spicy"]["objects"] key = (self.objtype, name) if key in objects: self.env.warn( self.env.docname, ( f"duplicate description of {self.objtype} {name}, ", "other instance in ", self.env.doc2path(objects[key]), ), self.lineno, ) objects[key] = self.env.docname indextext = self.get_index_text(self.objtype, name) if indextext: self.indexnode["entries"].append( make_index_tuple("single", indextext, targetname, targetname) ) def get_index_text(self, objectname, name): return _("%s (%s)") % (name, self.objtype) def handle_signature(self, sig, signode): signode += addnodes.desc_name("", sig) return sig class SpicyOperator(SpicyGeneric): def handle_signature(self, sig, signode): m = sig.split() name = m[0] result = m[1].replace("~", " ") args = m[2:] if len(m) > 1 else [] op = "" postfix = "" for a in args: if a.startswith("t:"): op += a[2:].replace("~", " ") elif a.startswith("a:"): op += a[2:] elif a.startswith("op:"): op += a[3:] elif a.startswith("x:"): op += a[2:].replace("-", " ") elif a == "": op += " " elif a == "$commutative$": postfix += " (commutative)" else: op += a.replace("~", " ") signode += nodes.literal("", op) if result != "-": signode += nodes.inline("", " → ") signode += nodes.literal("", result) if postfix: signode += nodes.superscript("", postfix) return name class X(nodes.FixedTextElement): pass class SpicyMethod(SpicyGeneric): def handle_signature(self, sig, signode): m = sig.split() name = m[0] method = m[2] const = m[3] result = m[4].replace("~", " ") args = sig[sig.find("(") + 1 : -1].replace("~", " ") # try: # (ns, id) = result.split("::") # rnode = addnodes.pending_xref("", refdomain='spicy', reftype='type', reftarget=result) # rnode += nodes.literal("", id, classes=['xref']) # # except ValueError: # rnode = nodes.inline("", result) signode += nodes.literal("", f"{method}({args})") if result != "-": signode += nodes.inline("", " → ") signode += nodes.literal("", result) if const == "const": signode += nodes.inline("", " ") signode += nodes.superscript("", "(const)") return name class SpicyType(SpicyGeneric): def handle_signature(self, sig, signode): name = sig if sig.find("::") > 0: signode += nodes.literal("", name) return name class SpicyFunction(SpicyGeneric): def handle_signature(self, sig, signode): name = sig if sig.find("::") > 0: signode += nodes.strong("", name) return name class SpicyMethodXRefRole(XRefRole): def process_link(self, env, refnode, has_explicit_title, title, target): i = title.find("::") if i > 0: title = title[i + 2 :] + "()" return title, target class SpicyDomain(Domain): """Spicy domain.""" name = "spicy" label = "Spicy" object_types = { "operator": ObjType(_("operator"), "op"), "method": ObjType(_("method"), "method"), "type": ObjType(_("type"), "type"), "function": ObjType(_("function"), "function"), } directives = { "operator": SpicyOperator, "method": SpicyMethod, "type": SpicyType, "function": SpicyFunction, } roles = { "op": XRefRole(), "method": SpicyMethodXRefRole(), "type": XRefRole(), "function": XRefRole(), } initial_data = { "objects": {}, # fullname -> docname, objtype } def clear_doc(self, docname): for (type_, name), doc in list(self.data["objects"].items()): if doc == docname: del self.data["objects"][type_, name] def resolve_xref(self, env, fromdocname, builder, type_, target, node, contnode): objects = self.data["objects"] objtypes = self.objtypes_for_role(type_) for objtype in objtypes: if (objtype, target) in objects: return make_refnode( builder, fromdocname, objects[objtype, target], objtype + "-" + target, contnode, target + " " + objtype, ) def get_objects(self): for (type_, name), docname in self.data["objects"].items(): yield name, name, type_, docname, type_ + "-" + name, 1 class SpicyCode(CodeBlock): required_arguments = 0 optional_arguments = 1 option_spec = {"exec": directives.unchanged} def __init__(self, *args, **kwargs): if len(args[1]) > 0: file = "_" + args[1][0] else: file = None args = list(args) args[1] = self.arguments = ["spicy"] args[2]["lines"] = "2-" super(CodeBlock, self).__init__(*args, **kwargs) if file: self.file = self.env.relfn2path(os.path.join("examples/", file)) try: os.mkdir(os.path.dirname(self.file[1])) except FileExistsError: pass except FileNotFoundError: pass else: self.file = None def error(self, msg): self.state.document.settings.env.note_reread() msg = red(msg) logger.error(msg) return [msg] def message(self, msg): logger.info(msg) def run(self): literal = CodeBlock.run(self) language = literal[0]["language"] if not self.file: return literal text = str(literal[0][0]) if os.path.exists(self.file[1]): in_ = open(self.file[1]) in_.readline() # Skip header old = str(in_.read()) else: old = "" if text != old: self.message(f"updating {darkgreen(self.file[0])}") f = open(self.file[1], "w") f.write( "# Automatically generated; edit in Sphinx source code, not here.\n" ) f.write(text) f.close() ntext = "" include = 1 for line in text.split("\n"): if "%hide-begin%" in line: include -= 1 continue if "%hide-end%" in line: include += 1 continue if include > 0: ntext += line + "\n" ntext = ntext.strip() literal[0] = nodes.literal_block(ntext, ntext) literal[0]["language"] = language return literal class SpicyOutput(LiteralInclude): required_arguments = 1 optional_arguments = 1 option_spec = { "exec": directives.unchanged_required, "prefix": directives.unchanged, "show-as": directives.unchanged, "show-with": directives.unchanged, "expect-failure": bool, "max-lines": int, } def __init__(self, *args, **kwargs): options = args[2] self.exec_ = options["exec"].strip() self.prefix = options.get("prefix", None) self.show_as = "" self.show_with = "" self.expect_failure = "expect-failure" in options self.max_lines = options.get("max-lines", 0) if "show-with" in options: self.show_with = options["show-with"] options["show-as"] = self.exec_ if "show-as" in options: self.show_as = options.get("show-as", None) if "prefix" not in options: self.prefix = None self.content_hash = f"# Automatically generated; do not edit. -- {self.exec_}/{self.show_as}/{self.expect_failure}" source_orig = args[1][0] file = "_" + source_orig index = f"_{args[1][1]}" if len(args[1]) > 1 else "" output = f"examples/{file}.output{index}" args = list(args) args[1] = [output] args[2]["lines"] = "2-" args[2]["language"] = "text" super(LiteralInclude, self).__init__(*args, **kwargs) source = self.env.relfn2path(os.path.join("examples/", file))[0] self.update(source_orig, source, source + f".output{index}", self.exec_) def run(self): literal = LiteralInclude.run(self) if self.prefix: prefix = nodes.Text(self.prefix, self.prefix) return [prefix, literal[0]] else: return literal def update(self, source_orig, source, destination, cmd): if os.path.exists(destination) and "UPDATE_SPICY_CODE" not in os.environ: destination_time = os.path.getmtime(destination) if os.path.exists(source): source_time = os.path.getmtime(source) elif "UPDATE_SPICY_CODE" not in os.environ: return if source_time <= destination_time: hash = open(destination).readline().strip() if hash == self.content_hash: return # When running from CI, all recorded output should be up to date. # Abort if that's not the case. if "CI" in os.environ: self.error( f"error during CI: {destination} is not up to date in repository" ) return all_good = True first = True show_as = [] if self.show_as: show_as = self.show_as.split(";") for one_cmd in cmd.split(";"): one_cmd = one_cmd.strip() one_cmd = one_cmd.replace("%INPUT", source) self.message(f"executing {darkgreen(one_cmd)}") try: output = subprocess.check_output( one_cmd, shell=True, stderr=subprocess.STDOUT ) if not output: output = b"\n" if self.expect_failure: self.error("execution of '%s' expected to fail, but succeeded") all_good = False except subprocess.CalledProcessError as e: output = e.output if not self.expect_failure: self.error("execution failed: " + e.output.decode("utf8")) all_good = False if all_good: if self.max_lines > 0: lines = output.split(b"\n") if len(lines) > self.max_lines: output = ( b"\n".join(lines[: self.max_lines - 1]) + "\n[⋯]\n".encode() ) out = None if first: out = open(destination, "wb") out.write(self.content_hash.encode()) out.write(b"\n") else: out = open(destination, "ab") out.write(b"\n") if show_as: one_cmd = f"# {show_as[0].strip()}\n" one_cmd = one_cmd.replace( "%INPUT", self.show_with if self.show_with else source_orig ) output = output.replace(source.encode(), self.show_with.encode()) out.write(one_cmd.encode()) show_as = show_as[1:] out.write(output) out.close() first = False def error(self, msg): self.state.document.settings.env.note_reread() msg = red(msg) logger.error(msg) return [msg] def message(self, msg): logger.info(msg) directives.register_directive("spicy-code", SpicyCode) directives.register_directive("spicy-output", SpicyOutput)