Skip to content

Commit

Permalink
subcommand docs; closes #44, #45
Browse files Browse the repository at this point in the history
  • Loading branch information
tomerfiliba committed Nov 10, 2012
1 parent 03702c3 commit 67373b3
Show file tree
Hide file tree
Showing 7 changed files with 226 additions and 82 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
---
* `Paramiko <http://pypi.python.org/pypi/paramiko/1.8.0>`_ integration
`#10 <https://github.com/tomerfiliba/plumbum/issues/10>`_
* CLI: now with built-in support for `sub-commands <http://plumbum.readthedocs.org/en/latest/cli.html#sub-commands>`_.
See also: `#43 <https://github.com/tomerfiliba/plumbum/issues/43>`_
* The "import hack" has moved to the package's ``__init__.py``, to make it importable directly
`#45 <https://github.com/tomerfiliba/plumbum/issues/45>`_

1.0.1
-----
Expand Down
131 changes: 78 additions & 53 deletions docs/cli.rst
Original file line number Diff line number Diff line change
Expand Up @@ -342,80 +342,105 @@ Sub-commands
------------
.. versionadded:: 1.1

The ``Application`` class can also have *sub-applications* (or *sub-commands*) - this basically
means you can nest applications one into the other, and they will be processed accordingly.
This is a common practice in many tools that tend to get large and spread out, like ``git``,
``hg`` and many other version control systems.

How Sub-commands Work
^^^^^^^^^^^^^^^^^^^^^
Each sub-command is responsible of **every** argument that follows it, which makes the structure
recursive. Take, for instance, ``maincommand -x --foo=zzz bar subcommand -w --bar=123 file1 file2 file3``;
the *root application* is ``maincommand``, which accepts two switches (``-x`` and ``--foo``) and
one positional argument (``bar``). Following it, comes ``subcommand``, which accepts everything
that comes after it (two switches and three positional arguments).

Subcommands are processed in order: first, ``maincommand`` is executed with its set of parameters
(invoking its ``main()`` method), and if everything goes well, ``subcommand`` will then be invoked.
Theoretically, there's no limit on the number of nested sub-commands that you can define or use;
in parctice, however, there's usually only a "main command" and a single sub-command.


.. note::
Each sub-command is an ``Application`` on its own and can be used independently.

::
A common practice of CLI applications, as they span out and get larger, is to split their
logic into multiple, pluggable *sub-applications* (or *sub-commands*). A classic example is version
control systems, such as `git <http://git-scm.com/>`_, where ``git`` is the *root* command,
under which sub-commands such as ``commit`` or ``push`` are nested. Git even supports ``alias``-ing,
which creates allows users to create custom sub-commands. Plumbum makes writing such applications
really easy.

Before we get to the code, it is important to stress out two things:

* Under Plumbum, each sub-command is a full-fledged ``cli.Application`` on its own; if you wish,
you can execute it separately, detached from its so-called root application. When an application
is run independently, its ``parent`` attribute is ``None``; when it is run as a sub-command,
its ``parent`` attribute points to its parent application. Likewise, when an parent application
is executed with a sub-command, its ``nested_command`` is set to the nested application; otherwise
it's ``None``.

* Each sub-command is responsible of **all** arguments that follow it (up to the next sub-command).
This allows applications to process their own switches and positional arguments before the nested
application is invoked. Take, for instance, ``git --foo=bar spam push origin --tags``: the root
application, ``git``, is in charge of the switch ``--foo`` and the positional argument ``spam``,
and the nested application ``push`` is in charge of the arguments that follow it. In theory,
you can nest several sub-applications one into the other; in practice, only a single level
is normally used.

Here is an example of a mock version control system, called ``geet``. We're going to have a root
application ``Geet``, which has two sub-commands - ``GeetCommit`` and ``GeetPush``: these are
attached to the root application using the ``subcommand`` decorator ::
class Geet(cli.Application):
def main(self, *args):
if args:
print "Unknown command %r" % (args[0],)
return 1 # error exit code
if not self.nested_command: # will be ``None`` if no sub-command follows
print "No command given"
return 1 # error exit code

@Geet.subcommand("commit") # attach 'geet commit'
class GeetCommit(cli.Application):
auto_add = cli.Flag("-a")
message = cli.SwitchAttr("-m", str)

def main(self):
print "doing the commit..."

class GeetPull(cli.Application):
@Geet.subcommand("commit") # attach 'geet push'
class GeetPush(cli.Application):
def main(self, remote, branch = None)
print "doing the pull..."
class Geet(cli.Application):
commit = cli.Subcommand(GeetPull, "pull")
pull = cli.Subcommand(GeetPull, "pull")
def main(self, *args):
if args:
print "Unknown command %r" % (args[0],)
else:
print "No command given"
print "doing the push..."

if __name__ == "__main__":
Geet.run()

You can also add sub-commands later on (not necessarily during the definitions of the parent class)::
Naturally, since ``GeetCommit`` is a ``cli.Application`` on its own right, you may invoke
``GeetCommit.run()`` directly -- if that makes sense in the context of your application.

class Geet(cli.Application):
pass #...
class GeetCommit(cli.Application):
pass #...
# add commit as a subcommand
Geet.commit = cli.Subcommand(GeetCommit, "commit")
.. note::
You can also attach sub-commands "imperatively", using ``subcommand`` as a method instead
of a decorator: ``Geet.subcommand("push", GeetPush)``.

Or using the ``subcommand`` decorator::
Here's an example of running this application::

class Geet(cli.Application):
pass #...
$ python geet.py --help
geet v1.7.2
The l33t version control
@Geet.subcommand("commit")
class GeetCommit(cli.Application):
pass #...
Usage: geet.py [SWITCHES] [SUBCOMMAND [SWITCHES]] args...
Meta-switches:
-h, --help Prints this help message and quits
-v, --version Prints the program's version and quits
Subcommands:
commit creates a new commit in the current branch; see
'geet commit --help' for more info
push pushes the current local branch to the remote
one; see 'geet push --help' for more info
$ python geet.py commit --help
geet commit v1.7.2
creates a new commit in the current branch
Usage: geet commit [SWITCHES]
Meta-switches:
-h, --help Prints this help message and quits
-v, --version Prints the program's version and quits
Switches:
-a automatically add changed files
-m VALUE:str sets the commit message; required
which does exactly the same thing as above.
$ python geet.py commit -m "foo"
committing...


See Also
--------
* `filecopy.py <https://github.com/tomerfiliba/plumbum/blob/master/examples/filecopy.py>`_ example
* `geet.py <https://github.com/tomerfiliba/plumbum/blob/master/examples/geet.py>`_ - an runnable
example of using sub-commands
* `RPyC <http://rpyc.sf.net>`_ has changed it bash-based build script to Plumbum CLI.
Notice `how short and readable <https://github.com/tomerfiliba/rpyc/blob/c457a28d689df7605838334a437c6b35f9a94618/build.py>`_
it is.
Expand Down
78 changes: 78 additions & 0 deletions examples/geet.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""
Examples::
$ python geet.py
no command given
$ python geet.py leet
unknown command 'leet'
$ python geet.py --help
geet v1.7.2
The l33t version control
Usage: geet.py [SWITCHES] [SUBCOMMAND [SWITCHES]] args...
Meta-switches:
-h, --help Prints this help message and quits
-v, --version Prints the program's version and quits
Subcommands:
commit creates a new commit in the current branch; see
'geet commit --help' for more info
push pushes the current local branch to the remote
one; see 'geet push --help' for more info
$ python geet.py commit --help
geet commit v1.7.2
creates a new commit in the current branch
Usage: geet commit [SWITCHES]
Meta-switches:
-h, --help Prints this help message and quits
-v, --version Prints the program's version and quits
Switches:
-a automatically add changed files
-m VALUE:str sets the commit message; required
$ python geet.py commit -m "foo"
committing...
"""
from plumbum import cli


class Geet(cli.Application):
"""The l33t version control"""
PROGNAME = "geet"
VERSION = "1.7.2"

def main(self, *args):
if args:
print("unknown command %r" % (args[0]))
return 1
if not self.nested_command:
print("no command given")
return 1

@Geet.subcommand("commit")
class GeetCommit(cli.Application):
"""creates a new commit in the current branch"""

auto_add = cli.Flag("-a", help = "automatically add changed files")
message = cli.SwitchAttr("-m", str, mandatory = True, help = "sets the commit message")

def main(self):
print("committing...")

@Geet.subcommand("push")
class GeetPush(cli.Application):
"""pushes the current local branch to the remote one"""

tags = cli.Flag("--tags", help = "whether to push tags (default is False)")

def main(self, remote, branch = "master"):
print("pushing to %s/%s..." % (remote, branch))


if __name__ == "__main__":
Geet.run()
2 changes: 1 addition & 1 deletion plumbum/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ class LocalModule(ModuleType):
"""The module-hack that allows us to use ``from plumbum.cmd import some_program``"""
__all__ = () # to make help() happy
__package__ = __name__
__getitem__ = __getattr__ = local.__getitem__
__getattr__ = local.__getitem__

cmd = LocalModule(__name__ + ".cmd", LocalModule.__doc__)
sys.modules[cmd.__name__] = cmd
Expand Down
57 changes: 41 additions & 16 deletions plumbum/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ class WrongArgumentType(SwitchError):
"""Raised when a switch expected an argument of some type, but an argument of a wrong
type has been given"""
pass
class SubcommandError(SwitchError):
"""Raised when there's something wrong with subcommands"""
pass
class ShowHelp(SwitchError):
pass
class ShowVersion(SwitchError):
Expand Down Expand Up @@ -374,30 +377,42 @@ def main(self, src, dst):
* ``VERSION`` - the program's version (defaults to ``1.0``)
* ``DESCRIPTION`` - a short description of your program (shown in help)
* ``DESCRIPTION`` - a short description of your program (shown in help). If not set,
the class' ``__doc__`` will be used.
* ``USAGE`` - the usage line (shown in help)
A note on sub-commands: when an application is the root, its ``parent`` attribute is set to
``None``. When it is used as a nested-command, ``parent`` will point to be its direct ancestor.
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
VERSION = None
USAGE = None
parent = None
nested_command = None

def __init__(self, executable):
if self.PROGNAME is None:
self.PROGNAME = os.path.basename(executable)
if self.DESCRIPTION is None:
self.DESCRIPTION = inspect.getdoc(self)

self.executable = executable
self._switches_by_name = {}
self._switches_by_func = {}
self._subcommands = {}
self._curr_subcommand = None

for cls in reversed(type(self).mro()):
for obj in cls.__dict__.values():
if isinstance(obj, Subcommand):
if obj.name.startswith("-"):
raise ValueError("Subcommand names cannot start with '-'")
raise SubcommandError("Subcommand names cannot start with '-'")
# it's okay for child classes to override subcommands set by their parents
self._subcommands[obj.name] = obj.subapplication
continue

Expand All @@ -413,7 +428,7 @@ def __init__(self, executable):
@classmethod
def subcommand(cls, name, subapp = None):
"""Registers the given sub-application as a sub-command of this one. This method can be
used both as a decorator and as a normal classmethod::
used both as a decorator and as a normal ``classmethod``::
@MyApp.subcommand("foo")
class FooApp(cli.Application):
Expand All @@ -426,7 +441,7 @@ class FooApp(cli.Application):
.. versionadded:: 1.1
"""
def wrapper(subapp):
setattr(cls, "_subcommand_%s" % (subapp.__name__), subapp)
setattr(cls, "_subcommand_%s" % (subapp.__name__), Subcommand(name, subapp))
return subapp
if subapp:
return wrapper(subapp)
Expand All @@ -446,7 +461,7 @@ def _parse_args(self, argv):
break

if a in self._subcommands:
self._curr_subcommand = (self._subcommands[a], [self.executable + " " + a] + argv)
self.nested_command = (self._subcommands[a], [self.PROGNAME + " " + a] + argv)
break

elif a.startswith("--") and len(a) >= 3:
Expand Down Expand Up @@ -596,16 +611,16 @@ def run(cls, argv = sys.argv, exit = True): #@ReservedAssignment
inst.version()
except SwitchError:
ex = sys.exc_info()[1] # compatibility with python 2.5
print(ex)
print("")
print("Error: %s" % (ex,))
print("~" * 70)
inst.help()
retcode = 2
else:
for f, a in ordered:
f(inst, *a)
retcode = inst.main(*tailargs)
if not retcode and inst._curr_subcommand:
subapp, argv = inst._curr_subcommand
if not retcode and inst.nested_command:
subapp, argv = inst.nested_command
subapp.parent = inst
inst, retcode = subapp.run(argv, exit = False)

Expand Down Expand Up @@ -687,16 +702,26 @@ def help(self): #@ReservedAssignment
if self._subcommands:
print("Subcommands:")
for name, subapp in sorted(self._subcommands.items()):
desc = subapp.DESCRIPTION + "; " if subapp.DESCRIPTION else ""
print (" %-25s %suse '%s %s --help' for details" % (name, desc, self.PROGNAME, name))
doc = subapp.DESCRIPTION if subapp.DESCRIPTION else inspect.getdoc(subapp)
help = doc + "; " if doc else "" #@ReservedAssignment
help += "see '%s %s --help' for more info" % (self.PROGNAME, name)
wrapper = TextWrapper(width = int(local.env.get("COLUMNS", 80)),
initial_indent = " " * min(max(31, len(name)), 50), subsequent_indent = " " * 31)
help = wrapper.fill(" ".join(l.strip() for l in help.splitlines())) #@ReservedAssignment
print(" %-25s %s" % (name, help.strip()))

@switch(["-v", "--version"], overridable = True, group = "Meta-switches")
def version(self):
"""Prints the program's version and quits"""
if self.VERSION:
print ("%s v%s" % (self.PROGNAME, self.VERSION))
else:
print (self.PROGNAME)
ver = None
curr = self
while curr:
ver = getattr(curr, "VERSION", None)
if ver:
print ("%s v%s" % (self.PROGNAME, ver))
return
curr = curr.parent
print ("%s (no version set)" % (self.PROGNAME,))



Loading

0 comments on commit 67373b3

Please sign in to comment.