Skip to content
Browse files

subcommand docs; closes #44, #45

  • Loading branch information...
1 parent 03702c3 commit 67373b30c5562325b88fff623a7876886b450d76 @tomerfiliba committed Nov 10, 2012
Showing with 226 additions and 82 deletions.
  1. +4 −0 CHANGELOG.rst
  2. +78 −53 docs/cli.rst
  3. +78 −0 examples/geet.py
  4. +1 −1 plumbum/__init__.py
  5. +41 −16 plumbum/cli.py
  6. +8 −10 tests/test_cli.py
  7. +16 −2 tests/test_remote.py
View
4 CHANGELOG.rst
@@ -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
-----
View
131 docs/cli.rst
@@ -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.
View
78 examples/geet.py
@@ -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()
View
2 plumbum/__init__.py
@@ -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
View
57 plumbum/cli.py
@@ -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):
@@ -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
@@ -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):
@@ -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)
@@ -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:
@@ -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)
@@ -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,))
View
18 tests/test_cli.py
@@ -24,10 +24,18 @@ def main(self, *args):
self.eggs = old
self.tailargs = args
+class Geet(cli.Application):
+ debug = cli.Flag("--debug")
+
+ def main(self):
+ print ("hi this is geet main")
+
+@Geet.subcommand("add")
class GeetAdd(cli.Application):
def main(self, *files):
return "adding", files
+@Geet.subcommand("commit")
class GeetCommit(cli.Application):
message = cli.Flag("-m", str)
@@ -37,16 +45,6 @@ def main(self):
else:
return "committing"
-class Geet(cli.Application):
- debug = cli.Flag("--debug")
-
- def main(self):
- print ("hi this is geet main")
-
- # subcommands
- add = cli.Subcommand("add", GeetAdd)
- commit = cli.Subcommand("commit", GeetCommit)
-
class CLITest(unittest.TestCase):
def test_meta_switches(self):
View
18 tests/test_remote.py
@@ -8,6 +8,21 @@
#logging.basicConfig(level = logging.DEBUG)
+if not hasattr(unittest, "skipIf"):
+ import logging
+ import functools
+ def skipIf(cond, msg = None):
+ def deco(func):
+ if cond:
+ return func
+ else:
+ @functools.wraps(func)
+ def wrapper(*args, **kwargs):
+ logging.warn("skipping test")
+ return wrapper
+ return deco
+ unittest.skipIf = skipIf
+
class RemotePathTest(unittest.TestCase):
def test_basename(self):
name = RemotePath(SshMachine("localhost"), "/some/long/path/to/file.txt").basename
@@ -19,9 +34,8 @@ def test_dirname(self):
self.assertTrue(isinstance(name, RemotePath))
self.assertEqual("/some/long/path/to", str(name))
+ @unittest.skipIf(not hasattr(os, "chown"), "os.chown not supported")
def test_chown(self):
- if not hasattr(os, "chown"):
- self.skip("os.chown not supported")
with SshMachine("localhost") as rem:
with rem.tempdir() as dir:
p = dir / "foo.txt"

0 comments on commit 67373b3

Please sign in to comment.
Something went wrong with that request. Please try again.