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