Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce DESCRIPTION_MORE attribute #378

Merged
merged 2 commits into from
Apr 10, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 115 additions & 4 deletions plumbum/cli/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,7 @@ def __repr__(self):
#===================================================================================================

class Application(object):
"""
The base class for CLI applications; your "entry point" class should derive from it,
"""The base class for CLI applications; your "entry point" class should derive from it,
define the relevant switch functions and attributes, and the ``main()`` function.
The class defines two overridable "meta switches" for version (``-v``, ``--version``)
help (``-h``, ``--help``), and help-all (``--help-all``).
Expand Down Expand Up @@ -96,6 +95,12 @@ def main(self, src, dst):
* ``DESCRIPTION`` - a short description of your program (shown in help). If not set,
the class' ``__doc__`` will be used. Can be in color.

* ``DESCRIPTION_MORE`` - a detailed description of your program (shown in help). The text will be printed
by paragraphs (specified by empty lines between them). The indentation of each paragraph will be the
indentation of its first line. List items are identified by their first non-whitespace character being
one of '-', '*', and '/'; so that they are not combined with preceding paragraphs. Bullet '/' is
"invisible", meaning that the bullet itself will not be printed to the output.

* ``USAGE`` - the usage line (shown in help)

* ``COLOR_USAGE`` - The color of the usage line
Expand All @@ -111,10 +116,12 @@ def main(self, src, dst):
Likewise, when an application is invoked with a sub-command, its ``nested_command`` attribute
will hold the chosen sub-application and its command-line arguments (a tuple); otherwise, it
will be set to ``None``

"""

PROGNAME = None
DESCRIPTION = None
DESCRIPTION_MORE = None
VERSION = None
USAGE = None
COLOR_USAGE = None
Expand All @@ -128,7 +135,7 @@ def main(self, src, dst):

def __new__(cls, executable=None):
"""Allows running the class directly as a shortcut for main.
This is neccessary for some setup scripts that want a single function,
This is necessary for some setup scripts that want a single function,
instead of an expression with a dot in it."""


Expand Down Expand Up @@ -618,6 +625,111 @@ def help(self): # @ReservedAssignment
if self.DESCRIPTION:
print(self.DESCRIPTION.strip() + '\n')

def split_indentation(s):
"""Identifies the initial indentation (all spaces) of the string and returns the indentation as well
as the remainder of the line.
"""
i = 0
while i < len(s) and s[i] == ' ': i += 1
return s[:i], s[i:]

def paragraphs(text):
"""Yields each paragraph of text along with its initial and subsequent indentations to be used by
textwrap.TextWrapper.

Identifies list items from their first non-space character being one of bullets '-', '*', and '/'.
However, bullet '/' is invisible and is removed from the list item.

:param text: The text to separate into paragraphs
"""

paragraph = None
initial_indent = ""
subsequent_indent = ""

def current():
"""Yields the current result if present.
"""
if paragraph:
yield paragraph, initial_indent, subsequent_indent

for part in text.lstrip("\n").split("\n"):
indent, line = split_indentation(part)

if len(line) == 0:
# Starting a new paragraph
for item in current():
yield item
yield "", "", ""

paragraph = None
initial_indent = ""
subsequent_indent = ""
else:
# Adding to current paragraph
def is_list_item(line):
"""Returns true if the first element of 'line' is a bullet character.
"""
bullets = [ '-', '*', '/' ]
return line[0] in bullets

def has_invisible_bullet(line):
"""Returns true if the first element of 'line' is the invisible bullet ('/').
"""
return line[0] == '/'

if is_list_item(line):
# Done with current paragraph
for item in current():
yield item

if has_invisible_bullet(line):
line = line[1:]

paragraph = line
initial_indent = indent

# Calculate extra indentation for subsequent lines of this list item
i = 1
while i < len(line) and line[i] == ' ': i += 1
subsequent_indent = indent + " " * i
else:
if not paragraph:
# Start a new paragraph
paragraph = line
initial_indent = indent
subsequent_indent = indent
else:
# Add to current paragraph
paragraph = paragraph + ' ' + line

for item in current():
yield item


def wrapped_paragraphs(text, width):
"""Yields each line of each paragraph of text after wrapping them on 'width' number of columns.

:param text: The text to yield wrapped lines of
:param width: The width of the wrapped output
"""
if not text:
return

width = max(width, 1)

for paragraph, initial_indent, subsequent_indent in paragraphs(text):
wrapper = TextWrapper(width, initial_indent=initial_indent, subsequent_indent=subsequent_indent)
w = wrapper.wrap(paragraph)
for line in w:
yield line
if len(w) == 0:
yield ""

cols, _ = get_terminal_size()
for line in wrapped_paragraphs(self.DESCRIPTION_MORE, cols):
print(line)

m = six.getfullargspec(self.main)
tailargs = m.args[1:] # skip self
if m.defaults:
Expand Down Expand Up @@ -666,7 +778,6 @@ def switchs(by_groups, show_groups):
print("")

sw_width = max(len(prefix) for si, prefix, color in switchs(by_groups, False)) + 4
cols, _ = get_terminal_size()
description_indent = " {0}{1}{2}"
wrapper = TextWrapper(width = max(cols - min(sw_width, 60), 50) - 6)
indentation = "\n" + " " * (cols - wrapper.width)
Expand Down
53 changes: 53 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import sys

from plumbum import cli, local
from plumbum.cli.terminal import get_terminal_size

class SimpleApp(cli.Application):
@cli.switch(["a"])
Expand Down Expand Up @@ -60,6 +61,36 @@ def cleanup(self, retcode):
print("geet commit cleaning up with rc = %s" % (retcode,))

class Sample(cli.Application):
DESCRIPTION = "A sample cli application"
DESCRIPTION_MORE = '''
ABC This is just a sample help text typed with a Dvorak keyboard.
Although this paragraph is not left or right justified
in source, we expect it to appear
formatted nicely on the output, maintaining the indentation of the first line.

DEF this one has a different indentation.

Let's test that list items are not combined as paragraphs.

- Item 1
GHI more text for item 1, which may be very very very very very very long and even more long and long and long to
prove that we can actually wrap list items as well.
- Item 2 and this is
some text for item 2
- Item 3

List items with invisible bullets should be printed without the bullet.

/XYZ Invisible 1
/Invisible 2

* Star 1
* Star 2

Last paragraph can fill more than one line on the output as well. So many features is bound to cause lots of bugs.
Oh well...
'''

foo = cli.SwitchAttr("--foo")

Sample.unbind_switches("--version")
Expand Down Expand Up @@ -174,6 +205,28 @@ def test_unbind(self, capsys):
assert "--foo" in stdout
assert "--version" not in stdout

def test_description(self, capsys):
_, rc = Sample.run(["sample", "--help"], exit = False)
assert rc == 0
stdout, stderr = capsys.readouterr()
cols, _ = get_terminal_size()

if cols < 9:
# Terminal is too narrow to test
pass
else:
# Paragraph indentation should be preserved
assert " ABC" in stdout
assert " DEF" in stdout
assert " - Item" in stdout
# List items should not be combined into paragraphs
assert " * Star 2"
# Lines of the same list item should be combined. (The right-hand expression of the 'or' operator
# below is for when the terminal is too narrow, causing "GHI" to be wrapped to the next line.)
assert " GHI" not in stdout or " GHI" in stdout
# List item with invisible bullet should be indented without the bullet
assert " XYZ" in stdout

def test_default_main(self, capsys):
_, rc = Sample.run(["sample"], exit = False)
assert rc == 1
Expand Down