Commits

larry committed 8763ff7

Improved usage! Not done yet.

Comments (0)

Files changed (1)

 # A don't-repeat-yourself command-line parser.
 #
 # TODO:
+#  * rename value_usage
 #  * when called as a module (python3 -m dryparse <x>) it decorates
 #    the callables so that for all arguments it tries eval, and if
 #    that works it uses the result, otherwise it fails over to str
 
 import collections
 import inspect
+import itertools
 import shlex
 import sys
 import textwrap
 
 unspecified = Unspecified()
 
-def option_usage_formatter(name):
+def option_value_usage_formatter(name):
     return " " + name.upper()
 
+
+
+def column_wrapper(prefix, options, *,
+    min_column=12, max_column=40, right_margin=79):
+    """
+    Formats text in a pleasing format:
+    ##
+    prefix here         text here which wraps to
+                        multiple lines in a pleasing way.
+    ##
+
+    prefix can be either a string or an iterable of strings.
+
+    options should be an iterable of strings that have
+    already been broken up at word boundaries.
+    an empty string in the options iterable inserts
+    a blank line in the output.
+    """
+
+    lines = []
+    line_prefixes = []
+
+    if isinstance(prefix, str):
+        line = prefix
+    else:
+        prefixes = list(reversed(prefix))
+        line = ''
+        indent = ' ' * 8
+        add_space = False
+        while prefixes:
+            prefix = prefixes.pop()
+            test = line
+            if add_space:
+                test += ' '
+            else:
+                add_space = True
+            test += prefix
+            if len(test) > right_margin:
+                prefixes.append(prefix)
+                lines.append(line)
+                line = indent
+                first = True
+            line = test
+
+    if len(line) < max_column:
+        column = max(min_column, len(line))
+        line += ' ' * (column - len(line))
+        line_prefixes.append(line)
+    else:
+        column = max_column
+        lines.append(line)
+
+    empty_prefixes = itertools.repeat(' ' * column)
+    prefixes = itertools.chain(line_prefixes, empty_prefixes)
+    options = list(reversed(options))
+
+    while options:
+        # print("options", len(options), options)
+        test = line = next(prefixes)
+        is_first_option = True
+        while options:
+            o = options.pop()
+            if not o:
+                lines.append(line)
+                line = ''
+                break
+            test = line + ' ' + o
+            if len(test) > right_margin:
+                if is_first_option:
+                    line = test
+                else:
+                    options.append(o)
+                break
+            else:
+                line = test
+                is_first_option = False
+        lines.append(line)
+
+    return '\n'.join(lines).rstrip()
+
+
 @all
 class DryArgument:
     type = None
     doc = None
     multiple = False
     value = unspecified
-    usage = None
+    value_usage = None
 
     def __repr__(self):
         a = ['<DryArgument ', self.name]
         add = a.append
-        for name in sorted(self.options):
-            add(' -')
-            if len(name) > 1:
-                add('-')
-            add(name)
+        add(self.usage())
         if self.multiple:
             add(' *')
         add(' ')
         add('>')
         return ''.join(a)
 
+    def usage(self, *, first_line=True):
+        if not self.options:
+            return name
+        a = []
+        add = a.append
+
+        if first_line:
+            add('[')
+            separator = '|'
+        else:
+            separator = ', '
+
+        add_separator = False
+        for name in sorted(self.options):
+            if add_separator:
+                add(separator)
+            else:
+                add_separator = True
+            add('-')
+            if len(name) > 1:
+                add('-')
+            add(name)
+        if self.type != bool:
+            add('=')
+            add(self.value_usage)
+        if first_line:
+            add(']')
+        return ''.join(a)
+
     def set_value(self, value):
         value = self.type(value)
         if self.multiple:
                             self.multiple = True
                             continue
                         if option.startswith('='):
-                            self.usage = option[1:]
+                            self.value_usage = option[1:]
                         if option in {'-', '--'}:
                             continue
                         assert option.startswith('-'), "Illegal field in annotation list of options: '" + option + "'"
         if self.default is unspecified and self.options:
             self.default = self.type()
 
-        if is_option and not self.usage:
-            self.usage = option_usage_formatter(self.type.__name__)
-
+        if is_option and (self.type is not bool) and (not self.value_usage):
+            self.value_usage = option_value_usage_formatter(self.type.__name__)
 
 
 @all
                 len(name) == 1):
                 short_options.append(name)
             else:
-                if long_options:
-                    long_options.append(' ')
-                long_options.append('[')
-                dash = '-' + ('-' if len(name) == 1 else '')
-                if value.type is bool:
-                    argument = ''
-                else:
-                    argument = '=<' + value.type.__name__.upper() + '>'
-                help = dash + name + argument
-                long_options.append(help)
-                if value.multiple:
-                    second = ' [' + help[:-1] + '2> ... ]'
-                    long_options.append(second)
-                long_options.append(']')
+                long_options.append(value.usage(first_line=True))
+            seen.add(value)
 
         arguments = []
         unwind = 0
         for argument in self.arguments:
-            if arguments:
-                arguments.append(' ')
             if argument.default is not unspecified:
                 arguments.append('[')
                 unwind += 1
             arguments.append(argument.name)
 
         if self.star_args:
-            if arguments:
-                arguments.append(' ')
             arguments.append('[...]')
 
         arguments.append(']' * unwind)
         final = []
         if short_options:
             final.append('[-' + ''.join(short_options) + ']')
-        if final and long_options:
-            final.append(' ')
         final.extend(long_options)
-        if final and arguments:
-            final.append(' ')
         final.extend(arguments)
-        return ''.join(final)
 
-    def _usage(self, *, error=None):
+        return column_wrapper(["usage:", sys.argv[0], self.name], final)
+
+    def _usage(self, *, short=False, error=None):
+        short = bool(short or error)
         output = []
         def print(*a):
             s = " ".join([str(x) for x in a])
             output.append(s)
         lines = []
-        first_line = ''
+        summary_line = ''
         if self.doc:
             lines = [x.rstrip() for x in textwrap.dedent(self.doc.expandtabs()).split('\n')]
             while lines and not lines[0]:
                 del lines[0]
             if lines:
-                first_line = lines[0]
+                summary_line = lines[0]
                 del lines[0]
                 while lines and not lines[0]:
                     del lines[0]
                     lines.pop()
 
         if error:
-            print("Error:", error)
-            print()
-        print(self.name + ":", first_line)
+            print(error)
+
+        print(self._usage_first_line())
+
+        if error or short:
+            print('Try "' + sys.argv[0] + ' help ' + self.name + '" for more information.')
+            return '\n'.join(output)
+
+        print(summary_line)
+
         if lines:
             print()
             for line in lines:
         needed = len(list(filter(lambda x: x == unspecified, final_args)))
         if needed:
             specified = len(arguments)
-            return self.usage(error=" ".join(("Not enough arguments;", str(specified + needed), "required, but only", str(specified), "specified.")))
+            error = ["Not enough arguments."]
+            if specified:
+                error.extend((" " + str(specified + needed), "required, but only", str(specified), "specified."))
+            return self.usage(error=" ".join(error))
         assert unspecified not in final_args
 
         return_value = self.callable(*final_args, **final_kwargs)