Tree.py
Summary
- Lines:
219 of 219 covered (100.0%)
- Branches:
98 of 98 taken (100.0%), 0 lines partially
- Coverage:
100.0%
- Directory:
Source
pyTooling/Sphinx/Tree.pycoveredpartialuncoveredexcludednot executable
1# ==================================================================================================================== #
2# _____ _ _ ____ _ _ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ / ___| _ __ | |__ (_)_ __ __ __ #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | \___ \| '_ \| '_ \| | '_ \\ \/ / #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_ ___) | |_) | | | | | | | |> < #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____/| .__/|_| |_|_|_| |_/_/\_\ #
7# |_| |___/ |___/ |_| #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2026-2026 Patrick Lehmann - Bötzingen, Germany #
15# #
16# Licensed under the Apache License, Version 2.0 (the "License"); #
17# you may not use this file except in compliance with the License. #
18# You may obtain a copy of the License at #
19# #
20# http://www.apache.org/licenses/LICENSE-2.0 #
21# #
22# Unless required by applicable law or agreed to in writing, software #
23# distributed under the License is distributed on an "AS IS" BASIS, #
24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
25# See the License for the specific language governing permissions and #
26# limitations under the License. #
27# #
28# SPDX-License-Identifier: Apache-2.0 #
29# ==================================================================================================================== #
30#
31"""
32A Sphinx directive drawing a hierarchy as a tree: a directory, a class hierarchy, the structure of a testsuite.
33
34Written as text in a code block, a hierarchy needs its lines drawn by hand, can't link to what it shows, and can't
35be folded. The ``tree`` directive takes the hierarchy as an indented list in the style of a YAML block sequence, and
36draws the lines itself:
37
38.. code-block:: ReST
39
40 .. tree::
41 :root-icon: 📦
42 :node-icon: 📁
43 :leaf-icon: 📄
44 :icons: > 📁
45
46 - pyTooling
47 - Tree
48 - :class:`~pyTooling.Tree.Node`
49 > Graph
50 - README.md
51
52**An entry is a line starting with a marker** and a space, and an entry indented deeper than the one above it is that
53entry's child. An entry's text is inline ReST, so a role such as ``:class:`` or ``:ref:`` links to what the entry names.
54
55.. rubric:: Descriptions
56
57**A** ``|`` **between spaces separates an entry's text from its description**, which is inline ReST too. HTML draws
58the descriptions as a second column: all of them start at the same horizontal position, whatever the entry's level.
59An entry without a description stays valid:
60
61.. code-block:: ReST
62
63 .. tree::
64
65 - CoverageSummary | the report
66 - Directory | e.g. ``myPackage/``
67 - File | e.g. ``myPackage/Shapes.py``
68 - Package
69
70A ``|`` within a literal or a role's text, or escaped as ``\\|``, separates nothing, and neither does a substitution
71reference such as ``|br|``.
72
73.. rubric:: Roots, nodes and leaves
74
75An entry without a parent is a root, an entry with children is a node, and every other entry is a leaf. An entry
76marked ``-`` gets the icon of its kind. Option ``:icons:`` declares more markers, each with its own icon, whatever the
77entry's kind is - e.g. a folder for an empty directory, which is a leaf.
78
79.. rubric:: Legend
80
81**An icon option may explain its icon**, behind a ``|`` between spaces: ``:leaf-icon: 📄 | file``. Option ``:icons:``
82then declares one marker per line. The texts form the tree's legend, which HTML draws below the tree - or above it, as
83option ``:legend:`` chooses:
84
85.. code-block:: ReST
86
87 .. tree::
88 :legend: top
89 :root-icon: 📦 | package
90 :node-icon: 📁 | directory
91 :leaf-icon: 📄 | file
92 :icons: > ⚙️ | settings
93 # 🐍 | Python module
94
95.. rubric:: HTML and other formats
96
97HTML draws an entry with children as a ``<details>`` element, so a reader folds and unfolds it without JavaScript;
98the stylesheet draws the lines and shows the expanded or the collapsed icon. Every other format - e.g. LaTeX - finds
99no visitor for :class:`~pyTooling.Sphinx.Node.TreeItem` and :class:`~pyTooling.Sphinx.Node.TreeLabel` and renders
100their base-classes instead: a nested bullet list without icons, with a description behind its entry's text and an en
101dash. These formats have no legend, as their trees show no icons: :class:`RemoveTreeLegends` removes it.
102
103.. seealso::
104
105 :ref:`DIR/Tree`
106 |rarr| The directive's options, with rendered examples.
107 :mod:`pyTooling.Sphinx`
108 |rarr| The extension this belongs to, and what else it brings.
109"""
110from re import compile as re_compile
111from typing import Any, Iterable, Optional as Nullable
112from unicodedata import category, east_asian_width
113
114from docutils import nodes
115from docutils.parsers.rst import directives
116from sphinx.transforms.post_transforms import SphinxPostTransform
117
118from pyTooling.Common import StringEnum
119from pyTooling.Decorators import export
120from pyTooling.Tree import Node
121from pyTooling.Sphinx import BaseDirective, SphinxExtensionError, strip
122from pyTooling.Sphinx.Node import TreeDescription, TreeItem, TreeLabel, TreeLegend, TreeLegendItem
123from pyTooling.Sphinx.Node import TreeSeparator
124
125
126__all__ = [
127 "DEFAULT_MARKER", "DEFAULT_ICONS", "CODE_POINT_PATTERN", "SEPARATOR_PATTERN", "SEPARATOR"
128]
129
130#: The marker of an entry whose icon is the one of its kind.
131DEFAULT_MARKER = "-"
132
133#: The icons drawn when the directive's options don't state one, keyed by the option's name.
134#:
135#: Roots, nodes and leaves have none, so a tree shows only its lines; a node with children shows whether it is
136#: expanded.
137DEFAULT_ICONS = {
138 "root-icon": "",
139 "node-icon": "",
140 "leaf-icon": "",
141 "expanded-icon": "▾",
142 "collapsed-icon": "▸",
143}
144
145#: A word of an icon option written as a Unicode code point, e.g. ``U+1F4C1``.
146CODE_POINT_PATTERN = re_compile(r"U\+([0-9A-Fa-f]{1,6})")
147
148#: The separator between an entry's text and its description, as group ``separator``: a ``|`` after whitespace and
149#: before whitespace or the line's end.
150#:
151#: The other alternatives match what the separator isn't searched in - an escaped character, an inline literal, and
152#: interpreted text such as a role's -, so a ``|`` in them separates nothing.
153SEPARATOR_PATTERN = re_compile(r"\\.|``.*?``|`(?:\\.|[^`\\])*`|(?<=\s)(?P<separator>\|)(?=\s|$)")
154
155#: What a format without columns writes between an entry's text and its description: an en dash between spaces.
156SEPARATOR = " \u2013 "
157
158_Entry = Node
159"""
160An entry of the content: its text as the value, the index of its line, its marker and its description as key-value
161pairs.
162"""
163
164
165@export
166def icon(option: Nullable[str]) -> str:
167 """
168 Option converter reading an icon: characters as written, and a word ``U+<hex>`` as the code point it names.
169
170 A code point keeps a document readable in an editor whose font lacks the icon's glyph.
171
172 :param option: The option's value as it was written; ``None`` if the option has no value.
173 :returns: The icon's characters; empty if the option has no value, which draws no icon.
174 :raises ValueError: If a word names a code point beyond U+10FFFF.
175 :raises ValueError: If a word names a surrogate code point, which is no character.
176 """
177 if option is None:
178 return ""
179
180 characters = []
181 for word in option.split():
182 if (match := CODE_POINT_PATTERN.fullmatch(word)) is None:
183 characters.append(word)
184 elif (codePoint := int(match[1], 16)) > 0x10ffff:
185 raise ValueError(f"'{word}' is beyond the last code point U+10FFFF.")
186 elif 0xd800 <= codePoint <= 0xdfff:
187 raise ValueError(f"'{word}' is a surrogate code point, which is no character.")
188 else:
189 characters.append(chr(codePoint))
190
191 return "".join(characters)
192
193
194@export
195def describedIcon(option: Nullable[str]) -> tuple[str, str]:
196 """
197 Option converter reading an icon and the text explaining it in the tree's legend: ``<icon> | <text>``.
198
199 The icon is read by :func:`icon`. The text follows a separator ``|`` between spaces - see :data:`SEPARATOR_PATTERN`
200 -, and is inline ReST. An icon without a text has no item in the legend.
201
202 :param option: The option's value as it was written; ``None`` if the option has no value.
203 :returns: The icon's characters and the text; empty if the option has no value or no text.
204 :raises ValueError: If the value has more than one separator ``|``.
205 :raises ValueError: If the value has a text, but no icon.
206 """
207 if option is None:
208 return "", ""
209
210 value = f" {option}" # a separator at the start follows a space too
211 separators = [match.start() for match in SEPARATOR_PATTERN.finditer(value) if match["separator"] is not None]
212 if len(separators) > 1:
213 raise ValueError(f"'{option.strip()}' has more than one separator ' | '; a '|' in a text is escaped as '\\|'.")
214 elif len(separators) == 0:
215 return icon(option), ""
216
217 characters = icon(value[:separators[0]])
218 text = value[separators[0] + 1:].strip()
219 if characters == "" and text != "":
220 raise ValueError(f"'{option.strip()}' has a text, but no icon.")
221
222 return characters, text
223
224
225@export
226class LegendPosition(StringEnum):
227 """
228 Where a tree's legend is drawn, as option ``:legend:`` of the ``tree`` directive states it.
229
230 The legend gets the CSS class ``tree-legend-<value>``.
231 """
232
233 Top = "top" #: Above the tree.
234 Bottom = "bottom" #: Below the tree.
235 Hidden = "none" #: Not at all.
236
237 DEFAULT = Bottom #: An option without a value draws the legend below the tree.
238
239
240@export
241def markerIcons(option: Nullable[str]) -> dict[str, tuple[str, str]]:
242 """
243 Option converter reading the markers an entry may start with, each with its icon and the text explaining it in the
244 tree's legend: ``<marker> <icon>``, separated by commas, or ``<marker> <icon> | <text>``, one per line.
245
246 A marker is one character, neither a letter, a digit, a comma nor a space. An icon and its text are read by
247 :func:`describedIcon`. A line with a separator ``|`` holds one marker, so a text may contain commas; a line without
248 one is split at its commas.
249
250 :param option: The option's value as it was written; ``None`` if the option has no value.
251 :returns: The icons and their texts, keyed by their marker, in the order declared.
252 :raises ValueError: If an item isn't a marker followed by a space and an icon.
253 :raises ValueError: If a marker is a letter, a digit or a comma.
254 :raises ValueError: If a marker is declared twice.
255 :raises ValueError: If an item has more than one separator ``|``.
256 """
257 if option is None:
258 return {}
259
260 icons: dict[str, tuple[str, str]] = {}
261 for line in option.splitlines():
262 if any(match["separator"] is not None for match in SEPARATOR_PATTERN.finditer(line)):
263 items = [line]
264 else:
265 items = line.split(",")
266
267 for item in items:
268 if (item := item.strip()) == "":
269 continue
270
271 marker = item[0]
272 separator = item[1:2]
273 value = item[2:]
274 if separator.strip() != "" or value.strip() == "":
275 raise ValueError(f"'{item}' is not a marker followed by a space and an icon.")
276 elif marker.isalnum():
277 raise ValueError(f"Marker '{marker}' is a letter or a digit.")
278 elif marker in icons:
279 raise ValueError(f"Marker '{marker}' is declared twice.")
280
281 icons[marker] = describedIcon(value)
282
283 return icons
284
285
286@export
287class Tree(BaseDirective):
288 """
289 The ``tree`` directive: a hierarchy, written as an indented list, drawn as a tree.
290
291 :meth:`_ParseEntries` reads the content into :class:`~pyTooling.Tree.Node` instances, one tree per root, and
292 :meth:`_BuildItem` turns them into nested bullet lists of :class:`~pyTooling.Sphinx.Node.TreeItem` and
293 :class:`~pyTooling.Sphinx.Node.TreeLabel`. The options choose the icons and how many levels are expanded initially;
294 ``:class:`` puts additional CSS classes on the tree. :meth:`_BuildLegend` turns the texts of the icons into the
295 tree's legend, which ``:legend:`` places above or below the tree.
296
297 If an entry has a description, every entry's text ends with a :class:`~pyTooling.Sphinx.Node.TreeDescription`, and
298 :meth:`_ColumnWidth` estimates how wide the column of the texts in front of the descriptions is.
299 """
300
301 directiveName: str = "tree" #: Name the directive is invoked by.
302
303 has_content = True #: A boolean; ``True`` if content is allowed.
304 required_arguments = 0 #: Number of required directive arguments.
305 optional_arguments = 0 #: Number of optional arguments after the required ones.
306 final_argument_whitespace = False #: A boolean; ``True`` if the last argument may contain spaces.
307 # docutils declares 'option_spec' on 'Directive' and 'BaseDirective' assigns it, so mypy calls every
308 # spelling of this override a conflict with one of them
309 #: Mapping of option names to validator functions.
310 option_spec: dict[str, Any] = { # type: ignore[misc]
311 "root-icon": describedIcon,
312 "node-icon": describedIcon,
313 "leaf-icon": describedIcon,
314 "expanded-icon": icon,
315 "collapsed-icon": icon,
316 "icons": markerIcons,
317 "legend": LegendPosition.Parse,
318 "expanded-levels": directives.nonnegative_int,
319 "class": strip,
320 }
321
322 def run(self) -> list[nodes.Node]:
323 """
324 Draw the hierarchy written in the content, and the legend of its icons.
325
326 Option ``:legend:`` without any icon having a text draws no legend, and is warned about.
327
328 :returns: The tree as a bullet list with its legend above or below it, or the message of whatever the content or
329 the icons' texts got wrong.
330 """
331 markers = self.options.get("icons", {})
332 describedIcons = {
333 name: self.options.get(name, (DEFAULT_ICONS[name], "")) for name in ("root-icon", "node-icon", "leaf-icon")
334 }
335 describedIcons.update(markers)
336 position = self.options.get("legend", LegendPosition.DEFAULT)
337 try:
338 roots = self._ParseEntries(self.content, (DEFAULT_MARKER, *markers))
339 legend, messages = self._BuildLegend(describedIcons.values(), position)
340 except SphinxExtensionError as ex:
341 return [self.state.document.reporter.error(f"{self.directiveName}: {ex}", line=self.lineno)]
342
343 icons = {name: self.options.get(name, DEFAULT_ICONS[name]) for name in ("expanded-icon", "collapsed-icon")}
344 icons.update((key, characters) for key, (characters, _) in describedIcons.items())
345 expandedLevels = self.options.get("expanded-levels", None)
346 descriptionColumn = any(entry["description"] != "" for root in roots for entry in root.IteratePreOrder())
347
348 tree = nodes.bullet_list(classes=["pytooling-tree"] + self.options.get("class", "").split())
349 for root in roots:
350 tree += self._BuildItem(root, icons, expandedLevels, descriptionColumn)
351
352 if descriptionColumn:
353 columnWidth = self._ColumnWidth(tree.findall(TreeLabel))
354 for item in tree.children:
355 item["columnWidth"] = columnWidth
356
357 if len(legend.children) == 0:
358 if "legend" in self.options and position is not LegendPosition.Hidden:
359 messages.append(self.state.document.reporter.warning(
360 f"{self.directiveName}: ':legend: {position}' without an icon having a text draws no legend.",
361 line=self.lineno
362 ))
363 return [tree, *messages]
364 elif position is LegendPosition.Top:
365 return [legend, tree, *messages]
366 elif position is LegendPosition.Bottom:
367 return [tree, legend, *messages]
368 else:
369 return [tree, *messages]
370
371 def _BuildLegend(
372 self,
373 describedIcons: Iterable[tuple[str, str]],
374 position: LegendPosition
375 ) -> tuple[TreeLegend, list[nodes.system_message]]:
376 """
377 Build the legend of the tree's icons: an item per icon with a text, in the order given.
378
379 An icon given twice with the same text - e.g. a folder for a node and for a marker - is explained once. A text is
380 parsed as inline ReST at the directive's line, as an option has no line of its own.
381
382 :param describedIcons: Each icon with its text; an icon with an empty text has no item.
383 :param position: Where the legend is drawn; the legend gets the CSS class ``tree-legend-<position>``.
384 :returns: The legend - without items, if no icon has a text -, and the messages about the
385 texts.
386 :raises SphinxExtensionError: If an icon is given twice with different texts.
387 """
388 texts: dict[str, str] = {}
389 for characters, text in describedIcons:
390 if text == "":
391 continue
392 elif characters not in texts:
393 texts[characters] = text
394 elif texts[characters] != text:
395 raise SphinxExtensionError(f"Icon '{characters}' has two texts: '{texts[characters]}' and '{text}'.")
396
397 legend = TreeLegend("", classes=["pytooling-tree-legend", f"tree-legend-{position}"])
398 messages: list[nodes.system_message] = []
399 for characters, text in texts.items():
400 textNodes, textMessages = self.state.inline_text(text, self.lineno)
401 legend += TreeLegendItem(text, "", *textNodes, icon=characters)
402 messages.extend(textMessages)
403
404 return legend, messages
405
406 @staticmethod
407 def _ParseEntries(content: Iterable[str], markers: Iterable[str] = (DEFAULT_MARKER, )) -> list[_Entry]:
408 """
409 Read the content into trees: an entry per line, and an entry indented deeper than the one above is its child.
410
411 An entry's text is the node's value. Key ``index`` holds the index of the entry's line in the content, key
412 ``marker`` the marker the line starts with, and key ``description`` the text behind a separator ``|`` - see
413 :data:`SEPARATOR_PATTERN` -, or an empty string.
414
415 :param content: The directive's content, line by line.
416 :param markers: Optional, the markers an entry may start with. Default: ``-``.
417 :returns: The root of each tree, in the order written.
418 :raises SphinxExtensionError: If the content holds no entry.
419 :raises SphinxExtensionError: If a line doesn't start with a marker and a space.
420 :raises SphinxExtensionError: If a line has more than one separator ``|``.
421 :raises SphinxExtensionError: If an entry has no text.
422 :raises SphinxExtensionError: If an entry is indented less than the entry above, but not as deep as one of its
423 ancestors.
424 """
425 roots: list[_Entry] = []
426 ancestors: list[tuple[int, _Entry]] = [] # entries the next one can be a child or a sibling of, with indentation
427 for index, line in enumerate(content):
428 if (text := line.lstrip(" ")).strip() == "":
429 continue
430
431 indentation = len(line) - len(text)
432 marker = text[0]
433 if marker not in markers or text[1:2].strip() != "":
434 accepted = ", ".join(f"'{marker} '" for marker in markers)
435 raise SphinxExtensionError(f"'{text.rstrip()}' is not an entry, which starts with {accepted}.")
436
437 separators = [match.start() for match in SEPARATOR_PATTERN.finditer(text) if match["separator"] is not None]
438 if len(separators) > 1:
439 raise SphinxExtensionError(
440 f"'{text.rstrip()}' has more than one separator ' | '; a '|' in a text is escaped as '\\|'."
441 )
442 elif len(separators) == 1:
443 value, description = text[1:separators[0]].strip(), text[separators[0] + 1:].strip()
444 else:
445 value, description = text[1:].strip(), ""
446
447 if value == "":
448 raise SphinxExtensionError(f"'{text.rstrip()}' is an entry without text.")
449
450 closed = False
451 while len(ancestors) > 0 and ancestors[-1][0] > indentation:
452 ancestors.pop()
453 closed = True
454
455 if len(ancestors) > 0 and ancestors[-1][0] == indentation:
456 ancestors.pop()
457 elif closed:
458 raise SphinxExtensionError(
459 f"'{text.rstrip()}' is indented less than the entry above, but not as deep as one of its ancestors."
460 )
461
462 parent = ancestors[-1][1] if len(ancestors) > 0 else None
463 entry: _Entry = Node(
464 value=value, keyValuePairs={"index": index, "marker": marker, "description": description}, parent=parent
465 )
466 if parent is None:
467 roots.append(entry)
468
469 ancestors.append((indentation, entry))
470
471 if len(roots) == 0:
472 raise SphinxExtensionError("The directive's content holds no entry.")
473
474 return roots
475
476 def _BuildItem(
477 self,
478 entry: _Entry,
479 icons: dict[str, str],
480 expandedLevels: Nullable[int],
481 descriptionColumn: bool
482 ) -> TreeItem:
483 """
484 Build an entry and, below it, its children.
485
486 The entry's text and description are parsed as inline ReST at the line they were written on, so a message about
487 a role in them points to that line. Such messages follow the entry's text in the list item.
488
489 :param entry: The entry, as :meth:`_ParseEntries` read it.
490 :param icons: The icons, keyed by the option naming them, or by their marker.
491 :param expandedLevels: How many levels are expanded initially; ``None`` expands all.
492 :param descriptionColumn: Whether the tree has descriptions: then the entry's text ends with a description, empty
493 if the entry has none.
494 :returns: The entry as a list item, holding its text and a bullet list of its children.
495 """
496 line = self.content_offset + entry["index"] + 1
497 textNodes, messages = self.state.inline_text(entry.Value, line)
498
499 if entry.IsRoot:
500 kind = "root"
501 elif entry.HasChildren:
502 kind = "node"
503 else:
504 kind = "leaf"
505
506 entryIcon = icons[entry["marker"]] if entry["marker"] in icons else icons[f"{kind}-icon"]
507
508 item = TreeItem("", classes=[f"tree-{kind}"] + (["tree-expandable"] if entry.HasChildren else []))
509 item["columnWidth"] = None
510 item += (label := TreeLabel(entry.Value, "", *textNodes, icon=entryIcon, level=entry.Level))
511 if descriptionColumn:
512 description = TreeDescription(entry["description"])
513 if entry["description"] != "":
514 descriptionNodes, descriptionMessages = self.state.inline_text(entry["description"], line)
515 description += descriptionNodes
516 messages += descriptionMessages
517 label += TreeSeparator("", SEPARATOR)
518
519 label += description
520
521 item += messages
522
523 if not entry.HasChildren:
524 item["expanded"] = None
525 label["expandedIcon"] = None
526 label["collapsedIcon"] = None
527 return item
528
529 item["expanded"] = expandedLevels is None or entry.Level < expandedLevels
530 label["expandedIcon"] = icons["expanded-icon"]
531 label["collapsedIcon"] = icons["collapsed-icon"]
532
533 item += (children := nodes.bullet_list())
534 for child in entry.GetChildren():
535 children += self._BuildItem(child, icons, expandedLevels, descriptionColumn)
536
537 return item
538
539 @staticmethod
540 def _ColumnWidth(labels: Iterable[TreeLabel]) -> str:
541 """
542 Estimate the width of the column of the entries' texts in a tree with descriptions, from the tree's left edge.
543
544 An entry needs its level's indentation, its expander, and its icon, a gap and its text. HTML renders the text
545 later, in a font chosen by the theme, so the text's width is estimated in character cells, CSS unit ``ch``: two
546 for a wide character, e.g. an emoji, none for a combining or format character, e.g. a variation selector, and
547 one for every other character. The gap counts as one cell.
548
549 The width is the widest entry's: per level, the indentation and the most cells at that level, and of these the
550 maximum, computed by the browser with CSS function ``max()``. The indentation is CSS custom property
551 ``--pyTooling-tree-level``, so a project's indentation is accounted for.
552
553 :param labels: The entries' texts, with their level, icon and description.
554 :returns: The width as a CSS length.
555 """
556 cellsPerLevel: dict[int, int] = {}
557 for label in labels:
558 text = "".join(
559 child.astext() for child in label.children if not isinstance(child, (TreeSeparator, TreeDescription))
560 )
561 if label["icon"] != "":
562 text = f"{label['icon']} {text}"
563
564 cells = sum(
565 2 if east_asian_width(character) in ("W", "F") else 0 if category(character) in ("Mn", "Me", "Cf") else 1
566 for character in text
567 )
568 cellsPerLevel[label["level"]] = max(cells, cellsPerLevel.get(label["level"], 0))
569
570 widths = ", ".join(
571 f"{cells}ch" if level == 0 else f"calc({level} * var(--pyTooling-tree-level) + {cells}ch)"
572 for level, cells in sorted(cellsPerLevel.items())
573 )
574 return f"calc(var(--pyTooling-tree-expander-width) + max({widths}))"
575
576
577@export
578class RemoveTreeLegends(SphinxPostTransform):
579 """
580 A post-transform removing the trees' legends from the output formats, whose trees show no icons: LaTeX, text,
581 manual pages and Texinfo.
582
583 These formats write a tree as a nested bullet list, so a legend would explain icons the reader doesn't see.
584 """
585
586 default_priority = 200 #: Priority among the post-transforms.
587 formats = ("latex", "text", "man", "texinfo") #: Output formats the transform runs for.
588
589 def run(self, **kwargs: Any) -> None:
590 """
591 Remove every legend of a tree.
592
593 :param kwargs: Keyword arguments Sphinx passes to a post-transform; none is used.
594 """
595 for legend in list(self.document.findall(TreeLegend)):
596 legend.parent.remove(legend)