瀏覽代碼

Merge pull request #2632 from enkore/docs/nanorst

nanorst for --help
enkore 8 年之前
父節點
當前提交
19c8adb109
共有 4 個文件被更改,包括 250 次插入3 次删除
  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 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 @@ class Archiver:
                 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 @@ class Archiver:
                 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 @@ class Archiver:
         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 @@ class Archiver:
 
         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).
 

+ 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:
     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 Manifest
 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

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