diff options
Diffstat (limited to 'sphinx/environment/adapters')
-rw-r--r-- | sphinx/environment/adapters/__init__.py | 1 | ||||
-rw-r--r-- | sphinx/environment/adapters/asset.py | 15 | ||||
-rw-r--r-- | sphinx/environment/adapters/indexentries.py | 187 | ||||
-rw-r--r-- | sphinx/environment/adapters/toctree.py | 520 |
4 files changed, 723 insertions, 0 deletions
diff --git a/sphinx/environment/adapters/__init__.py b/sphinx/environment/adapters/__init__.py new file mode 100644 index 0000000..1566aec --- /dev/null +++ b/sphinx/environment/adapters/__init__.py @@ -0,0 +1 @@ +"""Sphinx environment adapters""" diff --git a/sphinx/environment/adapters/asset.py b/sphinx/environment/adapters/asset.py new file mode 100644 index 0000000..57fdc91 --- /dev/null +++ b/sphinx/environment/adapters/asset.py @@ -0,0 +1,15 @@ +"""Assets adapter for sphinx.environment.""" + +from sphinx.environment import BuildEnvironment + + +class ImageAdapter: + def __init__(self, env: BuildEnvironment) -> None: + self.env = env + + def get_original_image_uri(self, name: str) -> str: + """Get the original image URI.""" + while name in self.env.original_image_uri: + name = self.env.original_image_uri[name] + + return name diff --git a/sphinx/environment/adapters/indexentries.py b/sphinx/environment/adapters/indexentries.py new file mode 100644 index 0000000..6fdbea6 --- /dev/null +++ b/sphinx/environment/adapters/indexentries.py @@ -0,0 +1,187 @@ +"""Index entries adapters for sphinx.environment.""" + +from __future__ import annotations + +import re +import unicodedata +from itertools import groupby +from typing import TYPE_CHECKING, Any, Literal + +from sphinx.errors import NoUri +from sphinx.locale import _, __ +from sphinx.util import logging +from sphinx.util.index_entries import _split_into + +if TYPE_CHECKING: + from sphinx.builders import Builder + from sphinx.environment import BuildEnvironment + +logger = logging.getLogger(__name__) + + +class IndexEntries: + def __init__(self, env: BuildEnvironment) -> None: + self.env = env + self.builder: Builder + + def create_index(self, builder: Builder, group_entries: bool = True, + _fixre: re.Pattern = re.compile(r'(.*) ([(][^()]*[)])'), + ) -> list[tuple[str, list[tuple[str, Any]]]]: + """Create the real index from the collected index entries.""" + new: dict[str, list] = {} + + rel_uri: str | Literal[False] + index_domain = self.env.domains['index'] + for docname, entries in index_domain.entries.items(): + try: + rel_uri = builder.get_relative_uri('genindex', docname) + except NoUri: + rel_uri = False + + # new entry types must be listed in directives/other.py! + for entry_type, value, target_id, main, category_key in entries: + uri = rel_uri is not False and f'{rel_uri}#{target_id}' + try: + if entry_type == 'single': + try: + entry, sub_entry = _split_into(2, 'single', value) + except ValueError: + entry, = _split_into(1, 'single', value) + sub_entry = '' + _add_entry(entry, sub_entry, main, + dic=new, link=uri, key=category_key) + elif entry_type == 'pair': + first, second = _split_into(2, 'pair', value) + _add_entry(first, second, main, + dic=new, link=uri, key=category_key) + _add_entry(second, first, main, + dic=new, link=uri, key=category_key) + elif entry_type == 'triple': + first, second, third = _split_into(3, 'triple', value) + _add_entry(first, second + ' ' + third, main, + dic=new, link=uri, key=category_key) + _add_entry(second, third + ', ' + first, main, + dic=new, link=uri, key=category_key) + _add_entry(third, first + ' ' + second, main, + dic=new, link=uri, key=category_key) + elif entry_type == 'see': + first, second = _split_into(2, 'see', value) + _add_entry(first, _('see %s') % second, None, + dic=new, link=False, key=category_key) + elif entry_type == 'seealso': + first, second = _split_into(2, 'see', value) + _add_entry(first, _('see also %s') % second, None, + dic=new, link=False, key=category_key) + else: + logger.warning(__('unknown index entry type %r'), entry_type, + location=docname) + except ValueError as err: + logger.warning(str(err), location=docname) + + for (targets, sub_items, _category_key) in new.values(): + targets.sort(key=_key_func_0) + for (sub_targets, _0, _sub_category_key) in sub_items.values(): + sub_targets.sort(key=_key_func_0) + + new_list = sorted(new.items(), key=_key_func_1) + + if group_entries: + # fixup entries: transform + # func() (in module foo) + # func() (in module bar) + # into + # func() + # (in module foo) + # (in module bar) + old_key = '' + old_sub_items: dict[str, list] = {} + i = 0 + while i < len(new_list): + key, (targets, sub_items, category_key) = new_list[i] + # cannot move if it has sub_items; structure gets too complex + if not sub_items: + m = _fixre.match(key) + if m: + if old_key == m.group(1): + # prefixes match: add entry as subitem of the + # previous entry + old_sub_items.setdefault( + m.group(2), [[], {}, category_key])[0].extend(targets) + del new_list[i] + continue + old_key = m.group(1) + else: + old_key = key + old_sub_items = sub_items + i += 1 + + return [(key_, list(group)) + for (key_, group) in groupby(new_list, _key_func_3)] + + +def _add_entry(word: str, subword: str, main: str | None, *, + dic: dict[str, list], link: str | Literal[False], key: str | None) -> None: + entry = dic.setdefault(word, [[], {}, key]) + if subword: + entry = entry[1].setdefault(subword, [[], {}, key]) + if link: + entry[0].append((main, link)) + + +def _key_func_0(entry: tuple[str, str]) -> tuple[bool, str]: + """sort the index entries for same keyword.""" + main, uri = entry + return not main, uri # show main entries at first + + +def _key_func_1(entry: tuple[str, list]) -> tuple[tuple[int, str], str]: + """Sort the index entries""" + key, (_targets, _sub_items, category_key) = entry + if category_key: + # using the specified category key to sort + key = category_key + lc_key = unicodedata.normalize('NFD', key.lower()) + if lc_key.startswith('\N{RIGHT-TO-LEFT MARK}'): + lc_key = lc_key[1:] + + if not lc_key[0:1].isalpha() and not lc_key.startswith('_'): + # put symbols at the front of the index (0) + group = 0 + else: + # put non-symbol characters at the following group (1) + group = 1 + # ensure a deterministic order *within* letters by also sorting on + # the entry itself + return (group, lc_key), entry[0] + + +def _key_func_2(entry: tuple[str, list]) -> str: + """sort the sub-index entries""" + key = unicodedata.normalize('NFD', entry[0].lower()) + if key.startswith('\N{RIGHT-TO-LEFT MARK}'): + key = key[1:] + if key[0:1].isalpha() or key.startswith('_'): + key = chr(127) + key + return key + + +def _key_func_3(entry: tuple[str, list]) -> str: + """Group the entries by letter""" + key, (targets, sub_items, category_key) = entry + # hack: mutating the sub_items dicts to a list in the key_func + entry[1][1] = sorted(((sub_key, sub_targets) + for (sub_key, (sub_targets, _0, _sub_category_key)) + in sub_items.items()), key=_key_func_2) + + if category_key is not None: + return category_key + + # now calculate the key + if key.startswith('\N{RIGHT-TO-LEFT MARK}'): + key = key[1:] + letter = unicodedata.normalize('NFD', key[0])[0].upper() + if letter.isalpha() or letter == '_': + return letter + + # get all other symbols under one heading + return _('Symbols') diff --git a/sphinx/environment/adapters/toctree.py b/sphinx/environment/adapters/toctree.py new file mode 100644 index 0000000..e50d10b --- /dev/null +++ b/sphinx/environment/adapters/toctree.py @@ -0,0 +1,520 @@ +"""Toctree adapter for sphinx.environment.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, TypeVar + +from docutils import nodes +from docutils.nodes import Element, Node + +from sphinx import addnodes +from sphinx.locale import __ +from sphinx.util import logging, url_re +from sphinx.util.matching import Matcher +from sphinx.util.nodes import _only_node_keep_children, clean_astext + +if TYPE_CHECKING: + from collections.abc import Iterable, Set + + from sphinx.builders import Builder + from sphinx.environment import BuildEnvironment + from sphinx.util.tags import Tags + + +logger = logging.getLogger(__name__) + + +def note_toctree(env: BuildEnvironment, docname: str, toctreenode: addnodes.toctree) -> None: + """Note a TOC tree directive in a document and gather information about + file relations from it. + """ + if toctreenode['glob']: + env.glob_toctrees.add(docname) + if toctreenode.get('numbered'): + env.numbered_toctrees.add(docname) + include_files = toctreenode['includefiles'] + for include_file in include_files: + # note that if the included file is rebuilt, this one must be + # too (since the TOC of the included file could have changed) + env.files_to_rebuild.setdefault(include_file, set()).add(docname) + env.toctree_includes.setdefault(docname, []).extend(include_files) + + +def document_toc(env: BuildEnvironment, docname: str, tags: Tags) -> Node: + """Get the (local) table of contents for a document. + + Note that this is only the sections within the document. + For a ToC tree that shows the document's place in the + ToC structure, use `get_toctree_for`. + """ + + tocdepth = env.metadata[docname].get('tocdepth', 0) + try: + toc = _toctree_copy(env.tocs[docname], 2, tocdepth, False, tags) + except KeyError: + # the document does not exist any more: + # return a dummy node that renders to nothing + return nodes.paragraph() + + for node in toc.findall(nodes.reference): + node['refuri'] = node['anchorname'] or '#' + return toc + + +def global_toctree_for_doc( + env: BuildEnvironment, + docname: str, + builder: Builder, + collapse: bool = False, + includehidden: bool = True, + maxdepth: int = 0, + titles_only: bool = False, +) -> Element | None: + """Get the global ToC tree at a given document. + + This gives the global ToC, with all ancestors and their siblings. + """ + + toctrees: list[Element] = [] + for toctree_node in env.master_doctree.findall(addnodes.toctree): + if toctree := _resolve_toctree( + env, + docname, + builder, + toctree_node, + prune=True, + maxdepth=int(maxdepth), + titles_only=titles_only, + collapse=collapse, + includehidden=includehidden, + ): + toctrees.append(toctree) + if not toctrees: + return None + result = toctrees[0] + for toctree in toctrees[1:]: + result.extend(toctree.children) + return result + + +def _resolve_toctree( + env: BuildEnvironment, docname: str, builder: Builder, toctree: addnodes.toctree, *, + prune: bool = True, maxdepth: int = 0, titles_only: bool = False, + collapse: bool = False, includehidden: bool = False, +) -> Element | None: + """Resolve a *toctree* node into individual bullet lists with titles + as items, returning None (if no containing titles are found) or + a new node. + + If *prune* is True, the tree is pruned to *maxdepth*, or if that is 0, + to the value of the *maxdepth* option on the *toctree* node. + If *titles_only* is True, only toplevel document titles will be in the + resulting tree. + If *collapse* is True, all branches not containing docname will + be collapsed. + """ + + if toctree.get('hidden', False) and not includehidden: + return None + + # For reading the following two helper function, it is useful to keep + # in mind the node structure of a toctree (using HTML-like node names + # for brevity): + # + # <ul> + # <li> + # <p><a></p> + # <p><a></p> + # ... + # <ul> + # ... + # </ul> + # </li> + # </ul> + # + # The transformation is made in two passes in order to avoid + # interactions between marking and pruning the tree (see bug #1046). + + toctree_ancestors = _get_toctree_ancestors(env.toctree_includes, docname) + included = Matcher(env.config.include_patterns) + excluded = Matcher(env.config.exclude_patterns) + + maxdepth = maxdepth or toctree.get('maxdepth', -1) + if not titles_only and toctree.get('titlesonly', False): + titles_only = True + if not includehidden and toctree.get('includehidden', False): + includehidden = True + + tocentries = _entries_from_toctree( + env, + prune, + titles_only, + collapse, + includehidden, + builder.tags, + toctree_ancestors, + included, + excluded, + toctree, + [], + ) + if not tocentries: + return None + + newnode = addnodes.compact_paragraph('', '') + if caption := toctree.attributes.get('caption'): + caption_node = nodes.title(caption, '', *[nodes.Text(caption)]) + caption_node.line = toctree.line + caption_node.source = toctree.source + caption_node.rawsource = toctree['rawcaption'] + if hasattr(toctree, 'uid'): + # move uid to caption_node to translate it + caption_node.uid = toctree.uid # type: ignore[attr-defined] + del toctree.uid + newnode.append(caption_node) + newnode.extend(tocentries) + newnode['toctree'] = True + + # prune the tree to maxdepth, also set toc depth and current classes + _toctree_add_classes(newnode, 1, docname) + newnode = _toctree_copy(newnode, 1, maxdepth if prune else 0, collapse, builder.tags) + + if isinstance(newnode[-1], nodes.Element) and len(newnode[-1]) == 0: # No titles found + return None + + # set the target paths in the toctrees (they are not known at TOC + # generation time) + for refnode in newnode.findall(nodes.reference): + if url_re.match(refnode['refuri']) is None: + rel_uri = builder.get_relative_uri(docname, refnode['refuri']) + refnode['refuri'] = rel_uri + refnode['anchorname'] + return newnode + + +def _entries_from_toctree( + env: BuildEnvironment, + prune: bool, + titles_only: bool, + collapse: bool, + includehidden: bool, + tags: Tags, + toctree_ancestors: Set[str], + included: Matcher, + excluded: Matcher, + toctreenode: addnodes.toctree, + parents: list[str], + subtree: bool = False, +) -> list[Element]: + """Return TOC entries for a toctree node.""" + entries: list[Element] = [] + for (title, ref) in toctreenode['entries']: + try: + toc, refdoc = _toctree_entry( + title, ref, env, prune, collapse, tags, toctree_ancestors, + included, excluded, toctreenode, parents, + ) + except LookupError: + continue + + # children of toc are: + # - list_item + compact_paragraph + (reference and subtoc) + # - only + subtoc + # - toctree + children: Iterable[nodes.Element] = toc.children # type: ignore[assignment] + + # if titles_only is given, only keep the main title and + # sub-toctrees + if titles_only: + # delete everything but the toplevel title(s) + # and toctrees + for top_level in children: + # nodes with length 1 don't have any children anyway + if len(top_level) > 1: + if subtrees := list(top_level.findall(addnodes.toctree)): + top_level[1][:] = subtrees # type: ignore[index] + else: + top_level.pop(1) + # resolve all sub-toctrees + for sub_toc_node in list(toc.findall(addnodes.toctree)): + if sub_toc_node.get('hidden', False) and not includehidden: + continue + for i, entry in enumerate( + _entries_from_toctree( + env, + prune, + titles_only, + collapse, + includehidden, + tags, + toctree_ancestors, + included, + excluded, + sub_toc_node, + [refdoc] + parents, + subtree=True, + ), + start=sub_toc_node.parent.index(sub_toc_node) + 1, + ): + sub_toc_node.parent.insert(i, entry) + sub_toc_node.parent.remove(sub_toc_node) + + entries.extend(children) + + if not subtree: + ret = nodes.bullet_list() + ret += entries + return [ret] + + return entries + + +def _toctree_entry( + title: str, + ref: str, + env: BuildEnvironment, + prune: bool, + collapse: bool, + tags: Tags, + toctree_ancestors: Set[str], + included: Matcher, + excluded: Matcher, + toctreenode: addnodes.toctree, + parents: list[str], +) -> tuple[Element, str]: + from sphinx.domains.std import StandardDomain + + try: + refdoc = '' + if url_re.match(ref): + toc = _toctree_url_entry(title, ref) + elif ref == 'self': + toc = _toctree_self_entry(title, toctreenode['parent'], env.titles) + elif ref in StandardDomain._virtual_doc_names: + toc = _toctree_generated_entry(title, ref) + else: + if ref in parents: + logger.warning(__('circular toctree references ' + 'detected, ignoring: %s <- %s'), + ref, ' <- '.join(parents), + location=ref, type='toc', subtype='circular') + msg = 'circular reference' + raise LookupError(msg) + + toc, refdoc = _toctree_standard_entry( + title, + ref, + env.metadata[ref].get('tocdepth', 0), + env.tocs[ref], + toctree_ancestors, + prune, + collapse, + tags, + ) + + if not toc.children: + # empty toc means: no titles will show up in the toctree + logger.warning(__('toctree contains reference to document %r that ' + "doesn't have a title: no link will be generated"), + ref, location=toctreenode) + except KeyError: + # this is raised if the included file does not exist + ref_path = env.doc2path(ref, False) + if excluded(ref_path): + message = __('toctree contains reference to excluded document %r') + elif not included(ref_path): + message = __('toctree contains reference to non-included document %r') + else: + message = __('toctree contains reference to nonexisting document %r') + + logger.warning(message, ref, location=toctreenode) + raise + return toc, refdoc + + +def _toctree_url_entry(title: str, ref: str) -> nodes.bullet_list: + if title is None: + title = ref + reference = nodes.reference('', '', internal=False, + refuri=ref, anchorname='', + *[nodes.Text(title)]) + para = addnodes.compact_paragraph('', '', reference) + item = nodes.list_item('', para) + toc = nodes.bullet_list('', item) + return toc + + +def _toctree_self_entry( + title: str, ref: str, titles: dict[str, nodes.title], +) -> nodes.bullet_list: + # 'self' refers to the document from which this + # toctree originates + if not title: + title = clean_astext(titles[ref]) + reference = nodes.reference('', '', internal=True, + refuri=ref, + anchorname='', + *[nodes.Text(title)]) + para = addnodes.compact_paragraph('', '', reference) + item = nodes.list_item('', para) + # don't show subitems + toc = nodes.bullet_list('', item) + return toc + + +def _toctree_generated_entry(title: str, ref: str) -> nodes.bullet_list: + from sphinx.domains.std import StandardDomain + + docname, sectionname = StandardDomain._virtual_doc_names[ref] + if not title: + title = sectionname + reference = nodes.reference('', title, internal=True, + refuri=docname, anchorname='') + para = addnodes.compact_paragraph('', '', reference) + item = nodes.list_item('', para) + # don't show subitems + toc = nodes.bullet_list('', item) + return toc + + +def _toctree_standard_entry( + title: str, + ref: str, + maxdepth: int, + toc: nodes.bullet_list, + toctree_ancestors: Set[str], + prune: bool, + collapse: bool, + tags: Tags, +) -> tuple[nodes.bullet_list, str]: + refdoc = ref + if ref in toctree_ancestors and (not prune or maxdepth <= 0): + toc = toc.deepcopy() + else: + toc = _toctree_copy(toc, 2, maxdepth, collapse, tags) + + if title and toc.children and len(toc.children) == 1: + child = toc.children[0] + for refnode in child.findall(nodes.reference): + if refnode['refuri'] == ref and not refnode['anchorname']: + refnode.children[:] = [nodes.Text(title)] + return toc, refdoc + + +def _toctree_add_classes(node: Element, depth: int, docname: str) -> None: + """Add 'toctree-l%d' and 'current' classes to the toctree.""" + for subnode in node.children: + if isinstance(subnode, (addnodes.compact_paragraph, nodes.list_item)): + # for <p> and <li>, indicate the depth level and recurse + subnode['classes'].append(f'toctree-l{depth - 1}') + _toctree_add_classes(subnode, depth, docname) + elif isinstance(subnode, nodes.bullet_list): + # for <ul>, just recurse + _toctree_add_classes(subnode, depth + 1, docname) + elif isinstance(subnode, nodes.reference): + # for <a>, identify which entries point to the current + # document and therefore may not be collapsed + if subnode['refuri'] == docname: + if not subnode['anchorname']: + # give the whole branch a 'current' class + # (useful for styling it differently) + branchnode: Element = subnode + while branchnode: + branchnode['classes'].append('current') + branchnode = branchnode.parent + # mark the list_item as "on current page" + if subnode.parent.parent.get('iscurrent'): + # but only if it's not already done + return + while subnode: + subnode['iscurrent'] = True + subnode = subnode.parent + + +ET = TypeVar('ET', bound=Element) + + +def _toctree_copy(node: ET, depth: int, maxdepth: int, collapse: bool, tags: Tags) -> ET: + """Utility: Cut and deep-copy a TOC at a specified depth.""" + keep_bullet_list_sub_nodes = (depth <= 1 + or ((depth <= maxdepth or maxdepth <= 0) + and (not collapse or 'iscurrent' in node))) + + copy = node.copy() + for subnode in node.children: + if isinstance(subnode, (addnodes.compact_paragraph, nodes.list_item)): + # for <p> and <li>, just recurse + copy.append(_toctree_copy(subnode, depth, maxdepth, collapse, tags)) + elif isinstance(subnode, nodes.bullet_list): + # for <ul>, copy if the entry is top-level + # or, copy if the depth is within bounds and; + # collapsing is disabled or the sub-entry's parent is 'current'. + # The boolean is constant so is calculated outwith the loop. + if keep_bullet_list_sub_nodes: + copy.append(_toctree_copy(subnode, depth + 1, maxdepth, collapse, tags)) + elif isinstance(subnode, addnodes.toctree): + # copy sub toctree nodes for later processing + copy.append(subnode.copy()) + elif isinstance(subnode, addnodes.only): + # only keep children if the only node matches the tags + if _only_node_keep_children(subnode, tags): + for child in subnode.children: + copy.append(_toctree_copy( + child, depth, maxdepth, collapse, tags, # type: ignore[type-var] + )) + elif isinstance(subnode, (nodes.reference, nodes.title)): + # deep copy references and captions + sub_node_copy = subnode.copy() + sub_node_copy.children = [child.deepcopy() for child in subnode.children] + for child in sub_node_copy.children: + child.parent = sub_node_copy + copy.append(sub_node_copy) + else: + msg = f'Unexpected node type {subnode.__class__.__name__!r}!' + raise ValueError(msg) + return copy + + +def _get_toctree_ancestors( + toctree_includes: dict[str, list[str]], docname: str, +) -> Set[str]: + parent: dict[str, str] = {} + for p, children in toctree_includes.items(): + parent |= dict.fromkeys(children, p) + ancestors: list[str] = [] + d = docname + while d in parent and d not in ancestors: + ancestors.append(d) + d = parent[d] + # use dict keys for ordered set operations + return dict.fromkeys(ancestors).keys() + + +class TocTree: + def __init__(self, env: BuildEnvironment) -> None: + self.env = env + + def note(self, docname: str, toctreenode: addnodes.toctree) -> None: + note_toctree(self.env, docname, toctreenode) + + def resolve(self, docname: str, builder: Builder, toctree: addnodes.toctree, + prune: bool = True, maxdepth: int = 0, titles_only: bool = False, + collapse: bool = False, includehidden: bool = False) -> Element | None: + return _resolve_toctree( + self.env, docname, builder, toctree, + prune=prune, + maxdepth=maxdepth, + titles_only=titles_only, + collapse=collapse, + includehidden=includehidden, + ) + + def get_toctree_ancestors(self, docname: str) -> list[str]: + return [*_get_toctree_ancestors(self.env.toctree_includes, docname)] + + def get_toc_for(self, docname: str, builder: Builder) -> Node: + return document_toc(self.env, docname, self.env.app.builder.tags) + + def get_toctree_for( + self, docname: str, builder: Builder, collapse: bool, **kwargs: Any, + ) -> Element | None: + return global_toctree_for_doc(self.env, docname, builder, collapse=collapse, **kwargs) |