Source code for pyrocko.cli

# http://pyrocko.org - GPLv3
#
# The Pyrocko Developers, 21st Century
# ---|P------/S----------~Lg----------

'''
Command line argument parsing infrastructure for Pyrocko's tools.
'''

import os
import re
import inspect
import argparse
import textwrap


def dq(x):
    return '``%s``' % x


def ldq(xs):
    return ', '.join(dq(x) for x in xs)


# Since Python 3.13, argparse may colorize its output with ANSI escape
# sequences (see ``can_colorize`` in the standard library's ``_colorize``
# module). The custom wrapping and paragraph-detection logic below predates
# this and works on the assumption that its input is plain text: it uses
# ``len()``-based wrapping (via ``textwrap``) and ``str.startswith('usage:')``
# checks to recognize the usage line, both of which are thrown off by leading
# or embedded escape sequences (e.g. the usage line then starts with
# ``\x1b[1;34m`` rather than with ``usage:``, some words get glued to escape
# sequences that count towards the wrap width although they render with zero
# width, and a wrap point can end up splitting an escape sequence or a
# colorized word in two).
#
# Colorized help is a nice feature for interactive use, so rather than
# disabling it, ``_ansi_wrap`` below reimplements the relevant bits of
# ``wrap``/``wrap_usage`` to treat ANSI SGR escape sequences as zero-width:
# it wraps a color-stripped copy of the text with the real
# ``textwrap.wrap`` (getting its exact word-splitting and whitespace
# handling for free) and then replays the result against the original,
# colorized text to reattach the escape sequences at the right positions.
#
# Colorized output is unwanted for the ``rst`` help used to build the docs
# (see ``PyrockoArgumentParser``), where stray escape sequences would end up
# in the generated ``rst`` source, so that path still asks argparse not to
# colorize in the first place (see ``_argparse_supports_color`` below).
#
# ``color`` was added as a keyword argument to ``argparse.ArgumentParser``
# and ``argparse.HelpFormatter`` in a later Python 3 version than the
# minimum supported here, hence the feature check instead of a version
# check.
_argparse_supports_color = 'color' in inspect.signature(
    argparse.ArgumentParser.__init__).parameters

_ansi_sgr_re = re.compile(r'\x1b\[[0-9;]*m')

_triple_backtick_re = re.compile(r'```(.*?)```', re.S)
_double_backtick_re = re.compile(r'``(.*?)``', re.S)

_markup_bold = '\x1b[1m'
_markup_reset = '\x1b[0m'


def _visible(s):
    return _ansi_sgr_re.sub('', s)


def _colorize_markup(s, color):
    '''
    Replace our own :literal:`\\`\\`code\\`\\`` /
    :literal:`\\`\\`\\`code\\`\\`\\`` markup (see ``PyrockoArgumentParser``)
    with its normal-``--help`` rendering: :literal:`\\`\\`code\\`\\`` becomes
    ``code``,
    :literal:`\\`\\`\\`code\\`\\`\\`` becomes ``'code'``. If ``color`` is set
    (i.e. argparse is colorizing this output, see ``format_help``), the
    replacement text is additionally set in bold, so it stands out from the
    surrounding prose the same way inline code does in the ``rst`` docs.
    '''

    if color:
        s = _triple_backtick_re.sub(
            lambda m: "%s'%s'%s" % (
                _markup_bold, m.group(1), _markup_reset), s)
        s = _double_backtick_re.sub(
            lambda m: '%s%s%s' % (
                _markup_bold, m.group(1), _markup_reset), s)
    else:
        s = _triple_backtick_re.sub(lambda m: "'%s'" % m.group(1), s)
        s = _double_backtick_re.sub(lambda m: m.group(1), s)

    return s


def _ansi_wrap(text, width, subsequent_indent=''):
    '''
    Like :py:func:`textwrap.wrap`, but treats ANSI SGR escape sequences
    embedded in ``text`` as zero-width, so that e.g. a colorized option flag
    does not count towards the wrap width and does not get split apart by a
    wrap point landing inside it.
    '''

    fragments = _ansi_sgr_re.split(text)
    codes = _ansi_sgr_re.findall(text)
    if not codes:
        return textwrap.wrap(
            text, width, subsequent_indent=subsequent_indent,
            break_on_hyphens=False)

    plain = ''.join(fragments)

    # gaps[i]: escape sequences occurring in `text` immediately before the
    # visible character at plain[i] (gaps[len(plain)]: trailing escape
    # sequences occurring after the last visible character).
    gaps = [''] * (len(plain) + 1)
    pos = 0
    for fragment, code in zip(fragments, codes):
        pos += len(fragment)
        gaps[pos] += code

    # Locate each wrapped line's (start, end) span in `plain`.
    spans = []
    cursor = 0
    for i, line in enumerate(
            textwrap.wrap(
                plain, width, subsequent_indent=subsequent_indent,
                break_on_hyphens=False)):

        content = line[len(subsequent_indent):] if i > 0 else line
        start = plain.index(content, cursor)
        end = start + len(content)
        spans.append((start, end))
        cursor = end

    # Build each line's colorized text. Escape sequences occurring exactly
    # at a line's end (gaps[end], e.g. a reset right after the last word of
    # that line) are attached to that line, unless the next line's span
    # starts at that very same position -- which happens when a single
    # word had to be split apart because it alone exceeded the width (no
    # whitespace was dropped in between); in that case, the escape
    # sequences are picked up as the leading gap of the next line instead,
    # so that they are emitted exactly once either way.
    lines = []
    for i, (start, end) in enumerate(spans):
        colorized = ''.join(
            gaps[j] + plain[j] for j in range(start, end))
        next_start = spans[i + 1][0] if i + 1 < len(spans) else None
        if next_start != end:
            colorized += gaps[end]
        lines.append((subsequent_indent if i > 0 else '') + colorized)

    return lines


def unwrap(s):
    if s is None:
        return None
    s = s.strip()
    parts = re.split(r'\n{2,}', s)
    lines = []
    for part in parts:
        plines = part.splitlines()
        if not any(line.startswith('  ') for line in plines):
            lines.append(' '.join(plines))
        else:
            lines.extend(plines)

        lines.append('')

    return '\n'.join(lines)


def wrap(s):
    lines = []
    parts = re.split(r'\n{2,}', s)
    for part in parts:
        plines = part.splitlines()
        if _visible(part).startswith('usage:') \
                or all(line.startswith('    ') for line in plines):
            lines.extend(plines)
        else:
            for line in plines:
                if not line:
                    lines.append(line)
                if not line.startswith(' '):
                    lines.extend(
                        _ansi_wrap(line, 79))
                else:
                    lines.extend(
                        _ansi_wrap(line, 79, subsequent_indent=' '*24))

        lines.append('')

    return '\n'.join(lines)


def wrap_usage(s):
    lines = []
    for line in s.splitlines():
        if not line.startswith('usage:'):
            lines.append(line)
        else:
            lines.extend(textwrap.wrap(
                line, 79, subsequent_indent=' '*24, break_on_hyphens=False))

    return '\n'.join(lines)


def formatter_with_width(n):
    class PyrockoHelpFormatter(argparse.RawDescriptionHelpFormatter):
        def __init__(self, *args, **kwargs):
            kwargs['width'] = n
            kwargs['max_help_position'] = 24
            argparse.RawDescriptionHelpFormatter.__init__(
                self, *args, **kwargs)

            # fix alignment problems, with the post-processing wrapping
            self._action_max_length = 24

    return PyrockoHelpFormatter


[docs] class PyrockoArgumentParser(argparse.ArgumentParser): ''' Tweaks and extends the standard argument parser to simplify the generation of the online docs. We want to convert the ``--help`` outputs to ``rst`` for the html docs. Problem is that argparse's ``HelpFormatter`` to date have no public interface which we could use to achieve this. The solution here is a bit clunky but works ok for our purposes. We allow markup like :literal:`\\`\\`code\\`\\`` which is kept when producing ``rst`` (by parsing the final ``--help`` output) but stripped out when doing normal ``--help``. This leads to a problem with the internal output wrapping of argparse which it does before the stripping. To solve, we render with argparse to a very wide width and do the wrapping in post-processing. :literal:`\\`\\`code\\`\\`` is replaced with just ``code`` in normal output. :literal:`\\`\\`\\`code\\`\\`\\`` is replaced with ``'code'`` in normal output and with :literal:`\\`\\`code\\`\\`` in ``rst`` output. ``rst`` output is selected with environment variable ``PYROCKO_RST_HELP=1``. The script ``maintenance/argparse_help_to_rst.py`` extracts the ``rst`` help and generates the ``rst`` files for the docs. ''' def __init__( self, prog=None, usage=None, description=None, epilog=None, **kwargs): kwargs['formatter_class'] = formatter_with_width(1000000) if _argparse_supports_color \ and os.environ.get('PYROCKO_RST_HELP', '0') != '0': # Keep the docs free of stray escape sequences. kwargs.setdefault('color', False) description = unwrap(description) epilog = unwrap(epilog) argparse.ArgumentParser.__init__( self, prog=prog, usage=usage, description=description, epilog=epilog, **kwargs) if hasattr(self, '_action_groups'): for group in self._action_groups: if group.title == 'positional arguments': group.title = 'Positional arguments' elif group.title == 'optional arguments': group.title = 'Optional arguments' elif group.title == 'options': group.title = 'Options' self.raw_help = False def format_help(self, *args, **kwargs): s = argparse.ArgumentParser.format_help(self, *args, **kwargs) # If argparse put any color into its own output (flags, metavars, # headers, ...), colorize our own backtick markup consistently. color = bool(_ansi_sgr_re.search(s)) # replace usage with wrapped one from argparse because naive wrapping # does not look good. formatter_class = self.formatter_class self.formatter_class = formatter_with_width(79) usage = self.format_usage() self.formatter_class = formatter_class lines = [] for line in s.splitlines(): if _visible(line).startswith('usage:'): lines.append(usage) else: lines.append(line) s = '\n'.join(lines) if os.environ.get('PYROCKO_RST_HELP', '0') == '0': s = _colorize_markup(s, color) s = wrap(s) else: s = s.replace('```', '``') s = wrap_usage(s) return s
__all__ = [ 'PyrockoArgumentParser', 'dq', 'ldq', ]