Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# -*- coding: utf-8 -*-
2"""
3@file
4@brief Defines a sphinx extension to give a title to a todo,
5inspired from `todo.py <https://github.com/sphinx-doc/sphinx/blob/master/sphinx/ext/todo.py>`_.
6"""
7import os
8from docutils import nodes
9from docutils.parsers.rst import directives
10from docutils.frontend import Values
12import sphinx
13from sphinx.locale import _ as locale_
14from sphinx.errors import NoUri
15from docutils.parsers.rst import Directive
16from docutils.parsers.rst.directives.admonitions import BaseAdmonition
17from sphinx.util.nodes import set_source_info, process_index_entry
18from sphinx import addnodes
19from ..texthelper.texts_language import TITLES
20from .sphinxext_helper import try_add_config_value
23class todoext_node(nodes.admonition):
24 """
25 Defines ``todoext`` node.
26 """
27 pass
30class todoextlist(nodes.General, nodes.Element):
31 """
32 Defines ``todoextlist`` node.
33 """
34 pass
37class TodoExt(BaseAdmonition):
38 """
39 A ``todoext`` entry, displayed in the form of an admonition.
40 It takes the following options:
42 * *title:* a title for the todo (mandatory)
43 * *tag:* a tag to have several categories of todo (mandatory)
44 * *issue:* the issue requires `extlinks <https://www.sphinx-doc.org/en/master/ext/extlinks.html#confval-extlinks>`_
45 to be defined and must contain key ``'issue'`` (optional)
46 * *cost:* a cost if the todo were to be fixed (optional)
47 * *priority:* to prioritize items (optional)
48 * *hidden:* if true, the todo does not appear where it is inserted but it
49 will with a todolist (optional)
50 * *date:* date (optional)
51 * *release:* release number (optional)
53 Example::
55 .. todoext::
56 :title: title for the todo
57 :tag: issue
58 :issue: issue number
60 Description of the todo
62 .. todoext::
63 :title: add option hidden to hide the item
64 :tag: done
65 :date: 2016-06-23
66 :hidden:
67 :issue: 17
68 :release: 1.4
69 :cost: 0.2
71 Once an item is done, it can be hidden from the documentation
72 and show up in a another page.
74 If the option ``issue`` is filled, the configuration must contain a key in ``extlinks``:
76 extlinks=dict(issue=('https://link/%s',
77 'issue {0} on somewhere')))
78 """
80 node_class = todoext_node
81 has_content = True
82 required_arguments = 0
83 optional_arguments = 0
84 final_argument_whitespace = False
85 option_spec = {
86 'class': directives.class_option,
87 'title': directives.unchanged,
88 'tag': directives.unchanged,
89 'issue': directives.unchanged,
90 'cost': directives.unchanged,
91 'priority': directives.unchanged,
92 'hidden': directives.unchanged,
93 'date': directives.unchanged,
94 'release': directives.unchanged,
95 'index': directives.unchanged,
96 }
98 def run(self):
99 """
100 builds the todo text
101 """
102 sett = self.state.document.settings
103 language_code = sett.language_code
104 lineno = self.lineno
106 env = self.state.document.settings.env if hasattr(
107 self.state.document.settings, "env") else None
108 docname = None if env is None else env.docname
109 if docname is not None:
110 docname = docname.replace("\\", "/").split("/")[-1]
111 legend = "{0}:{1}".format(docname, lineno)
112 else:
113 legend = ''
115 if not self.options.get('class'):
116 self.options['class'] = ['admonition-todoext']
118 # link to issue
119 issue = self.options.get('issue', "").strip()
120 if issue is not None and len(issue) > 0:
121 if hasattr(sett, "extlinks"):
122 extlinks = sett.extlinks
123 elif env is not None and hasattr(env.config, "extlinks"):
124 extlinks = env.config.extlinks
125 else: # pragma: no cover
126 available = "\n".join(sorted(sett.__dict__.keys()))
127 available2 = "\n".join(
128 sorted(env.config.__dict__.keys())) if env is not None else "-"
129 mes = ("extlinks (wih a key 'issue') is not defined in the "
130 "documentation settings, available in sett\n{0}\nCONFIG\n{1}")
131 raise ValueError( # pragma: no cover
132 mes.format(available, available2))
134 if "issue" not in extlinks:
135 raise KeyError( # pragma: no cover
136 "key 'issue' is not present in extlinks")
137 url, label = extlinks["issue"]
138 url = url % str(issue)
139 lab = label.format(issue)
140 linkin = nodes.reference(lab, locale_(lab), refuri=url)
141 link = nodes.paragraph()
142 link += linkin
143 else:
144 link = None
146 # cost
147 cost = self.options.get('cost', "").strip()
148 if cost: # pragma: no cover
149 try:
150 fcost = float(cost)
151 except ValueError:
152 raise ValueError(
153 "unable to convert cost '{0}' into float".format(cost))
154 else:
155 fcost = 0.0
157 # priority
158 prio = self.options.get('priority', "").strip()
160 # hidden
161 hidden = self.options.get('hidden', "false").strip().lower() in {
162 'true', '1', ''}
164 # body
165 (todoext,) = super(TodoExt, self).run()
166 if isinstance(todoext, nodes.system_message):
167 return [todoext]
169 # link
170 if link:
171 todoext += link
173 # title
174 title = self.options.get('title', "").strip()
175 todotag = self.options.get('tag', '').strip()
176 if len(title) > 0:
177 title = ": " + title
179 # prefix
180 prefix = TITLES[language_code]["todo"]
181 tododate = self.options.get('date', "").strip()
182 todorelease = self.options.get('release', "").strip()
183 infos = []
184 if len(todotag) > 0:
185 infos.append(todotag)
186 if len(prio) > 0:
187 infos.append('P=%s' % prio)
188 if fcost > 0:
189 if int(fcost) == fcost:
190 infos.append('C=%d' % int(fcost))
191 else:
192 infos.append('C=%1.1f' % fcost)
193 if todorelease:
194 infos.append('v{0}'.format(todorelease))
195 if tododate:
196 infos.append(tododate)
197 if infos:
198 prefix += "({0})".format(" - ".join(infos))
200 # main node
201 title = nodes.title(text=locale_(prefix + title))
202 todoext.insert(0, title)
203 todoext['todotag'] = todotag
204 todoext['todocost'] = fcost
205 todoext['todoprio'] = prio
206 todoext['todohidden'] = hidden
207 todoext['tododate'] = tododate
208 todoext['todorelease'] = todorelease
209 todoext['todotitle'] = self.options.get('title', "").strip()
210 set_source_info(self, todoext)
212 if hidden:
213 todoext['todoext_copy'] = todoext.deepcopy()
214 todoext.clear()
216 if env is not None:
217 targetid = 'indextodoe-%s' % env.new_serialno('indextodoe')
218 targetnode = nodes.target(legend, '', ids=[targetid])
219 set_source_info(self, targetnode)
220 self.state.add_target(targetid, '', targetnode, lineno)
222 # index node
223 index = self.options.get('index', None)
224 if index is not None:
225 indexnode = addnodes.index()
226 indexnode['entries'] = ne = []
227 indexnode['inline'] = False
228 set_source_info(self, indexnode)
229 for entry in index.split(","):
230 ne.extend(process_index_entry(entry, targetid))
231 else:
232 indexnode = None
233 else:
234 targetnode = None
235 indexnode = None
237 return [a for a in [indexnode, targetnode, todoext] if a is not None]
240def process_todoexts(app, doctree):
241 """
242 collect all todoexts in the environment
243 this is not done in the directive itself because it some transformations
244 must have already been run, e.g. substitutions
245 """
246 env = app.builder.env
247 if not hasattr(env, 'todoext_all_todosext'):
248 env.todoext_all_todosext = []
249 for node in doctree.traverse(todoext_node):
250 try:
251 targetnode = node.parent[node.parent.index(node) - 1]
252 if not isinstance(targetnode, nodes.target):
253 raise IndexError
254 except IndexError: # pragma: no cover
255 targetnode = None
256 newnode = node.deepcopy()
257 todotag = newnode['todotag']
258 todotitle = newnode['todotitle']
259 todoext_copy = node.get('todoext_copy', None)
260 del newnode['ids']
261 del newnode['todotag']
262 if todoext_copy is not None:
263 del newnode['todoext_copy']
264 env.todoext_all_todosext.append({
265 'docname': env.docname,
266 'source': node.source or env.doc2path(env.docname),
267 'todosource': node.source or env.doc2path(env.docname),
268 'lineno': node.line,
269 'todoext': newnode,
270 'target': targetnode,
271 'todotag': todotag,
272 'todocost': newnode['todocost'],
273 'todoprio': newnode['todoprio'],
274 'todotitle': todotitle,
275 'tododate': newnode['tododate'],
276 'todorelease': newnode['todorelease'],
277 'todohidden': newnode['todohidden'],
278 'todoext_copy': todoext_copy})
281class TodoExtList(Directive):
282 """
283 A list of all todoext entries, for a specific tag.
285 * tag: a tag to have several categories of todoext
287 Example::
289 .. todoextlist::
290 :tag: issue
291 """
293 has_content = False
294 required_arguments = 0
295 optional_arguments = 0
296 final_argument_whitespace = False
297 option_spec = {
298 'tag': directives.unchanged,
299 'sort': directives.unchanged}
301 def run(self):
302 """
303 Simply insert an empty todoextlist node which will be replaced later
304 when process_todoext_nodes is called
305 """
306 env = self.state.document.settings.env if hasattr(
307 self.state.document.settings, "env") else None
308 tag = self.options.get('tag', '').strip()
309 tsort = self.options.get('sort', '').strip()
310 if env is not None:
311 targetid = 'indextodoelist-%s' % env.new_serialno('indextodoelist')
312 targetnode = nodes.target('', '', ids=[targetid])
313 n = todoextlist('')
314 n["todotag"] = tag
315 n["todosort"] = tsort
316 return [targetnode, n]
317 else: # pragma: no cover
318 n = todoextlist('')
319 n["todotag"] = tag
320 n["todosort"] = tsort
321 return [n]
324def process_todoext_nodes(app, doctree, fromdocname):
325 """
326 process_todoext_nodes
327 """
328 if not app.config['todoext_include_todosext']:
329 for node in doctree.traverse(todoext_node):
330 node.parent.remove(node)
332 # Replace all todoextlist nodes with a list of the collected todosext.
333 # Augment each todoext with a backlink to the original location.
334 env = app.builder.env
335 if hasattr(env, "settings") and hasattr(env.settings, "language_code"):
336 lang = env.settings.language_code
337 else:
338 lang = "en"
340 orig_entry = TITLES[lang]["original entry"]
341 todomes = TITLES[lang]["todomes"]
342 allowed_tsort = {'date', 'prio', 'title', 'release', 'source'}
344 if not hasattr(env, 'todoext_all_todosext'):
345 env.todoext_all_todosext = []
347 for ilist, node in enumerate(doctree.traverse(todoextlist)):
348 if 'ids' in node:
349 node['ids'] = []
350 if not app.config['todoext_include_todosext']:
351 node.replace_self([])
352 continue
354 nbtodo = 0
355 fcost = 0
356 content = []
357 todotag = node["todotag"]
358 tsort = node["todosort"]
359 if tsort == '':
360 tsort = 'source'
361 if tsort not in allowed_tsort:
362 raise ValueError(
363 "option sort must in {0}, '{1}' is not".format(allowed_tsort, tsort))
365 double_list = [(info.get('todo%s' % tsort, ''),
366 info.get('todotitle', ''), info)
367 for info in env.todoext_all_todosext]
368 double_list.sort(key=lambda x: x[:2])
369 for n, todoext_info_ in enumerate(double_list):
370 todoext_info = todoext_info_[2]
371 if todoext_info["todotag"] != todotag:
372 continue
374 nbtodo += 1
375 fcost += todoext_info.get("todocost", 0.0)
377 para = nodes.paragraph(classes=['todoext-source'])
378 if app.config['todoext_link_only']:
379 description = locale_('<<%s>>' % orig_entry)
380 else:
381 description = (
382 locale_(todomes) %
383 (orig_entry, os.path.split(todoext_info['source'])[-1],
384 todoext_info['lineno'])
385 )
386 desc1 = description[:description.find('<<')]
387 desc2 = description[description.find('>>') + 2:]
388 para += nodes.Text(desc1, desc1)
390 # Create a reference
391 newnode = nodes.reference('', '', internal=True)
392 innernode = nodes.emphasis('', locale_(orig_entry))
393 try:
394 newnode['refuri'] = app.builder.get_relative_uri(
395 fromdocname, todoext_info['docname'])
396 try:
397 newnode['refuri'] += '#' + todoext_info['target']['refid']
398 except Exception as e: # pragma: no cover
399 raise KeyError("refid in not present in '{0}'".format(
400 todoext_info['target'])) from e
401 except NoUri: # pragma: no cover
402 # ignore if no URI can be determined, e.g. for LaTeX output
403 pass
404 newnode.append(innernode)
405 para += newnode
406 para += nodes.Text(desc2, desc2)
408 # (Recursively) resolve references in the todoext content
409 todoext_entry = todoext_info.get('todoext_copy', None)
410 if todoext_entry is None:
411 todoext_entry = todoext_info['todoext']
412 todoext_entry["ids"] = ["index-todoext-%d-%d" % (ilist, n)]
413 # it apparently requires an attributes ids
415 if not hasattr(todoext_entry, "settings"):
416 todoext_entry.settings = Values()
417 todoext_entry.settings.env = env
418 # If an exception happens here, see blog 2017-05-21 from the
419 # documentation.
420 env.resolve_references(todoext_entry, todoext_info['docname'],
421 app.builder)
423 # Insert into the todoextlist
424 content.append(todoext_entry)
425 content.append(para)
427 if fcost > 0: # pragma: no cover
428 cost = nodes.paragraph()
429 lab = "{0} items, cost: {1}".format(nbtodo, fcost)
430 cost += nodes.Text(lab)
431 content.append(cost)
432 else:
433 cost = nodes.paragraph()
434 lab = "{0} items".format(nbtodo)
435 cost += nodes.Text(lab)
436 content.append(cost)
438 node.replace_self(content)
441def purge_todosext(app, env, docname):
442 """
443 purge_todosext
444 """
445 if not hasattr(env, 'todoext_all_todosext'):
446 return
447 env.todoext_all_todosext = [todoext for todoext in env.todoext_all_todosext
448 if todoext['docname'] != docname]
451def merge_todoext(app, env, docnames, other):
452 """
453 merge_todoext
454 """
455 if not hasattr(other, 'todoext_all_todosext'):
456 return
457 if not hasattr(env, 'todoext_all_todosext'):
458 env.todoext_all_todosext = []
459 env.todoext_all_todosext.extend(other.todoext_all_todosext)
462def visit_todoext_node(self, node):
463 """
464 visit_todoext_node
465 """
466 self.visit_admonition(node)
469def depart_todoext_node(self, node):
470 """
471 depart_todoext_node,
472 see https://github.com/sphinx-doc/sphinx/blob/master/sphinx/writers/html.py
473 """
474 self.depart_admonition(node)
477def visit_todoextlist_node(self, node):
478 """
479 visit_todoextlist_node
480 see https://github.com/sphinx-doc/sphinx/blob/master/sphinx/writers/html.py
481 """
482 self.visit_admonition(node)
485def depart_todoextlist_node(self, node):
486 """
487 depart_todoext_node
488 """
489 self.depart_admonition(node)
492def setup(app):
493 """
494 Setup for ``todoext`` (sphinx).
495 """
496 if hasattr(app, "add_mapping"):
497 app.add_mapping('todoext', todoext_node)
498 app.add_mapping('todoextlist', todoextlist)
500 app.add_config_value('todoext_include_todosext', False, 'html')
501 app.add_config_value('todoext_link_only', False, 'html')
503 # The following variable is shared with extension
504 # `todo <https://www.sphinx-doc.org/en/master/usage/extensions/todo.html>`_.
505 try_add_config_value(app, 'extlinks', {}, 'env')
507 app.add_node(todoextlist,
508 html=(visit_todoextlist_node, depart_todoextlist_node),
509 epub=(visit_todoextlist_node, depart_todoextlist_node),
510 elatex=(visit_todoextlist_node, depart_todoextlist_node),
511 latex=(visit_todoextlist_node, depart_todoextlist_node),
512 text=(visit_todoextlist_node, depart_todoextlist_node),
513 md=(visit_todoextlist_node, depart_todoextlist_node),
514 rst=(visit_todoextlist_node, depart_todoextlist_node))
515 app.add_node(todoext_node,
516 html=(visit_todoext_node, depart_todoext_node),
517 epub=(visit_todoext_node, depart_todoext_node),
518 elatex=(visit_todoext_node, depart_todoext_node),
519 latex=(visit_todoext_node, depart_todoext_node),
520 text=(visit_todoext_node, depart_todoext_node),
521 md=(visit_todoext_node, depart_todoext_node),
522 rst=(visit_todoext_node, depart_todoext_node))
524 app.add_directive('todoext', TodoExt)
525 app.add_directive('todoextlist', TodoExtList)
526 app.connect('doctree-read', process_todoexts)
527 app.connect('doctree-resolved', process_todoext_nodes)
528 app.connect('env-purge-doc', purge_todosext)
529 app.connect('env-merge-info', merge_todoext)
530 return {'version': sphinx.__display_version__, 'parallel_read_safe': True}