"""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 = _('<>') else: description = (_('(The <> 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, }