1""" 2 sphinx.ext.todo 3 ~~~~~~~~~~~~~~~ 4 5 Allow todos to be inserted into your documentation. Inclusion of todos can 6 be switched of by a configuration variable. The todolist directive collects 7 all todos of your project and lists them along with a backlink to the 8 original location. 9 10 :copyright: Copyright 2007-2021 by the Sphinx team, see AUTHORS. 11 :license: BSD, see LICENSE for details. 12""" 13 14import warnings 15from typing import Any, Dict, Iterable, List, Tuple, cast 16 17from docutils import nodes 18from docutils.nodes import Element, Node 19from docutils.parsers.rst import directives 20from docutils.parsers.rst.directives.admonitions import BaseAdmonition 21 22import sphinx 23from sphinx import addnodes 24from sphinx.application import Sphinx 25from sphinx.deprecation import RemovedInSphinx40Warning 26from sphinx.domains import Domain 27from sphinx.environment import BuildEnvironment 28from sphinx.errors import NoUri 29from sphinx.locale import _, __ 30from sphinx.util import logging, texescape 31from sphinx.util.docutils import SphinxDirective, new_document 32from sphinx.util.nodes import make_refnode 33from sphinx.writers.html import HTMLTranslator 34from sphinx.writers.latex import LaTeXTranslator 35 36logger = logging.getLogger(__name__) 37 38 39class todo_node(nodes.Admonition, nodes.Element): 40 pass 41 42 43class todolist(nodes.General, nodes.Element): 44 pass 45 46 47class Todo(BaseAdmonition, SphinxDirective): 48 """ 49 A todo entry, displayed (if configured) in the form of an admonition. 50 """ 51 52 node_class = todo_node 53 has_content = True 54 required_arguments = 0 55 optional_arguments = 0 56 final_argument_whitespace = False 57 option_spec = { 58 'class': directives.class_option, 59 'name': directives.unchanged, 60 } 61 62 def run(self) -> List[Node]: 63 if not self.options.get('class'): 64 self.options['class'] = ['admonition-todo'] 65 66 (todo,) = super().run() # type: Tuple[Node] 67 if isinstance(todo, nodes.system_message): 68 return [todo] 69 elif isinstance(todo, todo_node): 70 todo.insert(0, nodes.title(text=_('Todo'))) 71 todo['docname'] = self.env.docname 72 self.add_name(todo) 73 self.set_source_info(todo) 74 self.state.document.note_explicit_target(todo) 75 return [todo] 76 else: 77 raise RuntimeError # never reached here 78 79 80class TodoDomain(Domain): 81 name = 'todo' 82 label = 'todo' 83 84 @property 85 def todos(self) -> Dict[str, List[todo_node]]: 86 return self.data.setdefault('todos', {}) 87 88 def clear_doc(self, docname: str) -> None: 89 self.todos.pop(docname, None) 90 91 def merge_domaindata(self, docnames: List[str], otherdata: Dict) -> None: 92 for docname in docnames: 93 self.todos[docname] = otherdata['todos'][docname] 94 95 def process_doc(self, env: BuildEnvironment, docname: str, 96 document: nodes.document) -> None: 97 todos = self.todos.setdefault(docname, []) 98 for todo in document.traverse(todo_node): 99 env.app.emit('todo-defined', todo) 100 todos.append(todo) 101 102 if env.config.todo_emit_warnings: 103 logger.warning(__("TODO entry found: %s"), todo[1].astext(), 104 location=todo) 105 106 107def process_todos(app: Sphinx, doctree: nodes.document) -> None: 108 warnings.warn('process_todos() is deprecated.', RemovedInSphinx40Warning, stacklevel=2) 109 # collect all todos in the environment 110 # this is not done in the directive itself because it some transformations 111 # must have already been run, e.g. substitutions 112 env = app.builder.env 113 if not hasattr(env, 'todo_all_todos'): 114 env.todo_all_todos = [] # type: ignore 115 for node in doctree.traverse(todo_node): 116 app.events.emit('todo-defined', node) 117 118 newnode = node.deepcopy() 119 newnode['ids'] = [] 120 env.todo_all_todos.append({ # type: ignore 121 'docname': env.docname, 122 'source': node.source or env.doc2path(env.docname), 123 'lineno': node.line, 124 'todo': newnode, 125 'target': node['ids'][0], 126 }) 127 128 if env.config.todo_emit_warnings: 129 label = cast(nodes.Element, node[1]) 130 logger.warning(__("TODO entry found: %s"), label.astext(), 131 location=node) 132 133 134class TodoList(SphinxDirective): 135 """ 136 A list of all todo entries. 137 """ 138 139 has_content = False 140 required_arguments = 0 141 optional_arguments = 0 142 final_argument_whitespace = False 143 option_spec = {} # type: Dict 144 145 def run(self) -> List[Node]: 146 # Simply insert an empty todolist node which will be replaced later 147 # when process_todo_nodes is called 148 return [todolist('')] 149 150 151class TodoListProcessor: 152 def __init__(self, app: Sphinx, doctree: nodes.document, docname: str) -> None: 153 self.builder = app.builder 154 self.config = app.config 155 self.env = app.env 156 self.domain = cast(TodoDomain, app.env.get_domain('todo')) 157 self.document = new_document('') 158 159 self.process(doctree, docname) 160 161 def process(self, doctree: nodes.document, docname: str) -> None: 162 todos = sum(self.domain.todos.values(), []) # type: List[todo_node] 163 for node in doctree.traverse(todolist): 164 if not self.config.todo_include_todos: 165 node.parent.remove(node) 166 continue 167 168 if node.get('ids'): 169 content = [nodes.target()] # type: List[Element] 170 else: 171 content = [] 172 173 for todo in todos: 174 # Create a copy of the todo node 175 new_todo = todo.deepcopy() 176 new_todo['ids'].clear() 177 178 self.resolve_reference(new_todo, docname) 179 content.append(new_todo) 180 181 todo_ref = self.create_todo_reference(todo, docname) 182 content.append(todo_ref) 183 184 node.replace_self(content) 185 186 def create_todo_reference(self, todo: todo_node, docname: str) -> nodes.paragraph: 187 if self.config.todo_link_only: 188 description = _('<<original entry>>') 189 else: 190 description = (_('(The <<original entry>> is located in %s, line %d.)') % 191 (todo.source, todo.line)) 192 193 prefix = description[:description.find('<<')] 194 suffix = description[description.find('>>') + 2:] 195 196 para = nodes.paragraph(classes=['todo-source']) 197 para += nodes.Text(prefix, prefix) 198 199 # Create a reference 200 linktext = nodes.emphasis(_('original entry'), _('original entry')) 201 reference = nodes.reference('', '', linktext, internal=True) 202 try: 203 reference['refuri'] = self.builder.get_relative_uri(docname, todo['docname']) 204 reference['refuri'] += '#' + todo['ids'][0] 205 except NoUri: 206 # ignore if no URI can be determined, e.g. for LaTeX output 207 pass 208 209 para += reference 210 para += nodes.Text(suffix, suffix) 211 212 return para 213 214 def resolve_reference(self, todo: todo_node, docname: str) -> None: 215 """Resolve references in the todo content.""" 216 for node in todo.traverse(addnodes.pending_xref): 217 if 'refdoc' in node: 218 node['refdoc'] = docname 219 220 # Note: To resolve references, it is needed to wrap it with document node 221 self.document += todo 222 self.env.resolve_references(self.document, docname, self.builder) 223 self.document.remove(todo) 224 225 226def process_todo_nodes(app: Sphinx, doctree: nodes.document, fromdocname: str) -> None: 227 """Replace all todolist nodes with a list of the collected todos. 228 Augment each todo with a backlink to the original location. 229 """ 230 warnings.warn('process_todo_nodes() is deprecated.', 231 RemovedInSphinx40Warning, stacklevel=2) 232 233 domain = cast(TodoDomain, app.env.get_domain('todo')) 234 todos = sum(domain.todos.values(), []) # type: List[todo_node] 235 236 for node in doctree.traverse(todolist): 237 if node.get('ids'): 238 content = [nodes.target()] # type: List[Element] 239 else: 240 content = [] 241 242 if not app.config['todo_include_todos']: 243 node.replace_self(content) 244 continue 245 246 for todo_info in todos: 247 para = nodes.paragraph(classes=['todo-source']) 248 if app.config['todo_link_only']: 249 description = _('<<original entry>>') 250 else: 251 description = ( 252 _('(The <<original entry>> is located in %s, line %d.)') % 253 (todo_info.source, todo_info.line) 254 ) 255 desc1 = description[:description.find('<<')] 256 desc2 = description[description.find('>>') + 2:] 257 para += nodes.Text(desc1, desc1) 258 259 # Create a reference 260 innernode = nodes.emphasis(_('original entry'), _('original entry')) 261 try: 262 para += make_refnode(app.builder, fromdocname, todo_info['docname'], 263 todo_info['ids'][0], innernode) 264 except NoUri: 265 # ignore if no URI can be determined, e.g. for LaTeX output 266 pass 267 para += nodes.Text(desc2, desc2) 268 269 todo_entry = todo_info.deepcopy() 270 todo_entry['ids'].clear() 271 272 # (Recursively) resolve references in the todo content 273 app.env.resolve_references(todo_entry, todo_info['docname'], app.builder) # type: ignore # NOQA 274 275 # Insert into the todolist 276 content.append(todo_entry) 277 content.append(para) 278 279 node.replace_self(content) 280 281 282def purge_todos(app: Sphinx, env: BuildEnvironment, docname: str) -> None: 283 warnings.warn('purge_todos() is deprecated.', RemovedInSphinx40Warning, stacklevel=2) 284 if not hasattr(env, 'todo_all_todos'): 285 return 286 env.todo_all_todos = [todo for todo in env.todo_all_todos # type: ignore 287 if todo['docname'] != docname] 288 289 290def merge_info(app: Sphinx, env: BuildEnvironment, docnames: Iterable[str], 291 other: BuildEnvironment) -> None: 292 warnings.warn('merge_info() is deprecated.', RemovedInSphinx40Warning, stacklevel=2) 293 if not hasattr(other, 'todo_all_todos'): 294 return 295 if not hasattr(env, 'todo_all_todos'): 296 env.todo_all_todos = [] # type: ignore 297 env.todo_all_todos.extend(other.todo_all_todos) # type: ignore 298 299 300def visit_todo_node(self: HTMLTranslator, node: todo_node) -> None: 301 if self.config.todo_include_todos: 302 self.visit_admonition(node) 303 else: 304 raise nodes.SkipNode 305 306 307def depart_todo_node(self: HTMLTranslator, node: todo_node) -> None: 308 self.depart_admonition(node) 309 310 311def latex_visit_todo_node(self: LaTeXTranslator, node: todo_node) -> None: 312 if self.config.todo_include_todos: 313 self.body.append('\n\\begin{sphinxadmonition}{note}{') 314 self.body.append(self.hypertarget_to(node)) 315 316 title_node = cast(nodes.title, node[0]) 317 title = texescape.escape(title_node.astext(), self.config.latex_engine) 318 self.body.append('%s:}' % title) 319 node.pop(0) 320 else: 321 raise nodes.SkipNode 322 323 324def latex_depart_todo_node(self: LaTeXTranslator, node: todo_node) -> None: 325 self.body.append('\\end{sphinxadmonition}\n') 326 327 328def setup(app: Sphinx) -> Dict[str, Any]: 329 app.add_event('todo-defined') 330 app.add_config_value('todo_include_todos', False, 'html') 331 app.add_config_value('todo_link_only', False, 'html') 332 app.add_config_value('todo_emit_warnings', False, 'html') 333 334 app.add_node(todolist) 335 app.add_node(todo_node, 336 html=(visit_todo_node, depart_todo_node), 337 latex=(latex_visit_todo_node, latex_depart_todo_node), 338 text=(visit_todo_node, depart_todo_node), 339 man=(visit_todo_node, depart_todo_node), 340 texinfo=(visit_todo_node, depart_todo_node)) 341 342 app.add_directive('todo', Todo) 343 app.add_directive('todolist', TodoList) 344 app.add_domain(TodoDomain) 345 app.connect('doctree-resolved', TodoListProcessor) 346 return { 347 'version': sphinx.__display_version__, 348 'env_version': 2, 349 'parallel_read_safe': True 350 } 351