Simplify `Tags` (#12490)

- Use a set to store the list of tags
- Cache evaluated expressions
- Extract locally-defined function into a private method
This commit is contained in:
Adam Turner
2024-06-29 10:21:23 +01:00
committed by GitHub
parent 3b3a7d911b
commit d2f1acc0ca
6 changed files with 80 additions and 56 deletions

View File

@@ -51,9 +51,9 @@ Important points to note:
* There is a special object named ``tags`` available in the config file. * There is a special object named ``tags`` available in the config file.
It can be used to query and change the tags (see :ref:`tags`). Use It can be used to query and change the tags (see :ref:`tags`). Use
``tags.has('tag')`` to query, ``tags.add('tag')`` and ``tags.remove('tag')`` ``'tag' in tags`` to query, ``tags.add('tag')`` and ``tags.remove('tag')``
to change. Only tags set via the ``-t`` command-line option or via to change. Only tags set via the ``-t`` command-line option or via
``tags.add('tag')`` can be queried using ``tags.has('tag')``. ``tags.add('tag')`` can be queried using ``'tag' in tags``.
Note that the current builder tag is not available in ``conf.py``, as it is Note that the current builder tag is not available in ``conf.py``, as it is
created *after* the builder is initialized. created *after* the builder is initialized.

View File

@@ -140,7 +140,7 @@ class Sphinx:
buildername: str, confoverrides: dict | None = None, buildername: str, confoverrides: dict | None = None,
status: IO | None = sys.stdout, warning: IO | None = sys.stderr, status: IO | None = sys.stdout, warning: IO | None = sys.stderr,
freshenv: bool = False, warningiserror: bool = False, freshenv: bool = False, warningiserror: bool = False,
tags: list[str] | None = None, tags: Sequence[str] = (),
verbosity: int = 0, parallel: int = 0, keep_going: bool = False, verbosity: int = 0, parallel: int = 0, keep_going: bool = False,
pdb: bool = False) -> None: pdb: bool = False) -> None:
"""Initialize the Sphinx application. """Initialize the Sphinx application.

View File

@@ -92,8 +92,8 @@ class Builder:
self.tags: Tags = app.tags self.tags: Tags = app.tags
self.tags.add(self.format) self.tags.add(self.format)
self.tags.add(self.name) self.tags.add(self.name)
self.tags.add("format_%s" % self.format) self.tags.add(f'format_{self.format}')
self.tags.add("builder_%s" % self.name) self.tags.add(f'builder_{self.name}')
# images that need to be copied over (source -> dest) # images that need to be copied over (source -> dest)
self.images: dict[str, str] = {} self.images: dict[str, str] = {}

View File

@@ -119,8 +119,8 @@ class GettextRenderer(SphinxRenderer):
class I18nTags(Tags): class I18nTags(Tags):
"""Dummy tags module for I18nBuilder. """Dummy tags module for I18nBuilder.
To translate all text inside of only nodes, this class To ensure that all text inside ``only`` nodes is translated,
always returns True value even if no tags are defined. this class always returns ``True`` regardless the defined tags.
""" """
def eval_condition(self, condition: Any) -> bool: def eval_condition(self, condition: Any) -> bool:

View File

@@ -21,7 +21,7 @@ from sphinx.util.console import strip_colors
from sphinx.util.docutils import additional_nodes from sphinx.util.docutils import additional_nodes
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Mapping from collections.abc import Mapping, Sequence
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from xml.etree.ElementTree import ElementTree from xml.etree.ElementTree import ElementTree
@@ -112,7 +112,7 @@ class SphinxTestApp(sphinx.application.Sphinx):
confoverrides: dict[str, Any] | None = None, confoverrides: dict[str, Any] | None = None,
status: StringIO | None = None, status: StringIO | None = None,
warning: StringIO | None = None, warning: StringIO | None = None,
tags: list[str] | None = None, tags: Sequence[str] = (),
docutils_conf: str | None = None, # extra constructor argument docutils_conf: str | None = None, # extra constructor argument
parallel: int = 0, parallel: int = 0,
# additional arguments at the end to keep the signature # additional arguments at the end to keep the signature

View File

@@ -1,36 +1,36 @@
from __future__ import annotations from __future__ import annotations
import warnings
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from jinja2 import nodes import jinja2.environment
from jinja2.environment import Environment import jinja2.nodes
from jinja2.parser import Parser import jinja2.parser
from sphinx.deprecation import RemovedInSphinx90Warning
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterator from collections.abc import Iterator, Sequence
from typing import Literal
from jinja2.nodes import Node _ENV = jinja2.environment.Environment()
env = Environment() class BooleanParser(jinja2.parser.Parser):
"""Only allow conditional expressions and binary operators."""
def parse_compare(self) -> jinja2.nodes.Expr:
class BooleanParser(Parser): node: jinja2.nodes.Expr
"""
Only allow condition exprs and/or/not operations.
"""
def parse_compare(self) -> nodes.Expr:
node: nodes.Expr
token = self.stream.current token = self.stream.current
if token.type == 'name': if token.type == 'name':
if token.value in ('true', 'false', 'True', 'False'): if token.value in {'true', 'True'}:
node = nodes.Const(token.value in ('true', 'True'), node = jinja2.nodes.Const(True, lineno=token.lineno)
lineno=token.lineno) elif token.value in {'false', 'False'}:
elif token.value in ('none', 'None'): node = jinja2.nodes.Const(False, lineno=token.lineno)
node = nodes.Const(None, lineno=token.lineno) elif token.value in {'none', 'None'}:
node = jinja2.nodes.Const(None, lineno=token.lineno)
else: else:
node = nodes.Name(token.value, 'load', lineno=token.lineno) node = jinja2.nodes.Name(token.value, 'load', lineno=token.lineno)
next(self.stream) next(self.stream)
elif token.type == 'lparen': elif token.type == 'lparen':
next(self.stream) next(self.stream)
@@ -42,47 +42,71 @@ class BooleanParser(Parser):
class Tags: class Tags:
def __init__(self, tags: list[str] | None = None) -> None: def __init__(self, tags: Sequence[str] = ()) -> None:
self.tags = dict.fromkeys(tags or [], True) self._tags = set(tags or ())
self._condition_cache: dict[str, bool] = {}
def has(self, tag: str) -> bool: def __str__(self) -> str:
return tag in self.tags return f'{self.__class__.__name__}({", ".join(sorted(self._tags))})'
__contains__ = has def __repr__(self) -> str:
return f'{self.__class__.__name__}({tuple(sorted(self._tags))})'
def __iter__(self) -> Iterator[str]: def __iter__(self) -> Iterator[str]:
return iter(self.tags) return iter(self._tags)
def __contains__(self, tag: str) -> bool:
return tag in self._tags
def has(self, tag: str) -> bool:
return tag in self._tags
def add(self, tag: str) -> None: def add(self, tag: str) -> None:
self.tags[tag] = True self._tags.add(tag)
def remove(self, tag: str) -> None: def remove(self, tag: str) -> None:
self.tags.pop(tag, None) self._tags.discard(tag)
@property
def tags(self) -> dict[str, Literal[True]]:
warnings.warn('Tags.tags is deprecated, use methods on Tags.',
RemovedInSphinx90Warning, stacklevel=2)
return dict.fromkeys(self._tags, True)
def eval_condition(self, condition: str) -> bool: def eval_condition(self, condition: str) -> bool:
"""Evaluate a boolean condition.
Only conditional expressions and binary operators (and, or, not)
are permitted, and operate on tag names, where truthy values mean
the tag is present and vice versa.
"""
if condition in self._condition_cache:
return self._condition_cache[condition]
# exceptions are handled by the caller # exceptions are handled by the caller
parser = BooleanParser(env, condition, state='variable') parser = BooleanParser(_ENV, condition, state='variable')
expr = parser.parse_expression() expr = parser.parse_expression()
if not parser.stream.eos: if not parser.stream.eos:
msg = 'chunk after expression' msg = 'chunk after expression'
raise ValueError(msg) raise ValueError(msg)
def eval_node(node: Node | None) -> bool: evaluated = self._condition_cache[condition] = self._eval_node(expr)
if isinstance(node, nodes.CondExpr): return evaluated
if eval_node(node.test):
return eval_node(node.expr1)
else:
return eval_node(node.expr2)
elif isinstance(node, nodes.And):
return eval_node(node.left) and eval_node(node.right)
elif isinstance(node, nodes.Or):
return eval_node(node.left) or eval_node(node.right)
elif isinstance(node, nodes.Not):
return not eval_node(node.node)
elif isinstance(node, nodes.Name):
return self.tags.get(node.name, False)
else:
msg = 'invalid node, check parsing'
raise ValueError(msg)
return eval_node(expr) def _eval_node(self, node: jinja2.nodes.Node | None) -> bool:
if isinstance(node, jinja2.nodes.CondExpr):
if self._eval_node(node.test):
return self._eval_node(node.expr1)
else:
return self._eval_node(node.expr2)
elif isinstance(node, jinja2.nodes.And):
return self._eval_node(node.left) and self._eval_node(node.right)
elif isinstance(node, jinja2.nodes.Or):
return self._eval_node(node.left) or self._eval_node(node.right)
elif isinstance(node, jinja2.nodes.Not):
return not self._eval_node(node.node)
elif isinstance(node, jinja2.nodes.Name):
return node.name in self._tags
else:
msg = 'invalid node, check parsing'
raise ValueError(msg)