diff --git a/src/borg/archiver.py b/src/borg/archiver.py index 1b135ee60..629a9bddf 100644 --- a/src/borg/archiver.py +++ b/src/borg/archiver.py @@ -64,6 +64,7 @@ from .helpers import replace_placeholders from .helpers import ChunkIteratorFileWrapper from .helpers import popen_with_error_handling +from .nanorst import RstToTextLazy, ansi_escapes from .patterns import ArgparsePatternAction, ArgparseExcludeFileAction, ArgparsePatternFileAction, parse_exclude_pattern from .patterns import PatternMatcher from .item import Item @@ -2256,6 +2257,18 @@ def resolve(self, args: argparse.Namespace): # Namespace has "in" but otherwise setattr(args, dest, option_value) def build_parser(self): + if hasattr(sys.stdout, 'isatty') and sys.stdout.isatty() and (sys.platform != 'win32' or 'ANSICON' in os.environ): + rst_state_hook = ansi_escapes + else: + rst_state_hook = None + + # You can use :ref:`xyz` in the following usage pages. However, for plain-text view, + # e.g. through "borg ... --help", define a substitution for the reference here. + # It will replace the entire :ref:`foo` verbatim. + rst_plain_text_references = { + 'a_status_oddity': '"I am seeing ‘A’ (added) status for a unchanged file!?"', + } + def process_epilog(epilog): epilog = textwrap.dedent(epilog).splitlines() try: @@ -2264,7 +2277,10 @@ def process_epilog(epilog): mode = 'command-line' if mode in ('command-line', 'build_usage'): epilog = [line for line in epilog if not line.startswith('.. man')] - return '\n'.join(epilog) + epilog = '\n'.join(epilog) + if mode == 'command-line': + epilog = RstToTextLazy(epilog, rst_state_hook, rst_plain_text_references) + return epilog def define_common_options(add_common_option): add_common_option('-h', '--help', action='help', help='show this help message and exit') @@ -3451,7 +3467,7 @@ def define_common_options(add_common_option): used to have upgraded Borg 0.xx or Attic archives deduplicate with Borg 1.x archives. - USE WITH CAUTION. + **USE WITH CAUTION.** Depending on the PATHs and patterns given, recreate can be used to permanently delete files from archives. When in doubt, use "--dry-run --verbose --list" to see how patterns/PATHS are @@ -3761,7 +3777,7 @@ def define_common_options(add_common_option): It creates input data below the given PATH and backups this data into the given REPO. The REPO must already exist (it could be a fresh empty repo or an existing repo, the - command will create / read / update / delete some archives named borg-test-data* there. + command will create / read / update / delete some archives named borg-test-data\* there. Make sure you have free space there, you'll need about 1GB each (+ overhead). diff --git a/src/borg/nanorst.py b/src/borg/nanorst.py new file mode 100644 index 000000000..3056a0ffe --- /dev/null +++ b/src/borg/nanorst.py @@ -0,0 +1,162 @@ + +import io + + +class TextPecker: + def __init__(self, s): + self.str = s + self.i = 0 + + def read(self, n): + self.i += n + return self.str[self.i - n:self.i] + + def peek(self, n): + if n >= 0: + return self.str[self.i:self.i + n] + else: + return self.str[self.i + n - 1:self.i - 1] + + def peekline(self): + out = '' + i = self.i + while i < len(self.str) and self.str[i] != '\n': + out += self.str[i] + i += 1 + return out + + def readline(self): + out = self.peekline() + self.i += len(out) + return out + + +def rst_to_text(text, state_hook=None, references=None): + """ + Convert rST to a more human text form. + + This is a very loose conversion. No advanced rST features are supported. + The generated output directly depends on the input (e.g. indentation of + admonitions). + """ + state_hook = state_hook or (lambda old_state, new_state, out: None) + references = references or {} + state = 'text' + text = TextPecker(text) + out = io.StringIO() + + inline_single = ('*', '`') + + while True: + char = text.read(1) + if not char: + break + next = text.peek(1) # type: str + + if state == 'text': + if text.peek(-1) != '\\': + if char in inline_single and next not in inline_single: + state_hook(state, char, out) + state = char + continue + if char == next == '*': + state_hook(state, '**', out) + state = '**' + text.read(1) + continue + if char == next == '`': + state_hook(state, '``', out) + state = '``' + text.read(1) + continue + if text.peek(-1).isspace() and char == ':' and text.peek(5) == 'ref:`': + # translate reference + text.read(5) + ref = '' + while True: + char = text.peek(1) + if char == '`': + text.read(1) + break + if char == '\n': + text.read(1) + continue # merge line breaks in :ref:`...\n...` + ref += text.read(1) + try: + out.write(references[ref]) + except KeyError: + raise ValueError("Undefined reference in Archiver help: %r — please add reference substitution" + "to 'rst_plain_text_references'" % ref) + continue + if text.peek(-2) in ('\n\n', '') and char == next == '.': + text.read(2) + try: + directive, arguments = text.peekline().split('::', maxsplit=1) + except ValueError: + directive = None + text.readline() + text.read(1) + if not directive: + continue + out.write(directive.title()) + out.write(':\n') + if arguments: + out.write(arguments) + out.write('\n') + continue + if state in inline_single and char == state: + state_hook(state, 'text', out) + state = 'text' + continue + if state == '``' and char == next == '`': + state_hook(state, 'text', out) + state = 'text' + text.read(1) + continue + if state == '**' and char == next == '*': + state_hook(state, 'text', out) + state = 'text' + text.read(1) + continue + out.write(char) + + assert state == 'text', 'Invalid final state %r (This usually indicates unmatched */**)' % state + return out.getvalue() + + +def ansi_escapes(old_state, new_state, out): + if old_state == 'text' and new_state in ('*', '`', '``'): + out.write('\033[4m') + if old_state == 'text' and new_state == '**': + out.write('\033[1m') + if old_state in ('*', '`', '``', '**') and new_state == 'text': + out.write('\033[0m') + + +class RstToTextLazy: + def __init__(self, str, state_hook=None, references=None): + self.str = str + self.state_hook = state_hook + self.references = references + self._rst = None + + @property + def rst(self): + if self._rst is None: + self._rst = rst_to_text(self.str, self.state_hook, self.references) + return self._rst + + def __getattr__(self, item): + return getattr(self.rst, item) + + def __str__(self): + return self.rst + + def __add__(self, other): + return self.rst + other + + def __iter__(self): + return iter(self.rst) + + def __contains__(self, item): + return item in self.rst diff --git a/src/borg/testsuite/archiver.py b/src/borg/testsuite/archiver.py index 1ce661ef5..8ae235f20 100644 --- a/src/borg/testsuite/archiver.py +++ b/src/borg/testsuite/archiver.py @@ -30,6 +30,7 @@ except ImportError: pass +import borg from .. import xattr, helpers, platform from ..archive import Archive, ChunkBuffer, flags_noatime, flags_normal from ..archiver import Archiver, parse_storage_quota @@ -44,6 +45,7 @@ from ..helpers import EXIT_SUCCESS, EXIT_WARNING, EXIT_ERROR from ..helpers import bin_to_hex from ..helpers import MAX_S +from ..nanorst import RstToTextLazy from ..patterns import IECommand, PatternMatcher, parse_pattern from ..item import Item from ..logger import setup_logging @@ -3335,3 +3337,33 @@ def test_parse_storage_quota(): assert parse_storage_quota('50M') == 50 * 1000**2 with pytest.raises(argparse.ArgumentTypeError): parse_storage_quota('5M') + + +def get_all_parsers(): + """ + Return dict mapping command to parser. + """ + parser = Archiver(prog='borg').build_parser() + parsers = {} + + def discover_level(prefix, parser, Archiver): + choices = {} + for action in parser._actions: + if action.choices is not None and 'SubParsersAction' in str(action.__class__): + for cmd, parser in action.choices.items(): + choices[prefix + cmd] = parser + if prefix and not choices: + return + + for command, parser in sorted(choices.items()): + discover_level(command + " ", parser, Archiver) + parsers[command] = parser + + discover_level("", parser, Archiver) + return parsers + + +@pytest.mark.parametrize('command, parser', list(get_all_parsers().items())) +def test_help_formatting(command, parser): + if isinstance(parser.epilog, RstToTextLazy): + assert parser.epilog.rst diff --git a/src/borg/testsuite/nanorst.py b/src/borg/testsuite/nanorst.py new file mode 100644 index 000000000..9b0cb7608 --- /dev/null +++ b/src/borg/testsuite/nanorst.py @@ -0,0 +1,37 @@ + +import pytest + +from ..nanorst import rst_to_text + + +def test_inline(): + assert rst_to_text('*foo* and ``bar``.') == 'foo and bar.' + + +def test_inline_spread(): + assert rst_to_text('*foo and bar, thusly\nfoobar*.') == 'foo and bar, thusly\nfoobar.' + + +def test_comment_inline(): + assert rst_to_text('Foo and Bar\n.. foo\nbar') == 'Foo and Bar\n.. foo\nbar' + + +def test_comment(): + assert rst_to_text('Foo and Bar\n\n.. foo\nbar') == 'Foo and Bar\n\nbar' + + +def test_directive_note(): + assert rst_to_text('.. note::\n Note this and that') == 'Note:\n Note this and that' + + +def test_ref(): + references = { + 'foo': 'baz' + } + assert rst_to_text('See :ref:`fo\no`.', references=references) == 'See baz.' + + +def test_undefined_ref(): + with pytest.raises(ValueError) as exc_info: + rst_to_text('See :ref:`foo`.') + assert 'Undefined reference' in str(exc_info.value)