Skip to content

Commit c188e3f

Browse files
authored
Restore support for nested only nodes in toctrees (#13663)
1 parent 88f7fa9 commit c188e3f

File tree

5 files changed

+192
-48
lines changed

5 files changed

+192
-48
lines changed

sphinx/environment/adapters/toctree.py

Lines changed: 73 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -482,56 +482,84 @@ def _toctree_add_classes(node: Element, depth: int, docname: str) -> None:
482482
subnode = subnode.parent
483483

484484

485-
ET = TypeVar('ET', bound=Element)
485+
_ET = TypeVar('_ET', bound=Element)
486486

487487

488488
def _toctree_copy(
489-
node: ET, depth: int, maxdepth: int, collapse: bool, tags: Tags
490-
) -> ET:
489+
node: _ET, depth: int, maxdepth: int, collapse: bool, tags: Tags
490+
) -> _ET:
491491
"""Utility: Cut and deep-copy a TOC at a specified depth."""
492-
keep_bullet_list_sub_nodes = depth <= 1 or (
493-
(depth <= maxdepth or maxdepth <= 0) and (not collapse or 'iscurrent' in node)
494-
)
492+
assert not isinstance(node, addnodes.only)
493+
depth = max(depth - 1, 1)
494+
copied = _toctree_copy_seq(node, depth, maxdepth, collapse, tags, initial_call=True)
495+
assert len(copied) == 1
496+
return copied[0] # type: ignore[return-value]
495497

496-
copy = node.copy()
497-
for subnode in node.children:
498-
if isinstance(subnode, addnodes.compact_paragraph | nodes.list_item):
499-
# for <p> and <li>, just recurse
500-
copy.append(_toctree_copy(subnode, depth, maxdepth, collapse, tags))
501-
elif isinstance(subnode, nodes.bullet_list):
502-
# for <ul>, copy if the entry is top-level
503-
# or, copy if the depth is within bounds and;
504-
# collapsing is disabled or the sub-entry's parent is 'current'.
505-
# The boolean is constant so is calculated outwith the loop.
506-
if keep_bullet_list_sub_nodes:
507-
copy.append(_toctree_copy(subnode, depth + 1, maxdepth, collapse, tags))
508-
elif isinstance(subnode, addnodes.toctree):
509-
# copy sub toctree nodes for later processing
510-
copy.append(subnode.copy())
511-
elif isinstance(subnode, addnodes.only):
512-
# only keep children if the only node matches the tags
513-
if _only_node_keep_children(subnode, tags):
514-
for child in subnode.children:
515-
copy.append(
516-
_toctree_copy(
517-
child,
518-
depth,
519-
maxdepth,
520-
collapse,
521-
tags, # type: ignore[type-var]
522-
)
523-
)
524-
elif isinstance(subnode, nodes.reference | nodes.title):
525-
# deep copy references and captions
526-
sub_node_copy = subnode.copy()
527-
sub_node_copy.children = [child.deepcopy() for child in subnode.children]
528-
for child in sub_node_copy.children:
529-
child.parent = sub_node_copy
530-
copy.append(sub_node_copy)
531-
else:
532-
msg = f'Unexpected node type {subnode.__class__.__name__!r}!'
533-
raise ValueError(msg) # NoQA: TRY004
534-
return copy
498+
499+
def _toctree_copy_seq(
500+
node: Node,
501+
depth: int,
502+
maxdepth: int,
503+
collapse: bool,
504+
tags: Tags,
505+
*,
506+
initial_call: bool = False,
507+
is_current: bool = False,
508+
) -> list[Element]:
509+
copy: Element
510+
if isinstance(node, addnodes.compact_paragraph | nodes.list_item):
511+
# for <p> and <li>, just recurse
512+
copy = node.copy()
513+
for subnode in node.children:
514+
copy += _toctree_copy_seq( # type: ignore[assignment,operator]
515+
subnode, depth, maxdepth, collapse, tags, is_current='iscurrent' in node
516+
)
517+
return [copy]
518+
519+
if isinstance(node, nodes.bullet_list):
520+
# for <ul>, copy if the entry is top-level
521+
# or, copy if the depth is within bounds and;
522+
# collapsing is disabled or the sub-entry's parent is 'current'.
523+
# The boolean is constant so is calculated outwith the loop.
524+
keep_bullet_list_sub_nodes = depth <= 1 or (
525+
(depth <= maxdepth or maxdepth <= 0)
526+
and (not collapse or is_current or 'iscurrent' in node)
527+
)
528+
if not keep_bullet_list_sub_nodes and not initial_call:
529+
return []
530+
depth += 1
531+
copy = node.copy()
532+
for subnode in node.children:
533+
copy += _toctree_copy_seq(
534+
subnode, depth, maxdepth, collapse, tags, is_current='iscurrent' in node
535+
)
536+
return [copy]
537+
538+
if isinstance(node, addnodes.toctree):
539+
# copy sub toctree nodes for later processing
540+
return [node.copy()]
541+
542+
if isinstance(node, addnodes.only):
543+
# only keep children if the only node matches the tags
544+
if not _only_node_keep_children(node, tags):
545+
return []
546+
copied: list[Element] = []
547+
for subnode in node.children:
548+
copied += _toctree_copy_seq(
549+
subnode, depth, maxdepth, collapse, tags, is_current='iscurrent' in node
550+
)
551+
return copied
552+
553+
if isinstance(node, nodes.reference | nodes.title):
554+
# deep copy references and captions
555+
sub_node_copy = node.copy()
556+
sub_node_copy.children = [child.deepcopy() for child in node.children]
557+
for child in sub_node_copy.children:
558+
child.parent = sub_node_copy
559+
return [sub_node_copy]
560+
561+
msg = f'Unexpected node type {node.__class__.__name__!r}!'
562+
raise ValueError(msg)
535563

536564

537565
def _get_toctree_ancestors(

sphinx/util/tags.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from sphinx.deprecation import RemovedInSphinx90Warning
1111

1212
if TYPE_CHECKING:
13-
from collections.abc import Iterator, Sequence
13+
from collections.abc import Collection, Iterator
1414
from typing import Literal
1515

1616
_ENV = jinja2.environment.Environment()
@@ -42,7 +42,7 @@ def parse_compare(self) -> jinja2.nodes.Expr:
4242

4343

4444
class Tags:
45-
def __init__(self, tags: Sequence[str] = ()) -> None:
45+
def __init__(self, tags: Collection[str] = ()) -> None:
4646
self._tags = set(tags or ())
4747
self._condition_cache: dict[str, bool] = {}
4848

tests/roots/test-toctree-only/conf.py

Whitespace-only changes.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
test-toctree-only
2+
=================
3+
4+
.. only:: not nonexistent
5+
6+
hello world
7+
8+
.. only:: text or not text
9+
10+
.. js:data:: test_toctree_only1
11+
12+
lorem ipsum dolor sit amet...
13+
14+
.. only:: not lorem
15+
16+
.. only:: not ipsum
17+
18+
.. js:data:: test_toctree_only2
19+
20+
lorem ipsum dolor sit amet...
21+
22+
after ``only:: not ipsum``
23+
24+
.. js:data:: test_toctree_only2
25+
26+
we're just normal men; we're just innocent men

tests/test_environment/test_environment_toctree.py

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,21 @@
44

55
from typing import TYPE_CHECKING
66

7+
import docutils
78
import pytest
89
from docutils import nodes
910
from docutils.nodes import bullet_list, list_item, literal, reference, title
1011

1112
from sphinx import addnodes
1213
from sphinx.addnodes import compact_paragraph, only
1314
from sphinx.builders.html import StandaloneHTMLBuilder
14-
from sphinx.environment.adapters.toctree import document_toc, global_toctree_for_doc
15+
from sphinx.environment.adapters.toctree import (
16+
_toctree_copy,
17+
document_toc,
18+
global_toctree_for_doc,
19+
)
1520
from sphinx.testing.util import assert_node
21+
from sphinx.util.tags import Tags
1622

1723
if TYPE_CHECKING:
1824
from sphinx.testing.util import SphinxTestApp
@@ -916,3 +922,87 @@ def test_toctree_index(app):
916922
numbered=0,
917923
entries=[(None, 'genindex'), (None, 'modindex'), (None, 'search')],
918924
)
925+
926+
927+
@pytest.mark.sphinx('dummy', testroot='toctree-only')
928+
def test_toctree_only(app):
929+
# regression test for https://github.com/sphinx-doc/sphinx/issues/13022
930+
# we mainly care that this doesn't fail
931+
932+
if docutils.__version_info__[:2] >= (0, 22):
933+
true = '1'
934+
else:
935+
true = 'True'
936+
expected_pformat = f"""\
937+
<bullet_list>
938+
<list_item>
939+
<compact_paragraph>
940+
<reference anchorname="" internal="{true}" refuri="#">
941+
test-toctree-only
942+
<bullet_list>
943+
<list_item>
944+
<compact_paragraph skip_section_number="{true}">
945+
<reference anchorname="#test_toctree_only1" internal="{true}" refuri="#test_toctree_only1">
946+
<literal>
947+
test_toctree_only1
948+
<list_item>
949+
<compact_paragraph skip_section_number="{true}">
950+
<reference anchorname="#test_toctree_only2" internal="{true}" refuri="#test_toctree_only2">
951+
<literal>
952+
test_toctree_only2
953+
<list_item>
954+
<compact_paragraph skip_section_number="{true}">
955+
<reference anchorname="#id0" internal="{true}" refuri="#id0">
956+
<literal>
957+
test_toctree_only2
958+
"""
959+
app.build()
960+
toc = document_toc(app.env, 'index', app.tags)
961+
assert toc.pformat(' ') == expected_pformat
962+
963+
964+
def test_toctree_copy_only():
965+
# regression test for https://github.com/sphinx-doc/sphinx/issues/13022
966+
# ensure ``_toctree_copy()`` properly filters out ``only`` nodes,
967+
# including nested nodes.
968+
node = nodes.literal('lobster!', 'lobster!')
969+
node = nodes.reference('', '', node, anchorname='', internal=True, refuri='index')
970+
node = addnodes.only('', node, expr='lobster')
971+
node = addnodes.compact_paragraph('', '', node, skip_section_number=True)
972+
node = nodes.list_item('', node)
973+
node = addnodes.only('', node, expr='not spam')
974+
node = addnodes.only('', node, expr='lobster')
975+
node = addnodes.only('', node, expr='not ham')
976+
node = nodes.bullet_list('', node)
977+
# this is a tree of the shape:
978+
# <bullet_list>
979+
# <only expr="not ham">
980+
# <only expr="lobster">
981+
# <only expr="not spam">
982+
# <list_item>
983+
# <compact_paragraph skip_section_number="True">
984+
# <only expr="lobster">
985+
# <reference anchorname="" internal="True" refuri="index">
986+
# <literal>
987+
# lobster!
988+
989+
tags = Tags({'lobster'})
990+
toc = _toctree_copy(node, 2, 0, False, tags)
991+
# the filtered ToC should look like:
992+
# <bullet_list>
993+
# <list_item>
994+
# <compact_paragraph skip_section_number="True">
995+
# <reference anchorname="" internal="True" refuri="index">
996+
# <literal>
997+
# lobster!
998+
999+
# no only nodes should remain
1000+
assert list(toc.findall(addnodes.only)) == []
1001+
1002+
# the tree is preserved
1003+
assert isinstance(toc, nodes.bullet_list)
1004+
assert isinstance(toc[0], nodes.list_item)
1005+
assert isinstance(toc[0][0], addnodes.compact_paragraph)
1006+
assert isinstance(toc[0][0][0], nodes.reference)
1007+
assert isinstance(toc[0][0][0][0], nodes.literal)
1008+
assert toc[0][0][0][0][0] == nodes.Text('lobster!')

0 commit comments

Comments
 (0)