Browse Source

Merge pull request #2632 from enkore/docs/nanorst

nanorst for --help
enkore 8 years ago
parent
commit
19c8adb109
4 changed files with 250 additions and 3 deletions
  1. 19 3
      src/borg/archiver.py
  2. 162 0
      src/borg/nanorst.py
  3. 32 0
      src/borg/testsuite/archiver.py
  4. 37 0
      src/borg/testsuite/nanorst.py

+ 19 - 3
src/borg/archiver.py

@@ -64,6 +64,7 @@ from .helpers import basic_json_data, json_print
 from .helpers import replace_placeholders
 from .helpers import replace_placeholders
 from .helpers import ChunkIteratorFileWrapper
 from .helpers import ChunkIteratorFileWrapper
 from .helpers import popen_with_error_handling
 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 ArgparsePatternAction, ArgparseExcludeFileAction, ArgparsePatternFileAction, parse_exclude_pattern
 from .patterns import PatternMatcher
 from .patterns import PatternMatcher
 from .item import Item
 from .item import Item
@@ -2256,6 +2257,18 @@ class Archiver:
                 setattr(args, dest, option_value)
                 setattr(args, dest, option_value)
 
 
     def build_parser(self):
     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):
         def process_epilog(epilog):
             epilog = textwrap.dedent(epilog).splitlines()
             epilog = textwrap.dedent(epilog).splitlines()
             try:
             try:
@@ -2264,7 +2277,10 @@ class Archiver:
                 mode = 'command-line'
                 mode = 'command-line'
             if mode in ('command-line', 'build_usage'):
             if mode in ('command-line', 'build_usage'):
                 epilog = [line for line in epilog if not line.startswith('.. man')]
                 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):
         def define_common_options(add_common_option):
             add_common_option('-h', '--help', action='help', help='show this help message and exit')
             add_common_option('-h', '--help', action='help', help='show this help message and exit')
@@ -3451,7 +3467,7 @@ class Archiver:
         used to have upgraded Borg 0.xx or Attic archives deduplicate with
         used to have upgraded Borg 0.xx or Attic archives deduplicate with
         Borg 1.x archives.
         Borg 1.x archives.
 
 
-        USE WITH CAUTION.
+        **USE WITH CAUTION.**
         Depending on the PATHs and patterns given, recreate can be used to permanently
         Depending on the PATHs and patterns given, recreate can be used to permanently
         delete files from archives.
         delete files from archives.
         When in doubt, use "--dry-run --verbose --list" to see how patterns/PATHS are
         When in doubt, use "--dry-run --verbose --list" to see how patterns/PATHS are
@@ -3761,7 +3777,7 @@ class Archiver:
 
 
         It creates input data below the given PATH and backups this data into the given REPO.
         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
         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).
         Make sure you have free space there, you'll need about 1GB each (+ overhead).
 
 

+ 162 - 0
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

+ 32 - 0
src/borg/testsuite/archiver.py

@@ -30,6 +30,7 @@ try:
 except ImportError:
 except ImportError:
     pass
     pass
 
 
+import borg
 from .. import xattr, helpers, platform
 from .. import xattr, helpers, platform
 from ..archive import Archive, ChunkBuffer, flags_noatime, flags_normal
 from ..archive import Archive, ChunkBuffer, flags_noatime, flags_normal
 from ..archiver import Archiver, parse_storage_quota
 from ..archiver import Archiver, parse_storage_quota
@@ -44,6 +45,7 @@ from ..helpers import Manifest
 from ..helpers import EXIT_SUCCESS, EXIT_WARNING, EXIT_ERROR
 from ..helpers import EXIT_SUCCESS, EXIT_WARNING, EXIT_ERROR
 from ..helpers import bin_to_hex
 from ..helpers import bin_to_hex
 from ..helpers import MAX_S
 from ..helpers import MAX_S
+from ..nanorst import RstToTextLazy
 from ..patterns import IECommand, PatternMatcher, parse_pattern
 from ..patterns import IECommand, PatternMatcher, parse_pattern
 from ..item import Item
 from ..item import Item
 from ..logger import setup_logging
 from ..logger import setup_logging
@@ -3335,3 +3337,33 @@ def test_parse_storage_quota():
     assert parse_storage_quota('50M') == 50 * 1000**2
     assert parse_storage_quota('50M') == 50 * 1000**2
     with pytest.raises(argparse.ArgumentTypeError):
     with pytest.raises(argparse.ArgumentTypeError):
         parse_storage_quota('5M')
         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

+ 37 - 0
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)