"""
Command-line ``--help`` helpers (muhaha!).
``gluetool`` uses docstrings to generate help for command-line options, modules, shared functions
and other stuff. To generate good looking and useful help texts a bit of work is required.
Add the Sphinx which we use to generate nice documentation of ``gluetool``'s API and structures,
with its directives, and it's even more work to make readable output. Therefore these helpers,
separated in their own file to keep things clean.
"""
import argparse
import ast
import inspect
import os
import textwrap
import docutils.core
import docutils.nodes
import docutils.parsers.rst
import docutils.writers
import sphinx.writers.text
import sphinx.locale
import sphinx.util.nodes
import six
from six import PY2, ensure_str, iteritems
from .color import Colors
from .log import Logging
# Type annotations
# pylint: disable=unused-import, wrong-import-order
from typing import TYPE_CHECKING, cast, Any, Callable, Dict, List, Optional, Tuple, Union # noqa
if TYPE_CHECKING:
import gluetool # noqa
import gluetool.glue # noqa
# Initialize Sphinx locale settings
sphinx.locale.init([os.path.split(sphinx.locale.__file__)], None)
# If not told otherwise, the default maximal length of lines is this many columns.
DEFAULT_WIDTH = 120
# Check what's the actual width of our terminal, or use a default if we're not sure.
try:
WIDTH = int(os.environ['COLUMNS'])
except (KeyError, ValueError):
WIDTH = DEFAULT_WIDTH
# Crop the maximal width to account for various explicit indents.
CROP_WIDTH = WIDTH - 10
# Tell Sphinx to render text into a slightly narrower space to account for some indenting
sphinx.writers.text.MAXWIDTH = CROP_WIDTH
FUNCTIONS_HELP_TEMPLATE = """
{% for signature, body in FUNCTIONS %}
{{ signature }}
{{ body }}
{% endfor %}
"""
EVAL_CONTEXT_HELP_TEMPLATE = """
{{ '** Evaluation context **' | style(fg='yellow') }}
{% for name, description in iteritems(CONTEXT) %}
* {{ name | style(fg='blue') }}
{{ description | indent(4, true) }}
{% endfor %}
"""
# Semantic colorizers
# pylint: disable=invalid-name
[docs]def C_FUNCNAME(text):
# type: (str) -> str
return Colors.style(text, fg='blue', reset=True)
[docs]def C_ARGNAME(text):
# type: (str) -> str
return Colors.style(text, fg='blue', reset=True)
[docs]def C_LITERAL(text):
# type: (str) -> str
return Colors.style(text, fg='cyan', reset=True)
# Our custom TextTranslator which does the same as Sphinx' original but colorizes some of the text bits.
#
# We must save a reference to the original class because we must use it when calling parent's __init__,
# since we cannot use "sphinx.writers.text.TextTranslator" - when we try to call
# sphinx.writers.text.TextTranslator.__init__, it's already set to our custom class => recursion...
# pylint: disable=invalid-name
_original_TextTranslator = sphinx.writers.text.TextTranslator
# pylint: disable=abstract-method
[docs]class TextTranslator(sphinx.writers.text.TextTranslator): # type: ignore # no type info in TextTranslator
# literals, ``foo``
[docs] def visit_literal(self, node):
# type: (Any) -> None
self.add_text(Colors.style('', fg='cyan', reset=False))
[docs] def depart_literal(self, node):
# type: (Any) -> None
self.add_text(Colors.style('', reset=True))
# "fields" are used to represent (shared) function parameters
[docs] def visit_field_name(self, node):
# type: (Any) -> None
_original_TextTranslator.visit_field_name(self, node)
self.add_text(Colors.style('', fg='blue', reset=False))
[docs] def depart_field_name(self, node):
# type: (Any) -> None
self.add_text(Colors.style('', reset=True))
_original_TextTranslator.depart_field_name(self, node)
sphinx.writers.text.TextTranslator = TextTranslator
# Custom help formatter that let's us control line length
[docs]class LineWrapRawTextHelpFormatter(argparse.RawDescriptionHelpFormatter):
def __init__(self, *args, **kwargs):
# type: (*Any, **Any) -> None
if kwargs.get('width', None) is None:
kwargs['width'] = CROP_WIDTH
super(LineWrapRawTextHelpFormatter, self).__init__(*args, **kwargs)
[docs] def _split_lines(self, text, width): # type: ignore # incompatible with super type because of unicode
# type: (str, int) -> List[str]
text = ensure_str(self._whitespace_matcher.sub(' ', ensure_str(text)).strip())
return textwrap.wrap(text, width)
#
# Code to use Sphinx TextWriter & few our helpers to parse
# our docstring to a plain text.
#
[docs]def py_default_role(role, rawtext, text, lineno, inliner, options=None, content=None):
# type: (Any, str, str, int, Any, Optional[Any], Optional[Any]) -> Tuple[Any, Any]
# pylint: disable=unused-argument,too-many-arguments
"""
Default handler we use for ``py:...`` roles, translates text to literal node.
"""
return [docutils.nodes.literal(rawsource=rawtext, text='{}'.format(text))], []
# register default handler for roles we're interested in
for python_role in ('py:class', 'py:meth', 'py:mod'):
docutils.parsers.rst.roles.register_canonical_role(python_role, py_default_role)
[docs]def doc_role_handler(role, rawtext, text, lineno, inliner, options=None, context=None):
# type: (Any, str, str, int, Any, Optional[Any], Optional[Any]) -> Tuple[Any, Any]
# pylint: disable=unused-argument,too-many-arguments
"""
Format ``:doc:`` roles, used to reference another bits of documentation.
"""
_, title, target = sphinx.util.nodes.split_explicit_title(text)
if target and target[0] == '/':
target = 'docs/source/{}.rst'.format(target[1:])
return [docutils.nodes.literal(rawsource=text, text='{} (See {})'.format(title, target))], []
docutils.parsers.rst.roles.register_canonical_role('doc', doc_role_handler)
[docs]class DummyTextBuilder:
# pylint: disable=too-few-public-methods,old-style-class,no-init
"""
Sphinx ``TextWriter`` (and other writers as well) requires an instance of ``Builder``
class that brings configuration into the rendering process. The original ``TextBuilder``
requires Sphinx `application` which brings a lot of other dependencies (e.g. source paths
and similar stuff) which are impractical in our use case ("render short string to plain
text"). Therefore this dummy class which just provides minimal configuration - ``TextWriter``
requires nothing else from ``Builder`` instance.
See ``sphinx/writers/text.py`` for the original implementation.
"""
[docs] class DummyConfig:
# pylint: disable=too-few-public-methods,old-style-class,no-init
text_newlines = '\n'
text_sectionchars = '*=-~"+`'
config = DummyConfig
translator_class = None
[docs]def rst_to_text(text):
# type: (str) -> str
"""
Render given text, written with RST, as plain text.
:param str text: string to render.
:rtype: str
:returns: plain text representation of ``text``.
"""
return ensure_str(docutils.core.publish_string(text, writer=sphinx.writers.text.TextWriter(DummyTextBuilder)))
[docs]def trim_docstring(docstring):
# type: (str) -> str
"""
Quoting `PEP 257 <https://www.python.org/dev/peps/pep-0257/#handling-docstring-indentation>`:
*Docstring processing tools will strip a uniform amount of indentation from
the second and further lines of the docstring, equal to the minimum indentation
of all non-blank lines after the first line. Any indentation in the first line
of the docstring (i.e., up to the first newline) is insignificant and removed.
Relative indentation of later lines in the docstring is retained. Blank lines
should be removed from the beginning and end of the docstring.*
Code bellow follows the quote.
This method does exactly that, therefore we can keep properly aligned docstrings
while still use them for reasonably formatted help texts.
:param str docstring: raw docstring.
:rtype: str
:returns: docstring with lines stripped of leading whitespace.
"""
if not docstring:
return ''
# Convert tabs to spaces (following the normal Python rules)
# and split into a list of lines:
lines = docstring.expandtabs().splitlines()
# Determine minimum indentation (first line doesn't count):
indent = six.MAXSIZE
for line in lines[1:]:
stripped = line.lstrip()
if stripped:
indent = min(indent, len(line) - len(stripped))
# Remove indentation (first line is special):
trimmed = [lines[0].strip()]
if indent < six.MAXSIZE:
for line in lines[1:]:
trimmed.append(line[indent:].rstrip())
# Strip off trailing and leading blank lines:
while trimmed and not trimmed[-1]:
trimmed.pop()
while trimmed and not trimmed[0]:
trimmed.pop(0)
# Return a single string:
return '\n'.join(trimmed)
[docs]def docstring_to_help(docstring, width=None, line_prefix=' '):
# type: (str, Optional[int], str) -> str
"""
Given docstring, process and render it as a plain text. This conversion function
is used to generate nice and readable help strings used when printing help on
command line.
:param str docstring: raw docstring of Python object (function, method, module, etc.).
:param int width: Maximal line width allowed.
:param str line_prefix: prefix each line with this string (e.g. to indent it with few spaces or tabs).
:returns: formatted docstring.
"""
width = width or WIDTH
# Remove leading whitespace
trimmed = trim_docstring(docstring)
# we're using Sphinx RST which doesn't look very nice - convert it to plain text then.
processed = rst_to_text(trimmed)
# For each line - which is actually a paragraph, given the text comes from RST - wrap it
# to fit inside given line length (a bit shorter, there's a prefix for each line!).
wrapped_lines = [] # type: List[str]
for line in processed.splitlines():
if line:
wrapped_lines += textwrap.wrap(
line,
width=width - len(line_prefix),
initial_indent=line_prefix,
subsequent_indent=line_prefix
)
else:
# yeah, we could just append empty string but line_prefix could be any string, e.g. 'foo: '
wrapped_lines.append(line_prefix)
return '\n'.join(wrapped_lines)
[docs]def option_help(txt):
# type: (str) -> str
"""
Given option help text, format it to be more suitable for command-line help.
Options can provide a single line of text, or mutiple lines (using triple
quotes and docstring-like indentation).
:param str txt: Raw option help text.
:returns: Formatted option help text.
"""
# Remove leading whitespace - this won't hurt anyone, and helps docstring-like texts
trimmed = trim_docstring(txt)
processed = rst_to_text(trimmed)
# Merge all lines into a single line
return ' '.join(processed.splitlines())
[docs]def function_help(func, name=None):
# type: (Callable[..., Any], Optional[str]) -> Tuple[str, str]
"""
Uses function's signature and docstring to generate a plain text help describing
the function.
:param callable func: Function to generate help for.
:param str name: If not set, ``func.__name__`` is used by default.
:returns: ``(signature, body)`` pair.
"""
name = name or func.__name__
# construct function signature
# with Python 3 use getfullargspec instead of getargspec
if PY2:
# pylint: disable=deprecated-method
signature = inspect.getargspec(func)
else:
signature = inspect.getfullargspec(func) # pylint: disable=no-member
no_default = object()
defaults = [] # type: List[Union[str, object]]
# arguments that don't have default value are assigned our special value to let us tell the difference
# between "no default" and "None is the default"
if signature.defaults is None:
defaults = [no_default for _ in range(len(signature.args) - 1)]
else:
defaults = [
no_default for _ in range(len(signature.args) - 1 - len(signature.defaults))
] + list(signature.defaults)
args = []
for arg, default in zip(signature.args[1:], defaults):
if default is no_default:
args.append(C_ARGNAME(arg))
else:
if isinstance(default, six.string_types):
default = "'{}'".format(default)
args.append('{}={}'.format(C_ARGNAME(arg), C_LITERAL(cast(str, default))))
return (
# signature
'{}({})'.format(C_FUNCNAME(name), ', '.join(args)),
# body
docstring_to_help(func.__doc__) if func.__doc__ else Colors.style(' No help provided :(', fg='red')
)
[docs]def functions_help(functions):
# type: (List[Tuple[str, Callable[..., Any]]]) -> str
"""
Generate help for a set of functions.
:param list(str, callable) functions: Functions to generate help for, passed as name
and the corresponding callable pairs.
:rtype: str
:returns: Formatted help.
"""
# pylint: disable=cyclic-import
from .utils import render_template
return render_template(
FUNCTIONS_HELP_TEMPLATE,
FUNCTIONS=[
function_help(func, name=name)
for name, func in functions
]
)
[docs]def eval_context_help(source):
# type: (gluetool.glue.Configurable) -> str
"""
Generate and format help for an evaluation context of a module. Looks for context content,
and gives it a nice header, suitable for command-line help, applying formatting along the way.
:param gluetool.glue.Configurable source: object whose eval context help we should format.
:returns: Formatted help.
"""
# pylint: disable=cyclic-import
from .utils import render_template
context_info = extract_eval_context_info(source)
if not context_info:
return ''
context_content = {
name: docstring_to_help(description, line_prefix='') for name, description in iteritems(context_info)
}
return render_template(EVAL_CONTEXT_HELP_TEMPLATE, CONTEXT=context_content)