Tree.py
Summary
- Lines:
223 of 223 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, \
123 TreeSeparator
124
125
126__all__ = [
127 "DEFAULT_MARKER", "DEFAULT_ICONS", "CODE_POINT_PATTERN", "SEPARATOR_PATTERN", "SEPARATOR"
128]
129
130DEFAULT_MARKER = "-"
131"""The marker of an entry whose icon is the one of its kind."""
132
133DEFAULT_ICONS = {
134 "root-icon": "",
135 "node-icon": "",
136 "leaf-icon": "",
137 "expanded-icon": "▾",
138 "collapsed-icon": "▸",
139}
140"""
141The icons drawn when the directive's options don't state one, keyed by the option's name.
142
143Roots, nodes and leaves have none, so a tree shows only its lines; a node with children shows whether it is
144expanded.
145"""
146
147CODE_POINT_PATTERN = re_compile(r"U\+([0-9A-Fa-f]{1,6})")
148"""A word of an icon option written as a Unicode code point, e.g. ``U+1F4C1``."""
149
150SEPARATOR_PATTERN = re_compile(r"\\.|``.*?``|`(?:\\.|[^`\\])*`|(?<=\s)(?P<separator>\|)(?=\s|$)")
151"""
152The separator between an entry's text and its description, as group ``separator``: a ``|`` after whitespace and
153before whitespace or the line's end.
154
155The other alternatives match what the separator isn't searched in - an escaped character, an inline literal, and
156interpreted text such as a role's -, so a ``|`` in them separates nothing.
157"""
158
159SEPARATOR = " \u2013 "
160"""What a format without columns writes between an entry's text and its description: an en dash between spaces."""
161
162_Entry = Node
163"""
164An entry of the content: its text as the value, the index of its line, its marker and its description as key-value
165pairs.
166"""
167
168
169@export
170def icon(option: Nullable[str]) -> str:
171 """
172 Option converter reading an icon: characters as written, and a word ``U+<hex>`` as the code point it names.
173
174 A code point keeps a document readable in an editor whose font lacks the icon's glyph.
175
176 :param option: The option's value as it was written; ``None`` if the option has no value.
177 :returns: The icon's characters; empty if the option has no value, which draws no icon.
178 :raises ValueError: If a word names a code point beyond U+10FFFF.
179 :raises ValueError: If a word names a surrogate code point, which is no character.
180 """
181 if option is None:
182 return ""
183
184 characters = []
185 for word in option.split():
186 if (match := CODE_POINT_PATTERN.fullmatch(word)) is None:
187 characters.append(word)
188 elif (codePoint := int(match[1], 16)) > 0x10ffff:
189 raise ValueError(f"'{word}' is beyond the last code point U+10FFFF.")
190 elif 0xd800 <= codePoint <= 0xdfff:
191 raise ValueError(f"'{word}' is a surrogate code point, which is no character.")
192 else:
193 characters.append(chr(codePoint))
194
195 return "".join(characters)
196
197
198@export
199def describedIcon(option: Nullable[str]) -> tuple[str, str]:
200 """
201 Option converter reading an icon and the text explaining it in the tree's legend: ``<icon> | <text>``.
202
203 The icon is read by :func:`icon`. The text follows a separator ``|`` between spaces - see :data:`SEPARATOR_PATTERN`
204 -, and is inline ReST. An icon without a text has no item in the legend.
205
206 :param option: The option's value as it was written; ``None`` if the option has no value.
207 :returns: The icon's characters and the text; empty if the option has no value or no text.
208 :raises ValueError: If the value has more than one separator ``|``.
209 :raises ValueError: If the value has a text, but no icon.
210 """
211 if option is None:
212 return "", ""
213
214 value = f" {option}" # a separator at the start follows a space too
215 separators = [match.start() for match in SEPARATOR_PATTERN.finditer(value) if match["separator"] is not None]
216 if len(separators) > 1:
217 raise ValueError(f"'{option.strip()}' has more than one separator ' | '; a '|' in a text is escaped as '\\|'.")
218 elif len(separators) == 0:
219 return icon(option), ""
220
221 characters = icon(value[:separators[0]])
222 text = value[separators[0] + 1:].strip()
223 if characters == "" and text != "":
224 raise ValueError(f"'{option.strip()}' has a text, but no icon.")
225
226 return characters, text
227
228
229@export
230class LegendPosition(StringEnum):
231 """
232 Where a legend is drawn, as a directive's option ``:legend:`` states it.
233
234 A tree's legend gets the CSS class ``tree-legend-<value>``, a unit test summary's legend
235 ``report-unittest-legend-<value>``.
236 """
237
238 Top = "top" #: Above the tree or table.
239 Bottom = "bottom" #: Below the tree or table.
240 Hidden = "none" #: Not at all.
241
242 DEFAULT = Bottom #: An option without a value draws the legend below the tree or table.
243
244
245@export
246def markerIcons(option: Nullable[str]) -> dict[str, tuple[str, str]]:
247 """
248 Option converter reading the markers an entry may start with, each with its icon and the text explaining it in the
249 tree's legend: ``<marker> <icon>``, separated by commas, or ``<marker> <icon> | <text>``, one per line.
250
251 A marker is one character, neither a letter, a digit, a comma nor a space. An icon and its text are read by
252 :func:`describedIcon`. A line with a separator ``|`` holds one marker, so a text may contain commas; a line without
253 one is split at its commas.
254
255 :param option: The option's value as it was written; ``None`` if the option has no value.
256 :returns: The icons and their texts, keyed by their marker, in the order declared.
257 :raises ValueError: If an item isn't a marker followed by a space and an icon.
258 :raises ValueError: If a marker is a letter, a digit or a comma.
259 :raises ValueError: If a marker is declared twice.
260 :raises ValueError: If an item has more than one separator ``|``.
261 """
262 if option is None:
263 return {}
264
265 icons: dict[str, tuple[str, str]] = {}
266 for line in option.splitlines():
267 if any(match["separator"] is not None for match in SEPARATOR_PATTERN.finditer(line)):
268 items = [line]
269 else:
270 items = line.split(",")
271
272 for item in items:
273 if (item := item.strip()) == "":
274 continue
275
276 marker = item[0]
277 separator = item[1:2]
278 value = item[2:]
279 if separator.strip() != "" or value.strip() == "":
280 raise ValueError(f"'{item}' is not a marker followed by a space and an icon.")
281 elif marker.isalnum():
282 raise ValueError(f"Marker '{marker}' is a letter or a digit.")
283 elif marker in icons:
284 raise ValueError(f"Marker '{marker}' is declared twice.")
285
286 icons[marker] = describedIcon(value)
287
288 return icons
289
290
291@export
292class Tree(BaseDirective):
293 """
294 The ``tree`` directive: a hierarchy, written as an indented list, drawn as a tree.
295
296 :meth:`_ParseEntries` reads the content into :class:`~pyTooling.Tree.Node` instances, one tree per root, and
297 :meth:`_BuildItem` turns them into nested bullet lists of :class:`~pyTooling.Sphinx.Node.TreeItem` and
298 :class:`~pyTooling.Sphinx.Node.TreeLabel`. The options choose the icons and how many levels are expanded initially;
299 ``:class:`` puts additional CSS classes on the tree. :meth:`_BuildLegend` turns the texts of the icons into the
300 tree's legend, which ``:legend:`` places above or below the tree.
301
302 If an entry has a description, every entry's text ends with a :class:`~pyTooling.Sphinx.Node.TreeDescription`, and
303 :meth:`_ColumnWidth` estimates how wide the column of the texts in front of the descriptions is.
304 """
305
306 directiveName: str = "tree" #: Name the directive is invoked by.
307
308 has_content = True #: A boolean; ``True`` if content is allowed.
309 required_arguments = 0 #: Number of required directive arguments.
310 optional_arguments = 0 #: Number of optional arguments after the required ones.
311 final_argument_whitespace = False #: A boolean; ``True`` if the last argument may contain spaces.
312 # docutils declares 'option_spec' on 'Directive' and 'BaseDirective' assigns it, so mypy calls every
313 # spelling of this override a conflict with one of them
314 #: Mapping of option names to validator functions.
315 option_spec: dict[str, Any] = { # type: ignore[misc]
316 "root-icon": describedIcon,
317 "node-icon": describedIcon,
318 "leaf-icon": describedIcon,
319 "expanded-icon": icon,
320 "collapsed-icon": icon,
321 "icons": markerIcons,
322 "legend": LegendPosition.Parse,
323 "expanded-levels": directives.nonnegative_int,
324 "class": strip,
325 }
326
327 def run(self) -> list[nodes.Node]:
328 """
329 Draw the hierarchy written in the content, and the legend of its icons.
330
331 Option ``:legend:`` without any icon having a text draws no legend, and is warned about.
332
333 :returns: The tree as a bullet list with its legend above or below it, or the message of whatever the content or
334 the icons' texts got wrong.
335 """
336 markers = self.options.get("icons", {})
337 describedIcons = {
338 name: self.options.get(name, (DEFAULT_ICONS[name], "")) for name in ("root-icon", "node-icon", "leaf-icon")
339 }
340 describedIcons.update(markers)
341 position = self.options.get("legend", LegendPosition.DEFAULT)
342 try:
343 roots = self._ParseEntries(self.content, (DEFAULT_MARKER, *markers))
344 legend, messages = self._BuildLegend(describedIcons.values(), position)
345 except SphinxExtensionError as ex:
346 return [self.state.document.reporter.error(f"{self.directiveName}: {ex}", line=self.lineno)]
347
348 icons = {name: self.options.get(name, DEFAULT_ICONS[name]) for name in ("expanded-icon", "collapsed-icon")}
349 icons.update((key, characters) for key, (characters, _) in describedIcons.items())
350 expandedLevels = self.options.get("expanded-levels", None)
351 descriptionColumn = any(entry["description"] != "" for root in roots for entry in root.IteratePreOrder())
352
353 tree = nodes.bullet_list(classes=["pytooling-tree"] + self.options.get("class", "").split())
354 for root in roots:
355 tree += self._BuildItem(root, icons, expandedLevels, descriptionColumn)
356
357 if descriptionColumn:
358 columnWidth = self._ColumnWidth(tree.findall(TreeLabel))
359 for item in tree.children:
360 item["columnWidth"] = columnWidth
361
362 if len(legend.children) == 0:
363 if "legend" in self.options and position is not LegendPosition.Hidden:
364 messages.append(self.state.document.reporter.warning(
365 f"{self.directiveName}: ':legend: {position}' without an icon having a text draws no legend.",
366 line=self.lineno
367 ))
368 return [tree, *messages]
369 elif position is LegendPosition.Top:
370 return [legend, tree, *messages]
371 elif position is LegendPosition.Bottom:
372 return [tree, legend, *messages]
373 else:
374 return [tree, *messages]
375
376 def _BuildLegend(
377 self,
378 describedIcons: Iterable[tuple[str, str]],
379 position: LegendPosition
380 ) -> tuple[TreeLegend, list[nodes.system_message]]:
381 """
382 Build the legend of the tree's icons: an item per icon with a text, in the order given.
383
384 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
385 parsed as inline ReST at the directive's line, as an option has no line of its own.
386
387 :param describedIcons: Each icon with its text; an icon with an empty text has no item.
388 :param position: Where the legend is drawn; the legend gets the CSS class ``tree-legend-<position>``.
389 :returns: The legend - without items, if no icon has a text -, and the messages about the
390 texts.
391 :raises SphinxExtensionError: If an icon is given twice with different texts.
392 """
393 texts: dict[str, str] = {}
394 for characters, text in describedIcons:
395 if text == "":
396 continue
397 elif characters not in texts:
398 texts[characters] = text
399 elif texts[characters] != text:
400 raise SphinxExtensionError(f"Icon '{characters}' has two texts: '{texts[characters]}' and '{text}'.")
401
402 legend = TreeLegend("", classes=["pytooling-tree-legend", f"tree-legend-{position}"])
403 messages: list[nodes.system_message] = []
404 for characters, text in texts.items():
405 textNodes, textMessages = self.state.inline_text(text, self.lineno)
406 legend += TreeLegendItem(text, "", *textNodes, icon=characters)
407 messages.extend(textMessages)
408
409 return legend, messages
410
411 @staticmethod
412 def _ParseEntries(content: Iterable[str], markers: Iterable[str] = (DEFAULT_MARKER, )) -> list[_Entry]:
413 """
414 Read the content into trees: an entry per line, and an entry indented deeper than the one above is its child.
415
416 An entry's text is the node's value. Key ``index`` holds the index of the entry's line in the content, key
417 ``marker`` the marker the line starts with, and key ``description`` the text behind a separator ``|`` - see
418 :data:`SEPARATOR_PATTERN` -, or an empty string.
419
420 :param content: The directive's content, line by line.
421 :param markers: Optional, the markers an entry may start with. Default: ``-``.
422 :returns: The root of each tree, in the order written.
423 :raises SphinxExtensionError: If the content holds no entry.
424 :raises SphinxExtensionError: If a line doesn't start with a marker and a space.
425 :raises SphinxExtensionError: If a line has more than one separator ``|``.
426 :raises SphinxExtensionError: If an entry has no text.
427 :raises SphinxExtensionError: If an entry is indented less than the entry above, but not as deep as one of its
428 ancestors.
429 """
430 roots: list[_Entry] = []
431 ancestors: list[tuple[int, _Entry]] = [] # entries the next one can be a child or a sibling of, with indentation
432 for index, line in enumerate(content):
433 if (text := line.lstrip(" ")).strip() == "":
434 continue
435
436 indentation = len(line) - len(text)
437 marker = text[0]
438 if marker not in markers or text[1:2].strip() != "":
439 accepted = ", ".join(f"'{marker} '" for marker in markers)
440 raise SphinxExtensionError(f"'{text.rstrip()}' is not an entry, which starts with {accepted}.")
441
442 separators = [match.start() for match in SEPARATOR_PATTERN.finditer(text) if match["separator"] is not None]
443 if len(separators) > 1:
444 raise SphinxExtensionError(
445 f"'{text.rstrip()}' has more than one separator ' | '; a '|' in a text is escaped as '\\|'."
446 )
447 elif len(separators) == 1:
448 value, description = text[1:separators[0]].strip(), text[separators[0] + 1:].strip()
449 else:
450 value, description = text[1:].strip(), ""
451
452 if value == "":
453 raise SphinxExtensionError(f"'{text.rstrip()}' is an entry without text.")
454
455 closed = False
456 while len(ancestors) > 0 and ancestors[-1][0] > indentation:
457 ancestors.pop()
458 closed = True
459
460 if len(ancestors) > 0 and ancestors[-1][0] == indentation:
461 ancestors.pop()
462 elif closed:
463 raise SphinxExtensionError(
464 f"'{text.rstrip()}' is indented less than the entry above, but not as deep as one of its ancestors."
465 )
466
467 parent = ancestors[-1][1] if len(ancestors) > 0 else None
468 entry: _Entry = Node(
469 value=value, keyValuePairs={"index": index, "marker": marker, "description": description}, parent=parent
470 )
471 if parent is None:
472 roots.append(entry)
473
474 ancestors.append((indentation, entry))
475
476 if len(roots) == 0:
477 raise SphinxExtensionError("The directive's content holds no entry.")
478
479 return roots
480
481 def _BuildItem(
482 self,
483 entry: _Entry,
484 icons: dict[str, str],
485 expandedLevels: Nullable[int],
486 descriptionColumn: bool
487 ) -> TreeItem:
488 """
489 Build an entry and, below it, its children.
490
491 The entry's text and description are parsed as inline ReST at the line they were written on, so a message about
492 a role in them points to that line. Such messages follow the entry's text in the list item.
493
494 :param entry: The entry, as :meth:`_ParseEntries` read it.
495 :param icons: The icons, keyed by the option naming them, or by their marker.
496 :param expandedLevels: How many levels are expanded initially; ``None`` expands all.
497 :param descriptionColumn: Whether the tree has descriptions: then the entry's text ends with a description, empty
498 if the entry has none.
499 :returns: The entry as a list item, holding its text and a bullet list of its children.
500 """
501 line = self.content_offset + entry["index"] + 1
502 textNodes, messages = self.state.inline_text(entry.Value, line)
503
504 if entry.IsRoot:
505 kind = "root"
506 elif entry.HasChildren:
507 kind = "node"
508 else:
509 kind = "leaf"
510
511 entryIcon = icons[entry["marker"]] if entry["marker"] in icons else icons[f"{kind}-icon"]
512
513 item = TreeItem("", classes=[f"tree-{kind}"] + (["tree-expandable"] if entry.HasChildren else []))
514 item["columnWidth"] = None
515 item += (label := TreeLabel(entry.Value, "", *textNodes, icon=entryIcon, level=entry.Level))
516 if descriptionColumn:
517 description = TreeDescription(entry["description"])
518 if entry["description"] != "":
519 descriptionNodes, descriptionMessages = self.state.inline_text(entry["description"], line)
520 description += descriptionNodes
521 messages += descriptionMessages
522 label += TreeSeparator("", SEPARATOR)
523
524 label += description
525
526 item += messages
527
528 if not entry.HasChildren:
529 item["expanded"] = None
530 label["expandedIcon"] = None
531 label["collapsedIcon"] = None
532 return item
533
534 item["expanded"] = expandedLevels is None or entry.Level < expandedLevels
535 label["expandedIcon"] = icons["expanded-icon"]
536 label["collapsedIcon"] = icons["collapsed-icon"]
537
538 item += (children := nodes.bullet_list())
539 for child in entry.GetChildren():
540 children += self._BuildItem(child, icons, expandedLevels, descriptionColumn)
541
542 return item
543
544 @staticmethod
545 def _ColumnWidth(labels: Iterable[TreeLabel]) -> str:
546 """
547 Estimate the width of the column of the entries' texts in a tree with descriptions, from the tree's left edge.
548
549 An entry needs its level's indentation, its expander, and its icon, a gap and its text. HTML renders the text
550 later, in a font chosen by the theme, so the text's width is estimated in character cells, CSS unit ``ch``: two
551 for a wide character, e.g. an emoji, none for a combining or format character, e.g. a variation selector, and
552 one for every other character. The gap counts as one cell.
553
554 The width is the widest entry's: per level, the indentation and the most cells at that level, and of these the
555 maximum, computed by the browser with CSS function ``max()``. The indentation is CSS custom property
556 ``--pyTooling-tree-level``, so a project's indentation is accounted for.
557
558 :param labels: The entries' texts, with their level, icon and description.
559 :returns: The width as a CSS length.
560 """
561 cellsPerLevel: dict[int, int] = {}
562 for label in labels:
563 text = "".join(
564 child.astext() for child in label.children if not isinstance(child, (TreeSeparator, TreeDescription))
565 )
566 if label["icon"] != "":
567 text = f"{label['icon']} {text}"
568
569 cells = sum(
570 2 if east_asian_width(character) in ("W", "F") else 0 if category(character) in ("Mn", "Me", "Cf") else 1
571 for character in text
572 )
573 cellsPerLevel[label["level"]] = max(cells, cellsPerLevel.get(label["level"], 0))
574
575 widths = ", ".join(
576 f"{cells}ch" if level == 0 else f"calc({level} * var(--pyTooling-tree-level) + {cells}ch)"
577 for level, cells in sorted(cellsPerLevel.items())
578 )
579 return f"calc(var(--pyTooling-tree-expander-width) + max({widths}))"
580
581
582@export
583class RemoveTreeLegends(SphinxPostTransform):
584 """
585 A post-transform removing the trees' legends from the output formats, whose trees show no icons: LaTeX, text,
586 manual pages and Texinfo.
587
588 These formats write a tree as a nested bullet list, so a legend would explain icons the reader doesn't see.
589 """
590
591 default_priority = 200 #: Priority among the post-transforms.
592 formats = ("latex", "text", "man", "texinfo") #: Output formats the transform runs for.
593
594 def run(self, **kwargs: Any) -> None:
595 """
596 Remove every legend of a tree.
597
598 :param kwargs: Keyword arguments Sphinx passes to a post-transform; none is used.
599 """
600 for legend in list(self.document.findall(TreeLegend)):
601 legend.parent.remove(legend)