# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Collapse the repeated entries `sphinx.ext.todo` accumulates on a `todolist`.
`sphinx.ext.todo` collects doctree nodes, not documented objects. A `{todo}`
written once in a docstring therefore lands on the `todolist` page once per
*rendering* of that docstring, and two conventions common to autodoc projects
render the same docstring several times:
- **A full-API page plus per-feature pages.** A project documenting every
module on one page, then documenting the same modules again next to the
prose that explains them, renders each docstring twice. `:no-index:` on the
second block does not help: it suppresses the cross-reference target and the
search-index entry, leaving the docstring (and its `todo` node) rendered in
full.
- **A package re-exporting its members.** `automodule` documents the imported
names a package lists in `__all__`, so a symbol appears once under the
package and once under the module that defines it. Both renderings can even
land on the same page.
The two multiply. On click-extra's own documentation the untreated list showed
35 entries for 17 distinct `{todo}` directives, one of them repeated four
times.
Nothing upstream deduplicates: {class}`sphinx.ext.todo.TodoListProcessor`
flattens the whole `todo` domain into the page in read order. This module
removes the surplus nodes from that domain just before the processor reads it,
so the rendered list carries one entry per directive.
```{todo}
Propose the deduplication upstream, as a `sphinx.ext.todo` feature rather
than a third-party hook.
The repetition is a property of how autodoc renders a docstring, not of how
a project writes one, so every autodoc project documenting a module twice
hits it and none of them can fix it in their own source: `:no-index:` reads
like the cure and is not.
{class}`sphinx.ext.todo.TodoListProcessor` already flattens the whole domain
in one place, which is where a `todo_deduplicate` config value would apply;
the two helpers this module needed ({func}`todo_identity` and
{func}`is_reexport`) are the whole of the logic.
Should it land, keep this module as a shim for the Sphinx releases below
that floor, then drop it once the floor moves past them.
```
"""
from __future__ import annotations
from pathlib import Path
from sphinx.ext.todo import todo_node, todolist
from sphinx.util import logging
TYPE_CHECKING = False
if TYPE_CHECKING:
from docutils import nodes
from sphinx.application import Sphinx
logger = logging.getLogger(__name__)
AUTODOC_DOCSTRING_MARKER = ":docstring of "
"""Separator autodoc puts between a source file and the object it documented.
A node produced from a docstring carries a synthetic source of the form
`{file}:docstring of {dotted.path}`, where `{file}` is the file the *reader*
reached the object through, not necessarily the one defining it. Splitting on
this marker is what lets {func}`todo_identity` recognize two renderings of a
single docstring reached through two import paths.
"""
DEDUPE_TODOS_CONFIG = "click_extra_dedupe_todos"
"""Name of the `conf.py` value gating the deduplication.
`True` by default: a `todolist` page listing the same item three times is a
defect in every project I know of, and a project that has not enabled
`sphinx.ext.todo` never notices the hook either way. Set it to `False` to get
Sphinx's raw output back, one entry per rendering.
"""
[docs]
def todo_identity(node: todo_node) -> tuple[str, int | None]:
"""Identify the `{todo}` directive a node was rendered from.
Two nodes share an identity when they come from the same line of the same
docstring or document, whatever page rendered them and whichever import
path autodoc reached the object through. The file prefix is dropped from a
docstring source precisely so a re-exported symbol matches the module that
defines it.
"""
source = node.source or ""
_, marker, dotted_path = source.partition(AUTODOC_DOCSTRING_MARKER)
return (dotted_path if marker else source), node.line
[docs]
def is_reexport(node: todo_node) -> bool:
"""Tell whether a node was rendered through a package's `__init__`.
Used to rank competing renderings: the entry surviving deduplication keeps
its backlink and its "located in ..." attribution, so a rendering reached
through the defining module is worth more to a reader than the same
docstring reached through a re-exporting package.
"""
source = node.source or ""
file_path, marker, _ = source.partition(AUTODOC_DOCSTRING_MARKER)
return bool(marker) and Path(file_path).stem == "__init__"
[docs]
def deduplicate_todos(app: Sphinx, doctree: nodes.document, docname: str) -> None:
"""Drop every `todo` node duplicating one already held by the domain.
Connected to `doctree-resolved` below the priority
{class}`sphinx.ext.todo.TodoListProcessor` runs at, and a no-op on any
document holding no `todolist`, so the work happens once on the page that
consumes the domain.
Among the renderings of one directive, the surviving node is the first in
`(rendered through a defining module, document name, position in the
document)` order. That ordering is total and reads the same on a clean
build and an incremental one, so the backlink a reader follows does not
move around between builds.
```{note}
The `todo` domain is mutated in place. That is safe because Sphinx pickles
the environment at the end of the reading phase, before the writing phase
this hook belongs to: the removals reach the page being written and never
the cached environment, so an incremental rebuild still starts from the
full set.
```
"""
if not getattr(app.config, DEDUPE_TODOS_CONFIG, True):
return
# `sphinx.ext.todo` may not be among the project's extensions, in which
# case no domain was ever registered and no page can hold a todolist.
domain = app.env.domains.get("todo")
if domain is None:
return
# Skip every page that does not consume the domain.
if next(doctree.findall(todolist), None) is None:
return
winners: dict[tuple[str, int | None], tuple[bool, str, int]] = {}
for name in sorted(domain.todos):
for position, node in enumerate(domain.todos[name]):
identity = todo_identity(node)
rank = (is_reexport(node), name, position)
current = winners.get(identity)
if current is None or rank < current:
winners[identity] = rank
keep = {(name, position) for _, name, position in winners.values()}
removed = 0
for name, todos in domain.todos.items():
kept = [node for position, node in enumerate(todos) if (name, position) in keep]
removed += len(todos) - len(kept)
if len(kept) != len(todos):
domain.todos[name] = kept
if removed:
logger.info(
"click_extra.sphinx.todos: collapsed %d duplicate todo %s into "
"%d unique %s on %s.",
removed,
"entries" if removed > 1 else "entry",
len(winners),
"directives" if len(winners) > 1 else "directive",
docname,
)
[docs]
def setup(app: Sphinx) -> None:
"""Register the deduplication hook on `app`.
Called from {func}`click_extra.sphinx.setup` so projects only need to list
`"click_extra.sphinx"` in their `extensions`. Priority 400 places the hook
below the default 500 {class}`sphinx.ext.todo.TodoListProcessor` is
connected at, which is what makes the domain already trimmed by the time
the list is rendered.
"""
app.add_config_value(
DEDUPE_TODOS_CONFIG, default=True, rebuild="html", types=[bool]
)
app.connect("doctree-resolved", deduplicate_todos, priority=400)