From 1f6789d24fb757184f3613ca6563ca652a6c1ca4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Prchl=C3=ADk?= Date: Thu, 19 Sep 2024 13:00:54 +0200 Subject: [PATCH] Move ReST rendering for CLI help into `tmt.utils` submodule (#3222) --- tmt/cli.py | 3 +- tmt/steps/__init__.py | 3 +- tmt/utils/__init__.py | 258 +--------------------------------------- tmt/utils/rest.py | 271 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 276 insertions(+), 259 deletions(-) create mode 100644 tmt/utils/rest.py diff --git a/tmt/cli.py b/tmt/cli.py index efe7f8bc07..fd6cde3ac8 100644 --- a/tmt/cli.py +++ b/tmt/cli.py @@ -27,6 +27,7 @@ import tmt.templates import tmt.trying import tmt.utils +import tmt.utils.rest from tmt.options import Deprecated, create_options_decorator, option from tmt.utils import Command, Path @@ -250,7 +251,7 @@ def write_dl( col_max: int = 30, col_spacing: int = 2) -> None: rows = [ - (option, tmt.utils.render_rst(help, _BOOTSTRAP_LOGGER)) + (option, tmt.utils.rest.render_rst(help, _BOOTSTRAP_LOGGER)) for option, help in rows ] diff --git a/tmt/steps/__init__.py b/tmt/steps/__init__.py index f5ce7dd695..acc5d205a3 100644 --- a/tmt/steps/__init__.py +++ b/tmt/steps/__init__.py @@ -34,6 +34,7 @@ import tmt.options import tmt.queue import tmt.utils +import tmt.utils.rest from tmt.options import option, show_step_method_hints from tmt.utils import ( DEFAULT_NAME, @@ -1171,7 +1172,7 @@ def __init__( self.name = name self.class_ = class_ - self.doc = tmt.utils.render_rst(doc, tmt.log.Logger.get_bootstrap_logger()) + self.doc = tmt.utils.rest.render_rst(doc, tmt.log.Logger.get_bootstrap_logger()) self.order = order # Parse summary and description from provided doc string diff --git a/tmt/utils/__init__.py b/tmt/utils/__init__.py index e16dad2f60..b806643ee6 100644 --- a/tmt/utils/__init__.py +++ b/tmt/utils/__init__.py @@ -46,10 +46,6 @@ ) import click -import docutils.frontend -import docutils.nodes -import docutils.parsers.rst -import docutils.utils import fmf import fmf.utils import jsonschema @@ -66,7 +62,7 @@ import tmt.log from tmt._compat.pathlib import Path -from tmt.log import LoggableValue, Logger +from tmt.log import LoggableValue if TYPE_CHECKING: from _typeshed import DataclassInstance @@ -5978,255 +5974,3 @@ def is_url(url: str) -> bool: """ Check if the given string is a valid URL """ parsed = urllib.parse.urlparse(url) return bool(parsed.scheme and parsed.netloc) - - -# -# ReST rendering -# -class RestVisitor(docutils.nodes.NodeVisitor): - """ - Custom renderer of docutils nodes. - - See :py:class:`docutils.nodes.NodeVisitor` for details, but the - functionality is fairly simple: for each node type, a pair of - methods is expected, ``visit_$NODE_TYPE`` and ``depart_$NODE_TYPE``. - As the visitor class iterates over nodes in the document, - corresponding methods are called. These methods render the given - node, filling "rendered paragraphs" list with rendered strings. - """ - - def __init__(self, document: docutils.nodes.document, logger: Logger) -> None: - super().__init__(document) - - self.logger = logger - self.debug = functools.partial(logger.debug, level=4, topic=tmt.log.Topic.HELP_RENDERING) - self.log_visit = functools.partial( - logger.debug, 'visit', level=4, topic=tmt.log.Topic.HELP_RENDERING) - self.log_departure = functools.partial( - logger.debug, 'depart', level=4, topic=tmt.log.Topic.HELP_RENDERING) - - #: Collects all rendered paragraps - text, blocks, lists, etc. - self._rendered_paragraphs: list[str] = [] - #: Collect components of a single paragraph - sentences, literals, - #: list items, etc. - self._rendered_paragraph: list[str] = [] - - self.in_literal_block: bool = False - self.in_note: bool = False - self.in_warning: bool = False - - #: Used by rendering of nested blocks, e.g. paragraphs positioned - #: as list items. - self._indent: int = 0 - self._text_prefix: Optional[str] = None - - @property - def rendered(self) -> str: - """ Return the rendered document as a single string """ - - return '\n'.join(self._rendered_paragraphs) - - def _emit(self, s: str) -> None: - """ Add a string to the paragraph being rendered """ - - self._rendered_paragraph.append(s) - - def _emit_paragraphs(self, paragraphs: list[str]) -> None: - """ Add new rendered paragraphs """ - - self._rendered_paragraphs += paragraphs - - def flush(self) -> None: - """ Finalize rendering of the current paragraph """ - - if not self._rendered_paragraph: - self.nl() - - else: - self._emit_paragraphs([''.join(self._rendered_paragraph)]) - self._rendered_paragraph = [] - - def nl(self) -> None: - """ Render a new, empty line """ - - # To simplify the implementation, this is merging of multiple - # empty lines into one. Rendering of nodes than does not have - # to worry about an empty line already being on the stack. - if self._rendered_paragraphs[-1] != '': - self._emit_paragraphs(['']) - - # Simple logging for nodes that have no effect - def _noop_visit(self, node: docutils.nodes.Node) -> None: - self.log_visit(str(node)) - - def _noop_departure(self, node: docutils.nodes.Node) -> None: - self.log_departure(str(node)) - - # Node renderers - visit_document = _noop_visit - - def depart_document(self, node: docutils.nodes.document) -> None: - self.log_departure(str(node)) - - self.flush() - - def visit_paragraph(self, node: docutils.nodes.paragraph) -> None: - self.log_visit(str(node)) - - if isinstance(node.parent, docutils.nodes.list_item): - if self._text_prefix: - self._emit(self._text_prefix) - self._text_prefix = None - - else: - self._emit(' ' * self._indent) - - elif self.in_note: - self._emit(click.style('NOTE: ', fg='blue', bold=True)) - return - - elif self.in_warning: - self._emit(click.style('WARNING: ', fg='yellow', bold=True)) - return - - def depart_paragraph(self, node: docutils.nodes.paragraph) -> None: - self.log_departure(str(node)) - - self.flush() - - def visit_Text(self, node: docutils.nodes.Text) -> None: # noqa: N802 - self.log_visit(str(node)) - - if isinstance(node.parent, docutils.nodes.literal): - return - - if self.in_literal_block: - return - - if self.in_note: - self._emit(click.style(node.astext(), fg='blue')) - - return - - if self.in_warning: - self._emit(click.style(node.astext(), fg='yellow')) - - return - - self._emit(node.astext()) - - depart_Text = _noop_departure # noqa: N815 - - def visit_literal(self, node: docutils.nodes.literal) -> None: - self.log_visit(str(node)) - - self._emit(click.style(node.astext(), fg='green')) - - depart_literal = _noop_departure - - def visit_literal_block(self, node: docutils.nodes.literal_block) -> None: - self.log_visit(str(node)) - - self.flush() - - fg: str = 'cyan' - - if 'yaml' in node.attributes['classes']: - pass - - elif 'shell' in node.attributes['classes']: - fg = 'yellow' - - self._emit_paragraphs([ - f' {click.style(line, fg=fg)}' for line in node.astext().splitlines() - ]) - - self.in_literal_block = True - - def depart_literal_block(self, node: docutils.nodes.literal_block) -> None: - self.log_departure(str(node)) - - self.in_literal_block = False - - self.nl() - - def visit_bullet_list(self, node: docutils.nodes.bullet_list) -> None: - self.log_visit(str(node)) - - self.nl() - - def depart_bullet_list(self, node: docutils.nodes.bullet_list) -> None: - self.log_departure(str(node)) - - self.nl() - - def visit_list_item(self, node: docutils.nodes.list_item) -> None: - self.log_visit(str(node)) - - self._text_prefix = '* ' - self._indent += 2 - - def depart_list_item(self, node: docutils.nodes.list_item) -> None: - self.log_departure(str(node)) - - self._indent -= 2 - - visit_inline = _noop_visit - depart_inline = _noop_departure - - visit_reference = _noop_visit - depart_reference = _noop_departure - - def visit_note(self, node: docutils.nodes.note) -> None: - self.log_visit(str(node)) - - self.nl() - self.in_note = True - - def depart_note(self, node: docutils.nodes.note) -> None: - self.log_departure(str(node)) - - self.in_note = False - self.nl() - - def visit_warning(self, node: docutils.nodes.warning) -> None: - self.log_visit(str(node)) - - self.nl() - self.in_warning = True - - def depart_warning(self, node: docutils.nodes.warning) -> None: - self.log_departure(str(node)) - - self.in_warning = False - self.nl() - - def unknown_visit(self, node: docutils.nodes.Node) -> None: - raise GeneralError(f"Unhandled ReST node '{node}'.") - - def unknown_departure(self, node: docutils.nodes.Node) -> None: - raise GeneralError(f"Unhandled ReST node '{node}'.") - - -def parse_rst(text: str) -> docutils.nodes.document: - """ Parse a ReST document into docutils tree of nodes """ - - parser = docutils.parsers.rst.Parser() - components = (docutils.parsers.rst.Parser,) - settings = docutils.frontend.OptionParser(components=components).get_default_values() - document = docutils.utils.new_document('', settings=settings) - - parser.parse(text, document) - - return document - - -def render_rst(text: str, logger: Logger) -> str: - """ Render a ReST document """ - - document = parse_rst(text) - visitor = RestVisitor(document, logger) - - document.walkabout(visitor) - - return visitor.rendered diff --git a/tmt/utils/rest.py b/tmt/utils/rest.py new file mode 100644 index 0000000000..21e47f0015 --- /dev/null +++ b/tmt/utils/rest.py @@ -0,0 +1,271 @@ +""" +ReST rendering. + +Package provides primitives for ReST rendering used mainly for CLI +help texts. +""" + +import functools +from typing import Optional + +import click +import docutils.frontend +import docutils.nodes +import docutils.parsers.rst +import docutils.utils + +import tmt.log +from tmt.log import Logger +from tmt.utils import GeneralError + + +# +# ReST rendering +# +class RestVisitor(docutils.nodes.NodeVisitor): + """ + Custom renderer of docutils nodes. + + See :py:class:`docutils.nodes.NodeVisitor` for details, but the + functionality is fairly simple: for each node type, a pair of + methods is expected, ``visit_$NODE_TYPE`` and ``depart_$NODE_TYPE``. + As the visitor class iterates over nodes in the document, + corresponding methods are called. These methods render the given + node, filling "rendered paragraphs" list with rendered strings. + """ + + def __init__(self, document: docutils.nodes.document, logger: Logger) -> None: + super().__init__(document) + + self.logger = logger + self.debug = functools.partial(logger.debug, level=4, topic=tmt.log.Topic.HELP_RENDERING) + self.log_visit = functools.partial( + logger.debug, 'visit', level=4, topic=tmt.log.Topic.HELP_RENDERING) + self.log_departure = functools.partial( + logger.debug, 'depart', level=4, topic=tmt.log.Topic.HELP_RENDERING) + + #: Collects all rendered paragraps - text, blocks, lists, etc. + self._rendered_paragraphs: list[str] = [] + #: Collect components of a single paragraph - sentences, literals, + #: list items, etc. + self._rendered_paragraph: list[str] = [] + + self.in_literal_block: bool = False + self.in_note: bool = False + self.in_warning: bool = False + + #: Used by rendering of nested blocks, e.g. paragraphs positioned + #: as list items. + self._indent: int = 0 + self._text_prefix: Optional[str] = None + + @property + def rendered(self) -> str: + """ Return the rendered document as a single string """ + + return '\n'.join(self._rendered_paragraphs) + + def _emit(self, s: str) -> None: + """ Add a string to the paragraph being rendered """ + + self._rendered_paragraph.append(s) + + def _emit_paragraphs(self, paragraphs: list[str]) -> None: + """ Add new rendered paragraphs """ + + self._rendered_paragraphs += paragraphs + + def flush(self) -> None: + """ Finalize rendering of the current paragraph """ + + if not self._rendered_paragraph: + self.nl() + + else: + self._emit_paragraphs([''.join(self._rendered_paragraph)]) + self._rendered_paragraph = [] + + def nl(self) -> None: + """ Render a new, empty line """ + + # To simplify the implementation, this is merging of multiple + # empty lines into one. Rendering of nodes than does not have + # to worry about an empty line already being on the stack. + if self._rendered_paragraphs[-1] != '': + self._emit_paragraphs(['']) + + # Simple logging for nodes that have no effect + def _noop_visit(self, node: docutils.nodes.Node) -> None: + self.log_visit(str(node)) + + def _noop_departure(self, node: docutils.nodes.Node) -> None: + self.log_departure(str(node)) + + # Node renderers + visit_document = _noop_visit + + def depart_document(self, node: docutils.nodes.document) -> None: + self.log_departure(str(node)) + + self.flush() + + def visit_paragraph(self, node: docutils.nodes.paragraph) -> None: + self.log_visit(str(node)) + + if isinstance(node.parent, docutils.nodes.list_item): + if self._text_prefix: + self._emit(self._text_prefix) + self._text_prefix = None + + else: + self._emit(' ' * self._indent) + + elif self.in_note: + self._emit(click.style('NOTE: ', fg='blue', bold=True)) + return + + elif self.in_warning: + self._emit(click.style('WARNING: ', fg='yellow', bold=True)) + return + + def depart_paragraph(self, node: docutils.nodes.paragraph) -> None: + self.log_departure(str(node)) + + self.flush() + + def visit_Text(self, node: docutils.nodes.Text) -> None: # noqa: N802 + self.log_visit(str(node)) + + if isinstance(node.parent, docutils.nodes.literal): + return + + if self.in_literal_block: + return + + if self.in_note: + self._emit(click.style(node.astext(), fg='blue')) + + return + + if self.in_warning: + self._emit(click.style(node.astext(), fg='yellow')) + + return + + self._emit(node.astext()) + + depart_Text = _noop_departure # noqa: N815 + + def visit_literal(self, node: docutils.nodes.literal) -> None: + self.log_visit(str(node)) + + self._emit(click.style(node.astext(), fg='green')) + + depart_literal = _noop_departure + + def visit_literal_block(self, node: docutils.nodes.literal_block) -> None: + self.log_visit(str(node)) + + self.flush() + + fg: str = 'cyan' + + if 'yaml' in node.attributes['classes']: + pass + + elif 'shell' in node.attributes['classes']: + fg = 'yellow' + + self._emit_paragraphs([ + f' {click.style(line, fg=fg)}' for line in node.astext().splitlines() + ]) + + self.in_literal_block = True + + def depart_literal_block(self, node: docutils.nodes.literal_block) -> None: + self.log_departure(str(node)) + + self.in_literal_block = False + + self.nl() + + def visit_bullet_list(self, node: docutils.nodes.bullet_list) -> None: + self.log_visit(str(node)) + + self.nl() + + def depart_bullet_list(self, node: docutils.nodes.bullet_list) -> None: + self.log_departure(str(node)) + + self.nl() + + def visit_list_item(self, node: docutils.nodes.list_item) -> None: + self.log_visit(str(node)) + + self._text_prefix = '* ' + self._indent += 2 + + def depart_list_item(self, node: docutils.nodes.list_item) -> None: + self.log_departure(str(node)) + + self._indent -= 2 + + visit_inline = _noop_visit + depart_inline = _noop_departure + + visit_reference = _noop_visit + depart_reference = _noop_departure + + def visit_note(self, node: docutils.nodes.note) -> None: + self.log_visit(str(node)) + + self.nl() + self.in_note = True + + def depart_note(self, node: docutils.nodes.note) -> None: + self.log_departure(str(node)) + + self.in_note = False + self.nl() + + def visit_warning(self, node: docutils.nodes.warning) -> None: + self.log_visit(str(node)) + + self.nl() + self.in_warning = True + + def depart_warning(self, node: docutils.nodes.warning) -> None: + self.log_departure(str(node)) + + self.in_warning = False + self.nl() + + def unknown_visit(self, node: docutils.nodes.Node) -> None: + raise GeneralError(f"Unhandled ReST node '{node}'.") + + def unknown_departure(self, node: docutils.nodes.Node) -> None: + raise GeneralError(f"Unhandled ReST node '{node}'.") + + +def parse_rst(text: str) -> docutils.nodes.document: + """ Parse a ReST document into docutils tree of nodes """ + + parser = docutils.parsers.rst.Parser() + components = (docutils.parsers.rst.Parser,) + settings = docutils.frontend.OptionParser(components=components).get_default_values() + document = docutils.utils.new_document('', settings=settings) + + parser.parse(text, document) + + return document + + +def render_rst(text: str, logger: Logger) -> str: + """ Render a ReST document """ + + document = parse_rst(text) + visitor = RestVisitor(document, logger) + + document.walkabout(visitor) + + return visitor.rendered