Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move ReST rendering for CLI help into tmt.utils submodule #3222

Merged
merged 1 commit into from
Sep 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion tmt/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
]

Expand Down
3 changes: 2 additions & 1 deletion tmt/steps/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
258 changes: 1 addition & 257 deletions tmt/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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('<rst-doc>', 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
Loading
Loading