From 51dcf8ea4fd4cd1d87bc126e34d74bf0d833afd9 Mon Sep 17 00:00:00 2001
From: Jason Kridner <jkridner@beagleboard.org>
Date: Wed, 14 Sep 2022 11:40:19 -0400
Subject: [PATCH] _ext: add callouts support

---
 _ext/callouts.py | 199 +++++++++++++++++++++++++++++++++++++++++++++++
 conf.py          |   3 +
 2 files changed, 202 insertions(+)
 create mode 100644 _ext/callouts.py

diff --git a/_ext/callouts.py b/_ext/callouts.py
new file mode 100644
index 00000000..97bb23e4
--- /dev/null
+++ b/_ext/callouts.py
@@ -0,0 +1,199 @@
+from docutils import nodes
+
+from sphinx.util.docutils import SphinxDirective
+from sphinx.transforms import SphinxTransform
+from docutils.nodes import Node
+
+# BASE_NUM = 2775  # black circles, white numbers
+BASE_NUM = 2459  # white circle, black numbers
+
+
+class CalloutIncludePostTransform(SphinxTransform):
+    """Code block post-processor for `literalinclude` blocks used in callouts."""
+
+    default_priority = 400
+
+    def apply(self, **kwargs) -> None:
+        visitor = LiteralIncludeVisitor(self.document)
+        self.document.walkabout(visitor)
+
+
+class LiteralIncludeVisitor(nodes.NodeVisitor):
+    """Change a literal block upon visiting it."""
+
+    def __init__(self, document: nodes.document) -> None:
+        super().__init__(document)
+
+    def unknown_visit(self, node: Node) -> None:
+        pass
+
+    def unknown_departure(self, node: Node) -> None:
+        pass
+
+    def visit_document(self, node: Node) -> None:
+        pass
+
+    def depart_document(self, node: Node) -> None:
+        pass
+
+    def visit_start_of_file(self, node: Node) -> None:
+        pass
+
+    def depart_start_of_file(self, node: Node) -> None:
+        pass
+
+    def visit_literal_block(self, node: nodes.literal_block) -> None:
+        if "<1>" in node.rawsource:
+            source = str(node.rawsource)
+            for i in range(1, 20):
+                source = source.replace(
+                    f"<{i}>", chr(int(f"0x{BASE_NUM + i}", base=16))
+                )
+            node.rawsource = source
+            node[:] = [nodes.Text(source)]
+
+
+class callout(nodes.General, nodes.Element):
+    """Sphinx callout node."""
+
+    pass
+
+
+def visit_callout_node(self, node):
+    """We pass on node visit to prevent the
+    callout being treated as admonition."""
+    pass
+
+
+def depart_callout_node(self, node):
+    """Departing a callout node is a no-op, too."""
+    pass
+
+
+class annotations(nodes.Element):
+    """Sphinx annotations node."""
+
+    pass
+
+
+def _replace_numbers(content: str):
+    """
+    Replaces strings of the form <x> with circled unicode numbers (e.g. â‘ ) as text.
+
+    Args:
+        content: Python str from a callout or annotations directive.
+
+    Returns: The formatted content string.
+    """
+    for i in range(1, 20):
+        content.replace(f"<{i}>", chr(int(f"0x{BASE_NUM + i}", base=16)))
+    return content
+
+
+def _parse_recursively(self, node):
+    """Utility to recursively parse a node from the Sphinx AST."""
+    self.state.nested_parse(self.content, self.content_offset, node)
+
+
+class CalloutDirective(SphinxDirective):
+    """Code callout directive with annotations for Sphinx.
+
+    Use this `callout` directive by wrapping either `code-block` or `literalinclude`
+    directives. Each line that's supposed to be equipped with an annotation should
+    have an inline comment of the form "# <x>" where x is an integer.
+
+    Afterwards use the `annotations` directive to add annotations to the previously
+    defined code labels ("<x>") by using the syntax "<x> my annotation" to produce an
+    annotation "my annotation" for x.
+    Note that annotation lines have to be separated by a new line, i.e.
+
+    .. annotations::
+
+        <1> First comment followed by a newline,
+
+        <2> second comment after the newline.
+
+
+    Usage example:
+    -------------
+
+    .. callout::
+
+        .. code-block:: python
+
+            from ray import tune
+            from ray.tune.search.hyperopt import HyperOptSearch
+            import keras
+
+            def objective(config):  # <1>
+                ...
+
+            search_space = {"activation": tune.choice(["relu", "tanh"])}  # <2>
+            algo = HyperOptSearch()
+
+            tuner = tune.Tuner(  # <3>
+                ...
+            )
+            results = tuner.fit()
+
+        .. annotations::
+
+            <1> Wrap a Keras model in an objective function.
+
+            <2> Define a search space and initialize the search algorithm.
+
+            <3> Start a Tune run that maximizes accuracy.
+    """
+
+    has_content = True
+
+    def run(self):
+        self.assert_has_content()
+
+        content = self.content
+        content = _replace_numbers(content)
+
+        callout_node = callout("\n".join(content))
+        _parse_recursively(self, callout_node)
+
+        return [callout_node]
+
+
+class AnnotationsDirective(SphinxDirective):
+    """Annotations directive, which is only used nested within a Callout directive."""
+
+    has_content = True
+
+    def run(self):
+        content = self.content
+        content = _replace_numbers(content)
+
+        joined_content = "\n".join(content)
+        annotations_node = callout(joined_content)
+        _parse_recursively(self, annotations_node)
+
+        return [annotations_node]
+
+
+def setup(app):
+    # Add new node types
+    app.add_node(
+        callout,
+        html=(visit_callout_node, depart_callout_node),
+        latex=(visit_callout_node, depart_callout_node),
+        text=(visit_callout_node, depart_callout_node),
+    )
+    app.add_node(annotations)
+
+    # Add new directives
+    app.add_directive("callout", CalloutDirective)
+    app.add_directive("annotations", AnnotationsDirective)
+
+    # Add post-processor
+    app.add_post_transform(CalloutIncludePostTransform)
+
+    return {
+        "version": "0.1",
+        "parallel_read_safe": True,
+        "parallel_write_safe": True,
+    }
diff --git a/conf.py b/conf.py
index 448c9712..8fdc7b32 100644
--- a/conf.py
+++ b/conf.py
@@ -23,7 +23,10 @@ author = 'BeagleBoard.org Foundation'
 
 # -- General configuration ---------------------------------------------------
 
+sys.path.append(os.path.abspath("./_ext"))
+
 extensions = [
+    "callouts",
     "sphinxcontrib.rsvgconverter",
     "sphinx_design"
 ]
-- 
GitLab