summaryrefslogtreecommitdiffstats
path: root/sphinx/ext/todo.py
diff options
context:
space:
mode:
Diffstat (limited to 'sphinx/ext/todo.py')
-rw-r--r--sphinx/ext/todo.py246
1 files changed, 246 insertions, 0 deletions
diff --git a/sphinx/ext/todo.py b/sphinx/ext/todo.py
new file mode 100644
index 0000000..e540e7e
--- /dev/null
+++ b/sphinx/ext/todo.py
@@ -0,0 +1,246 @@
+"""Allow todos to be inserted into your documentation.
+
+Inclusion of todos can be switched of by a configuration variable.
+The todolist directive collects all todos of your project and lists them along
+with a backlink to the original location.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any, cast
+
+from docutils import nodes
+from docutils.parsers.rst import directives
+from docutils.parsers.rst.directives.admonitions import BaseAdmonition
+
+import sphinx
+from sphinx import addnodes
+from sphinx.domains import Domain
+from sphinx.errors import NoUri
+from sphinx.locale import _, __
+from sphinx.util import logging, texescape
+from sphinx.util.docutils import SphinxDirective, new_document
+
+if TYPE_CHECKING:
+ from docutils.nodes import Element, Node
+
+ from sphinx.application import Sphinx
+ from sphinx.environment import BuildEnvironment
+ from sphinx.util.typing import OptionSpec
+ from sphinx.writers.html import HTML5Translator
+ from sphinx.writers.latex import LaTeXTranslator
+
+logger = logging.getLogger(__name__)
+
+
+class todo_node(nodes.Admonition, nodes.Element):
+ pass
+
+
+class todolist(nodes.General, nodes.Element):
+ pass
+
+
+class Todo(BaseAdmonition, SphinxDirective):
+ """
+ A todo entry, displayed (if configured) in the form of an admonition.
+ """
+
+ node_class = todo_node
+ has_content = True
+ required_arguments = 0
+ optional_arguments = 0
+ final_argument_whitespace = False
+ option_spec: OptionSpec = {
+ 'class': directives.class_option,
+ 'name': directives.unchanged,
+ }
+
+ def run(self) -> list[Node]:
+ if not self.options.get('class'):
+ self.options['class'] = ['admonition-todo']
+
+ (todo,) = super().run()
+ if isinstance(todo, nodes.system_message):
+ return [todo]
+ elif isinstance(todo, todo_node):
+ todo.insert(0, nodes.title(text=_('Todo')))
+ todo['docname'] = self.env.docname
+ self.add_name(todo)
+ self.set_source_info(todo)
+ self.state.document.note_explicit_target(todo)
+ return [todo]
+ else:
+ raise RuntimeError # never reached here
+
+
+class TodoDomain(Domain):
+ name = 'todo'
+ label = 'todo'
+
+ @property
+ def todos(self) -> dict[str, list[todo_node]]:
+ return self.data.setdefault('todos', {})
+
+ def clear_doc(self, docname: str) -> None:
+ self.todos.pop(docname, None)
+
+ def merge_domaindata(self, docnames: list[str], otherdata: dict) -> None:
+ for docname in docnames:
+ self.todos[docname] = otherdata['todos'][docname]
+
+ def process_doc(self, env: BuildEnvironment, docname: str,
+ document: nodes.document) -> None:
+ todos = self.todos.setdefault(docname, [])
+ for todo in document.findall(todo_node):
+ env.app.emit('todo-defined', todo)
+ todos.append(todo)
+
+ if env.config.todo_emit_warnings:
+ logger.warning(__("TODO entry found: %s"), todo[1].astext(),
+ location=todo)
+
+
+class TodoList(SphinxDirective):
+ """
+ A list of all todo entries.
+ """
+
+ has_content = False
+ required_arguments = 0
+ optional_arguments = 0
+ final_argument_whitespace = False
+ option_spec: OptionSpec = {}
+
+ def run(self) -> list[Node]:
+ # Simply insert an empty todolist node which will be replaced later
+ # when process_todo_nodes is called
+ return [todolist('')]
+
+
+class TodoListProcessor:
+ def __init__(self, app: Sphinx, doctree: nodes.document, docname: str) -> None:
+ self.builder = app.builder
+ self.config = app.config
+ self.env = app.env
+ self.domain = cast(TodoDomain, app.env.get_domain('todo'))
+ self.document = new_document('')
+
+ self.process(doctree, docname)
+
+ def process(self, doctree: nodes.document, docname: str) -> None:
+ todos: list[todo_node] = sum(self.domain.todos.values(), [])
+ for node in list(doctree.findall(todolist)):
+ if not self.config.todo_include_todos:
+ node.parent.remove(node)
+ continue
+
+ if node.get('ids'):
+ content: list[Element] = [nodes.target()]
+ else:
+ content = []
+
+ for todo in todos:
+ # Create a copy of the todo node
+ new_todo = todo.deepcopy()
+ new_todo['ids'].clear()
+
+ self.resolve_reference(new_todo, docname)
+ content.append(new_todo)
+
+ todo_ref = self.create_todo_reference(todo, docname)
+ content.append(todo_ref)
+
+ node.replace_self(content)
+
+ def create_todo_reference(self, todo: todo_node, docname: str) -> nodes.paragraph:
+ if self.config.todo_link_only:
+ description = _('<<original entry>>')
+ else:
+ description = (_('(The <<original entry>> is located in %s, line %d.)') %
+ (todo.source, todo.line))
+
+ prefix = description[:description.find('<<')]
+ suffix = description[description.find('>>') + 2:]
+
+ para = nodes.paragraph(classes=['todo-source'])
+ para += nodes.Text(prefix)
+
+ # Create a reference
+ linktext = nodes.emphasis(_('original entry'), _('original entry'))
+ reference = nodes.reference('', '', linktext, internal=True)
+ try:
+ reference['refuri'] = self.builder.get_relative_uri(docname, todo['docname'])
+ reference['refuri'] += '#' + todo['ids'][0]
+ except NoUri:
+ # ignore if no URI can be determined, e.g. for LaTeX output
+ pass
+
+ para += reference
+ para += nodes.Text(suffix)
+
+ return para
+
+ def resolve_reference(self, todo: todo_node, docname: str) -> None:
+ """Resolve references in the todo content."""
+ for node in todo.findall(addnodes.pending_xref):
+ if 'refdoc' in node:
+ node['refdoc'] = docname
+
+ # Note: To resolve references, it is needed to wrap it with document node
+ self.document += todo
+ self.env.resolve_references(self.document, docname, self.builder)
+ self.document.remove(todo)
+
+
+def visit_todo_node(self: HTML5Translator, node: todo_node) -> None:
+ if self.config.todo_include_todos:
+ self.visit_admonition(node)
+ else:
+ raise nodes.SkipNode
+
+
+def depart_todo_node(self: HTML5Translator, node: todo_node) -> None:
+ self.depart_admonition(node)
+
+
+def latex_visit_todo_node(self: LaTeXTranslator, node: todo_node) -> None:
+ if self.config.todo_include_todos:
+ self.body.append('\n\\begin{sphinxadmonition}{note}{')
+ self.body.append(self.hypertarget_to(node))
+
+ title_node = cast(nodes.title, node[0])
+ title = texescape.escape(title_node.astext(), self.config.latex_engine)
+ self.body.append('%s:}' % title)
+ node.pop(0)
+ else:
+ raise nodes.SkipNode
+
+
+def latex_depart_todo_node(self: LaTeXTranslator, node: todo_node) -> None:
+ self.body.append('\\end{sphinxadmonition}\n')
+
+
+def setup(app: Sphinx) -> dict[str, Any]:
+ app.add_event('todo-defined')
+ app.add_config_value('todo_include_todos', False, 'html')
+ app.add_config_value('todo_link_only', False, 'html')
+ app.add_config_value('todo_emit_warnings', False, 'html')
+
+ app.add_node(todolist)
+ app.add_node(todo_node,
+ html=(visit_todo_node, depart_todo_node),
+ latex=(latex_visit_todo_node, latex_depart_todo_node),
+ text=(visit_todo_node, depart_todo_node),
+ man=(visit_todo_node, depart_todo_node),
+ texinfo=(visit_todo_node, depart_todo_node))
+
+ app.add_directive('todo', Todo)
+ app.add_directive('todolist', TodoList)
+ app.add_domain(TodoDomain)
+ app.connect('doctree-resolved', TodoListProcessor)
+ return {
+ 'version': sphinx.__display_version__,
+ 'env_version': 2,
+ 'parallel_read_safe': True,
+ }