← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~allenap/maas/introducing-commandant into lp:maas

 

Gavin Panella has proposed merging lp:~allenap/maas/introducing-commandant into lp:maas.

Requested reviews:
  MAAS Maintainers (maas-maintainers)

For more details, see:
https://code.launchpad.net/~allenap/maas/introducing-commandant/+merge/123992
-- 
https://code.launchpad.net/~allenap/maas/introducing-commandant/+merge/123992
Your team MAAS Maintainers is requested to review the proposed merge of lp:~allenap/maas/introducing-commandant into lp:maas.
=== modified file 'Makefile'
--- Makefile	2012-09-11 22:54:20 +0000
+++ Makefile	2012-09-12 15:31:22 +0000
@@ -19,7 +19,9 @@
 build: \
     bin/buildout \
     bin/database \
-    bin/maas bin/test.maas bin/test.maastesting \
+    bin/maas bin/test.maas \
+    bin/maascli bin/test.maascli \
+    bin/test.maastesting \
     bin/twistd.pserv bin/test.pserv \
     bin/twistd.txlongpoll \
     bin/py bin/ipy \
@@ -62,6 +64,14 @@
 	$(buildout) install maas-test
 	@touch --no-create $@
 
+bin/maascli: bin/buildout buildout.cfg versions.cfg setup.py
+	$(buildout) install maascli
+	@touch --no-create $@
+
+bin/test.maascli: bin/buildout buildout.cfg versions.cfg setup.py
+	$(buildout) install maascli-test
+	@touch --no-create $@
+
 bin/test.maastesting: bin/buildout buildout.cfg versions.cfg setup.py
 	$(buildout) install maastesting-test
 	@touch --no-create $@

=== modified file 'buildout.cfg'
--- buildout.cfg	2012-08-21 15:59:40 +0000
+++ buildout.cfg	2012-09-12 15:31:22 +0000
@@ -3,6 +3,8 @@
   flake8
   maas
   maas-test
+  maascli
+  maascli-test
   maastesting-test
   pserv
   pserv-test
@@ -106,6 +108,29 @@
 extra-paths =
   ${maas:extra-paths}
 
+[maascli]
+recipe = zc.recipe.egg
+eggs =
+entry-points =
+  maascli=maascli:main
+extra-paths =
+  ${common:extra-paths}
+scripts =
+  maascli
+
+[maascli-test]
+recipe = zc.recipe.egg
+eggs =
+  ${maascli:eggs}
+  ${common:test-eggs}
+entry-points =
+  test.maascli=nose.core:TestProgram
+initialization =
+  sys.argv[1:1] = ["--where=src/maascli"]
+extra-paths = ${maascli:extra-paths}
+scripts =
+  test.maascli
+
 [maastesting-test]
 recipe = zc.recipe.egg
 eggs =

=== added directory 'src/maascli'
=== added file 'src/maascli/__init__.py'
--- src/maascli/__init__.py	1970-01-01 00:00:00 +0000
+++ src/maascli/__init__.py	2012-09-12 15:31:22 +0000
@@ -0,0 +1,39 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""The MAAS command-line interface."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+__all__ = []
+
+from glob import iglob
+from os.path import (
+    dirname,
+    join,
+    )
+import sys
+
+# Add `lib` in the current directory into sys.path.
+sys.path[:0] = iglob(join(dirname(__file__), "lib"))
+
+from commandant import builtins
+from commandant.controller import CommandController
+
+
+def main(argv=sys.argv):
+    controller = CommandController(
+        program_name=__name__, program_version="2.0",
+        program_summary="Control MAAS using its API from the command-line.",
+        program_url="http://maas.ubuntu.com/";)
+    # At this point controller.load_path(...) can be used to load commands
+    # from a pre-agreed location on the filesystem, so that the command set
+    # will grow and shrink with the installed packages.
+    controller.load_module(builtins)
+    controller.install_bzrlib_hooks()
+    controller.run(argv[1:])

=== added file 'src/maascli/__main__.py'
--- src/maascli/__main__.py	1970-01-01 00:00:00 +0000
+++ src/maascli/__main__.py	2012-09-12 15:31:22 +0000
@@ -0,0 +1,18 @@
+#!/usr/bin/env python2.7
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Command-line interface for MAAS."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+
+from . import main
+
+
+main()

=== added directory 'src/maascli/lib'
=== added file 'src/maascli/lib/Makefile'
--- src/maascli/lib/Makefile	1970-01-01 00:00:00 +0000
+++ src/maascli/lib/Makefile	2012-09-12 15:31:22 +0000
@@ -0,0 +1,8 @@
+commandant:
+	bzr export $@ --revision 45 lp:commandant/commandant
+	$(RM) -rv $@/test*
+
+clean:
+	$(RM) -r commandant
+
+.PHONY: clean

=== added directory 'src/maascli/lib/commandant'
=== added file 'src/maascli/lib/commandant/__init__.py'
--- src/maascli/lib/commandant/__init__.py	1970-01-01 00:00:00 +0000
+++ src/maascli/lib/commandant/__init__.py	2012-09-12 15:31:22 +0000
@@ -0,0 +1,16 @@
+"""Commandant is a framework for building command-oriented tools.
+
+A command-oriented command-line program takes a command name as its first
+argument, which it finds and runs, passing along any subsequent
+arguments.  Bazaar is command-oriented, for instance.  Commandant is inspired
+by Bazaar's user interface and uses bzrlib in its internal implementation.
+
+Commandant is a command discovery and execution tool.  Executables, such as
+shell scripts, can be used as commands.  Commands can also be implemented in
+Python.  These commands, along with help topics, are bundled together in a
+directory.  Commandant, when pointed at the directory containing the commands,
+provides a Bazaar-like user interface to discover and run them.
+"""
+
+__version__ = "0.4.0"
+__version_info__ = (0, 4, 0)

=== added file 'src/maascli/lib/commandant/builtins.py'
--- src/maascli/lib/commandant/builtins.py	1970-01-01 00:00:00 +0000
+++ src/maascli/lib/commandant/builtins.py	2012-09-12 15:31:22 +0000
@@ -0,0 +1,167 @@
+# Commandant is a framework for building command-oriented tools.
+# Copyright (C) 2009-2010 Jamshed Kakar.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""Builtin commands."""
+
+from cStringIO import StringIO
+import os
+from platform import platform
+
+import bzrlib
+from bzrlib.commands import Command
+from bzrlib.option import Option
+
+import commandant
+from commandant.help_topics import HelpTopic, CommandHelpTopic
+from commandant.formatting import print_columns
+
+
+class cmd_version(Command):
+    """Show version of commandant."""
+
+    takes_options = [Option("short", help="Print just the version number.")]
+
+    def run(self, short=None):
+        """Print the version."""
+        if short:
+            print >>self.outf, "%s" % (self.controller.program_version,)
+        else:
+            print >>self.outf, "%s %s" % (self.controller.program_name,
+                                          self.controller.program_version)
+            python_path = os.path.dirname(os.__file__)
+            bzrlib_path = bzrlib.__path__[0]
+            commandant_path = os.path.abspath(commandant.__path__[0])
+            print >>self.outf, "  Platform:", platform(aliased=1)
+            print >>self.outf, "  Python standard library:", python_path
+            print >>self.outf, "  bzrlib:", bzrlib_path
+            print >>self.outf, "  commandant:", commandant_path
+
+
+class cmd_help(Command):
+    """Show help about a command or topic."""
+
+    aliases = ["?", "--help", "-?", "-h"]
+    takes_args = ["topic?"]
+    _see_also = ["topics"]
+
+    def run(self, topic=None):
+        """
+        Show help for the C{bzrlib.commands.Command} or L{HelpTopic} matching
+        C{name}.
+
+        @param topic: Optionally, the name of the topic to show.  Default is
+            C{basic}.
+        """
+        if topic is None:
+            topic = "basic"
+        text = None
+        command = self.controller.get_command(topic)
+        help_topic = self.controller.get_help_topic(topic)
+        if help_topic:
+            text = help_topic.get_text().strip()
+        elif command:
+            help_topic = CommandHelpTopic(command)
+            help_topic.controller = self.controller
+            text = help_topic.get_text()
+        if text:
+            print >>self.outf, text
+        elif not (command or help_topic):
+            print >>self.outf, "%s is an unknown command or topic." % (topic,)
+
+
+class topic_basic(HelpTopic):
+    """Show basic help about this program."""
+
+    def get_summary(self):
+        """Get a short topic summary for use in a topic listing."""
+        return "Basic commands."
+
+    def get_text(self):
+        """Get topic content."""
+        return """\
+%(program-name)s -- %(program-summary)s
+%(program-url)s
+
+Basic commands:
+  %(program-name)s help commands  List all commands
+  %(program-name)s help topics    List all help topics
+""" % {"program-name": self.controller.program_name,
+       "program-summary": self.controller.program_summary,
+       "program-url": self.controller.program_url}
+
+
+class topic_commands(HelpTopic):
+    """List available commands with a short summary describing each one."""
+
+    def get_summary(self):
+        """Get a short topic summary for use in a topic listing."""
+        return "Basic help for all commands."
+
+    def get_text(self):
+        """Get topic content."""
+        stream = StringIO()
+        result = []
+        for name in self.controller.get_command_names():
+            command = self.controller.get_command(name)
+            help_topic = self.controller.get_help_topic(name)
+            if not help_topic and command:
+                help_topic = CommandHelpTopic(command)
+                if self.controller is not None:
+                    help_topic.controller = self.controller
+            summary = ""
+            if help_topic:
+                summary = help_topic.get_summary()
+            if self.include_command(command):
+                result.append((name, summary))
+        result.sort(key=lambda item: item[0])
+        print_columns(stream, result)
+        return stream.getvalue()
+
+    def include_command(self, command):
+        """Return C{True} if C{command} is visible."""
+        return not command.hidden
+
+
+class topic_hidden_commands(topic_commands):
+    """List hidden commands with a short summary describing each one."""
+
+    def get_summary(self):
+        """Get a short topic summary for use in a topic listing."""
+        return "Basic help for hidden commands."
+
+    def include_command(self, command):
+        """Return C{True} if C{command} is hidden."""
+        return command.hidden
+
+
+class topic_topics(HelpTopic):
+    """List available help topics with a short summary describing each one."""
+
+    def get_summary(self):
+        """Get a short topic summary for use in a topic listing."""
+        return "Topics list."
+
+    def get_text(self):
+        """Get topic content."""
+        stream = StringIO()
+        command_names = self.controller.get_command_names()
+        help_topic_names = self.controller.get_help_topic_names()
+        result = [(name, self.controller.get_help_topic(name).get_summary())
+                  for name in help_topic_names if name not in command_names]
+        result.sort(key=lambda item: item[0])
+        print_columns(stream, result)
+        return stream.getvalue()

=== added file 'src/maascli/lib/commandant/commands.py'
--- src/maascli/lib/commandant/commands.py	1970-01-01 00:00:00 +0000
+++ src/maascli/lib/commandant/commands.py	2012-09-12 15:31:22 +0000
@@ -0,0 +1,121 @@
+# Commandant is a framework for building command-oriented tools.
+# Copyright (C) 2009-2010 Jamshed Kakar.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""Infrastructure extends C{bzrlib.commands.Command} to support executables."""
+
+import os
+import sys
+
+from twisted.internet.defer import Deferred
+
+from bzrlib.commands import Command
+
+
+class ExecutableCommand(Command):
+    """Specialized command runs an executable program."""
+
+    path = None
+
+    def run_argv_aliases(self, argv, alias_argv=None):
+        """
+        Disable proper handling of argv and aliases so that arguments can be
+        passed directly to the executable.
+        """
+        self.run(argv)
+
+    def run(self, argv):
+        """
+        Run the executable, passing whatever arguments were passed to the
+        command.
+        """
+        if argv:
+            os.system("%s %s" % (self.path, " ".join(argv)))
+        else:
+            os.system(self.path)
+
+
+class TwistedCommand(Command):
+    """A command that runs with a Twisted reactor."""
+
+    _return_value = None
+    _failure_value = None
+
+    def get_reactor(self):
+        """Get the Twisted reactor to use when running this command."""
+        from twisted.internet import reactor
+        return reactor
+
+    def run_argv_aliases(self, argv, alias_argv=None):
+        """Start a reactor for the command to run in."""
+        self._start_reactor(argv, alias_argv)
+        if self._failure_value is not None:
+            type, value, traceback = self._failure_value
+            raise type, value, traceback
+        return self._return_value
+
+    def _start_reactor(self, argv, alias_argv):
+        """Start a reactor and queue a call to run the command."""
+        reactor = self.get_reactor()
+        reactor.callLater(0, self._run_command, argv, alias_argv)
+        reactor.run()
+        return self._return_value
+
+    def _run_command(self, argv, alias_argv):
+        """Run the command and stop the reactor when it completes."""
+        subclass = super(TwistedCommand, self)
+        try:
+            result = subclass.run_argv_aliases(argv, alias_argv)
+        except:
+            self._failure_value = sys.exc_info()
+            return self._stop_reactor(self._failure_value)
+        else:
+            return self._stop_reactor(result)
+
+    def _capture_return_value(self, result):
+        """Store the return value after running the command."""
+        self._return_value = result
+
+    def _capture_failure_value(self, failure):
+        """Store the failure value after running the command."""
+        self._failure_value = (failure.type, failure.value, failure.tb)
+
+    def _stop_reactor(self, result):
+        """Stop the reactor."""
+        reactor = self.get_reactor()
+        if isinstance(result, Deferred):
+            result.addErrback(self._capture_failure_value)
+            result.addCallback(self._capture_return_value)
+            # Use callLater to stop the reactor so that application code can
+            # add callbacks to the Deferred after its been returned by the run
+            # method.
+            result.addCallback(
+                lambda ignored: reactor.callLater(0, reactor.stop))
+        else:
+            self._capture_return_value(result)
+            reactor.callLater(0, reactor.stop)
+
+    def run(self):
+        """Actually run the command.
+
+        This method is invoked inside a running Twisted reactor, with the
+        options and arguments bound to keyword parameters.
+
+        Return a C{Deferred} or None if the command was successful.  It's okay
+        for this method to allow an exception to raise up.
+        """
+        raise NotImplementedError("Command '%r' needs to be implemented."
+                                  % self.name())

=== added file 'src/maascli/lib/commandant/controller.py'
--- src/maascli/lib/commandant/controller.py	1970-01-01 00:00:00 +0000
+++ src/maascli/lib/commandant/controller.py	2012-09-12 15:31:22 +0000
@@ -0,0 +1,265 @@
+# Commandant is a framework for building command-oriented tools.
+# Copyright (C) 2009-2010 Jamshed Kakar.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""Infrastructure to run C{bzrlib.commands.Command}s and L{HelpTopic}s."""
+
+import os
+import shutil
+import stat
+import sys
+import tempfile
+
+import bzrlib.ui
+from bzrlib.commands import run_bzr, Command
+
+from commandant import __version__
+from commandant.commands import ExecutableCommand
+from commandant.help_topics import FileHelpTopic
+
+
+DEFAULT_PROGRAM_NAME = "commandant"
+DEFAULT_PROGRAM_VERSION = __version__
+DEFAULT_PROGRAM_SUMMARY = "A framework for building command-oriented tools."
+DEFAULT_PROGRAM_URL = "http://launchpad.net/commandant";
+
+
+class CommandRegistry(object):
+
+    def __init__(self):
+        self._commands = {}
+
+    def install_bzrlib_hooks(self):
+        """
+        Register this controller with C{Command.hooks} so that the controller
+        can take advantage of Bazaar's command infrastructure.  C{bzrlib.ui}
+        is initialized for use in a terminal during this process.
+
+        L{_list_commands} and L{_get_command} are registered as callbacks for
+        the C{list_commands} and C{get_commands} hooks, respectively.
+        """
+        Command.hooks.install_named_hook(
+            "list_commands", self._list_commands, "commandant commands")
+        Command.hooks.install_named_hook(
+            "get_command", self._get_command, "commandant commands")
+        bzrlib.ui.ui_factory = bzrlib.ui.make_ui_for_terminal(
+            sys.stdin, sys.stdout, sys.stderr)
+
+    def _list_commands(self, names):
+        """
+        Hook to find C{bzrlib.commands.Command} names is called by C{bzrlib}.
+
+        @param names: A set of C{bzrlib.commands.Command} names to update with
+            names from this controller.
+        """
+        names.update(self._commands.iterkeys())
+        return names
+
+    def _get_command(self, command, name):
+        """
+        Hook to get the C{bzrlib.commands.Command} for C{name} is called by
+        C{bzrlib}.
+
+        @param command: A C{bzrlib.commands.Command}, or C{None}, to be
+            returned if a command matching C{name} can't be found.
+        @param name: The name of the C{bzrlib.commands.Command} to retrieve.
+        @return: The C{bzrlib.commands.Command} from the index or C{command}
+            if one isn't available for C{name}.
+        """
+        try:
+            local_command = self._commands[name]()
+        except KeyError:
+            return command
+        local_command.controller = self
+        return local_command
+
+    def register_command(self, name, command_class):
+        """Register a C{bzrlib.commands.Command} with this controller.
+
+        @param name: The name to register the command with.
+        @param command_class: A type object, typically a subclass of
+            C{bzrlib.commands.Command} to use when the command is invoked.
+        """
+        self._commands[name] = command_class
+
+
+class HelpTopicRegistry(object):
+
+    def __init__(self):
+        self._help_topics = {}
+
+    def register_help_topic(self, name, help_topic_class):
+        """Register a C{bzrlib.commands.Command} to this controller.
+
+        @param name: The name to register the command with.
+        @param command_class: A type object, typically a subclass of
+            C{bzrlib.commands.Command} to use when the command is invoked.
+        """
+        self._help_topics[name] = help_topic_class
+
+    def get_help_topic_names(self):
+        """Get a C{set} of help topic names."""
+        return set(self._help_topics.iterkeys())
+
+    def get_help_topic(self, name):
+        """
+        Get the help topic matching C{name} or C{None} if a match isn't found.
+        """
+        try:
+            help_topic = self._help_topics[name]()
+        except KeyError:
+            return None
+        help_topic.controller = self
+        return help_topic
+
+
+class CommandDiscoveryMixin(object):
+
+    def load_path(self, path):
+        """Load C{bzrlib.commands.Command}s and L{HelpTopic}s from C{path}.
+
+        Python files foundin C{path} are loaded and
+        C{bzrlib.commands.Command}s and L{HelpTopic}s within are loaded.
+        L{ExecutableCommand}s are created for executable programs in C{path}
+        and L{FileHelpTopic}s are created for text file help topics.
+        """
+        package_path = tempfile.mkdtemp()
+        try:
+            for filename in os.listdir(path):
+                file_path = os.path.join(path, filename)
+                if filename.endswith("~") or not os.path.exists(file_path):
+                    continue
+                file_mode = os.stat(file_path)[0]
+                if not os.path.isfile(file_path):
+                    continue
+                if file_mode | stat.S_IEXEC == file_mode:
+                    sanitized_name = filename.replace("_", "-")
+                    executable = type(
+                        "Executable", (ExecutableCommand,),
+                        {"path": file_path})
+                    self.register_command(sanitized_name, executable)
+                elif filename.endswith(".py"):
+                    command_module = import_module(filename, file_path,
+                                                   package_path)
+                    self.load_module(command_module)
+                elif filename.endswith(".txt"):
+                    sanitized_name = filename.replace("_", "-")[:-4]
+                    topic = type(
+                        "Topic", (FileHelpTopic,),
+                        {"path": file_path})
+                    self.register_help_topic(sanitized_name, topic)
+        finally:
+            shutil.rmtree(package_path)
+
+    def load_module(self, module):
+        """Load C{bzrlib.commands.Command}s and L{HelpTopic}s from C{module}.
+
+        Objects found in the module with names that start with C{cmd_} are
+        treated as C{bzrlib.commands.Command}s and objects with names that
+        start with C{topic_} are treated as L{HelpTopic}s.
+        """
+        for name in module.__dict__:
+            if name.startswith("cmd_"):
+                sanitized_name = name[4:].replace("_", "-")
+                self.register_command(sanitized_name, module.__dict__[name])
+            elif name.startswith("topic_"):
+                sanitized_name = name[6:].replace("_", "-")
+                self.register_help_topic(sanitized_name, module.__dict__[name])
+
+    def get_command_names(self):
+        """
+        Get the C{set} of C{bzrlib.commands.Command} names registered with
+        this controller.
+
+        This method is equivalent to C{bzrlib.commands.all_command_names} when
+        the controller is installed with C{bzrlib}.
+        """
+        return set(self._commands.iterkeys())
+
+    def get_command(self, name):
+        """Get the C{bzrlib.commands.Command} registered for C{name}.
+
+        @return: The C{bzrlib.commands.Command} from the index or C{None} if
+            one isn't available for C{name}.
+        """
+        return self._get_command(None, name)
+
+
+class CommandExecutionMixin(object):
+
+    def run(self, argv):
+        """Run the C{bzrlib.commands.Command} specified in C{argv}.
+
+        @raise BzrCommandError: Raised if a matching command can't be found.
+        """
+        run_bzr(argv)
+
+
+class CommandController(CommandRegistry, HelpTopicRegistry,
+                        CommandDiscoveryMixin, CommandExecutionMixin):
+    """C{bzrlib.commands.Command} discovery and execution controller.
+
+    A L{CommandController} is a container for named C{bzrlib.commands.Command}s
+    and L{HelpTopic}s types.  The L{load_module} and L{load_path} methods load
+    C{bzrlib.commands.Command} and L{HelpTopic} types from modules and from
+    the file system.  The L{register_command} and L{register_help_topic}
+    methods register C{bzrlib.commands.Command}s and L{HelpTopic}s types with
+    the controller.
+
+    A controller is an execution engine for commands.  The L{run} method
+    accepts command line arguments, finds a matching command, and runs it.
+    """
+
+    def __init__(self, program_name=None, program_version=None,
+                 program_summary=None, program_url=None):
+        CommandRegistry.__init__(self)
+        HelpTopicRegistry.__init__(self)
+        self.program_name = program_name or DEFAULT_PROGRAM_NAME
+        self.program_version = program_version or DEFAULT_PROGRAM_VERSION
+        self.program_summary = program_summary or DEFAULT_PROGRAM_SUMMARY
+        self.program_url = program_url or DEFAULT_PROGRAM_URL
+
+
+def import_module(filename, file_path, package_path):
+    """Import a module and make it a child of C{commandant_command}.
+
+    The module source in C{filename} at C{file_path} is copied to a temporary
+    directory, a Python package called C{commandant_command}.
+
+    @param filename: The name of the module file.
+    @param file_path: The path to the module file.
+    @param package_path: The path for the new C{commandant_command} package.
+    @return: The new module.
+    """
+    module_path = os.path.join(package_path, "commandant_command")
+    if not os.path.exists(module_path):
+        os.mkdir(module_path)
+
+    init_path = os.path.join(module_path, "__init__.py")
+    open(init_path, "w").close()
+
+    source_code = open(file_path, "r").read()
+    module_file_path = os.path.join(module_path, filename)
+    module_file = open(module_file_path, "w")
+    module_file.write(source_code)
+    module_file.close()
+
+    name = filename[:-3]
+    sys.path.append(package_path)
+    try:
+        return __import__("commandant_command.%s" % (name,), fromlist=[name])
+    finally:
+        sys.path.pop()

=== added file 'src/maascli/lib/commandant/entry_point.py'
--- src/maascli/lib/commandant/entry_point.py	1970-01-01 00:00:00 +0000
+++ src/maascli/lib/commandant/entry_point.py	2012-09-12 15:31:22 +0000
@@ -0,0 +1,48 @@
+# Commandant is a framework for building command-oriented tools.
+# Copyright (C) 2009-2010 Jamshed Kakar.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""Bootstrap code starts and runs Commandant."""
+
+from commandant import builtins
+from commandant.errors import UsageError
+from commandant.controller import CommandController
+
+
+def main(argv):
+    """Run the command named in C{argv}.
+
+    If a command name isn't provided the C{help} command is shown.
+
+    @raises UsageError: Raised if too few arguments are provided.
+    @param argv: A list command-line arguments.  The first argument should be
+       the path to C{bzrlib.commands.Command}s and L{HelpTopic}s to load and
+       the second argument should be the name of the command to run.  Any
+       further arguments are passed to the command.
+    """
+    if len(argv) < 2 or (len(argv) > 1 and argv[1].startswith("-")):
+        raise UsageError(
+            "You must provide a path to the commands you want to run.")
+    elif len(argv) < 3:
+        argv.append("help")
+
+    # Load commands topic from the user-supplied path after loading builtins,
+    # in case any of the user's commands or topics replace builtin ones.
+    controller = CommandController()
+    controller.load_module(builtins)
+    controller.load_path(argv[1])
+    controller.install_bzrlib_hooks()
+    controller.run(argv[2:])

=== added file 'src/maascli/lib/commandant/errors.py'
--- src/maascli/lib/commandant/errors.py	1970-01-01 00:00:00 +0000
+++ src/maascli/lib/commandant/errors.py	2012-09-12 15:31:22 +0000
@@ -0,0 +1,28 @@
+# Commandant is a framework for building command-oriented tools.
+# Copyright (C) 2009-2010 Jamshed Kakar.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""Exception classes."""
+
+
+class CommandantError(StandardError):
+    """Base class for Commandant exceptions."""
+    pass
+
+
+class UsageError(CommandantError):
+    """Raised when too few command-line arguments are provided."""
+    pass

=== added file 'src/maascli/lib/commandant/formatting.py'
--- src/maascli/lib/commandant/formatting.py	1970-01-01 00:00:00 +0000
+++ src/maascli/lib/commandant/formatting.py	2012-09-12 15:31:22 +0000
@@ -0,0 +1,59 @@
+# Commandant is a framework for building command-oriented tools.
+# Copyright (C) 2009-2010 Jamshed Kakar.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""Infrastructure for pretty-printing formatted output."""
+
+
+def print_columns(outf, rows, shrink_index=None, max_width=78, padding=2):
+    """Calculate optimal column widths and print C{rows} to C{outf}.
+
+    @param outf: The stream to write to.
+    @param rows: A list of rows to print.  Each row is a tuple of columns.
+        All rows must contain the same number of columns.
+    @param shrink_index: The index of the column to shrink, if the columns
+        provided exceed C{max_width}.  Shrinking is disabled by default.
+    @param max_width: The maximum number of characters per line.  Defaults to
+        78, though it isn't enforced unless C{shrink_index} is specified.
+    @param padding: The number of blank characters to output between columns.
+        Defaults to 2.
+    """
+    if not rows:
+        return
+
+    widths = []
+    for row in rows:
+        if not widths:
+            widths = [len(column) for i, column in enumerate(row)]
+        else:
+            widths = [
+                max(widths[i], len(column)) for i, column in enumerate(row)]
+
+    if shrink_index is not None:
+        fixed_width = sum(width + padding for i, width in enumerate(widths)
+                          if i != shrink_index)
+        if fixed_width + widths[shrink_index] > max_width:
+            widths[shrink_index] = max_width - fixed_width
+
+    padding_space = "".ljust(padding)
+    for row in rows:
+        output = []
+        for i, column in enumerate(row):
+            text = column[:widths[i]].ljust(widths[i])
+            if (i + 1 == len(row)):
+                text = text.strip()
+            output.append(text)
+        print >>outf, padding_space.join(output)

=== added file 'src/maascli/lib/commandant/help_topics.py'
--- src/maascli/lib/commandant/help_topics.py	1970-01-01 00:00:00 +0000
+++ src/maascli/lib/commandant/help_topics.py	2012-09-12 15:31:22 +0000
@@ -0,0 +1,131 @@
+# Commandant is a framework for building command-oriented tools.
+# Copyright (C) 2009-2010 Jamshed Kakar.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""Infrastructure for building L{HelpTopic} components."""
+
+from inspect import getdoc
+
+
+class HelpTopic(object):
+    """A help topic."""
+
+    controller = None
+
+    def get_summary(self):
+        """Get a short topic summary for use in a topic listing."""
+        raise NotImplementedError("Must be implemented by sub-class.")
+
+    def get_text(self):
+        """Get topic content."""
+        raise NotImplementedError("Must be implemented by sub-class.")
+
+
+class DocstringHelpTopic(object):
+    """A help topic that loads content from its docstring."""
+
+    def __init__(self):
+        super(DocstringHelpTopic, self).__init__()
+        self._summary = None
+        self._text = None
+
+    def _get_docstring(self):
+        """Get the docstring for this help topic."""
+        return getdoc(self)
+
+    def _load_help_text(self):
+        """Load summary and text content."""
+        docstring = self._get_docstring()
+        if not docstring:
+            return "", ""
+        else:
+            return self._load_docstring(docstring)
+
+    def _load_docstring(self, docstring):
+        """Load summary and text content from a docstring."""
+        lines = [line for line in docstring.splitlines()]
+        return lines[0], "\n".join(lines[1:]).strip()
+
+    def get_summary(self):
+        """Get a short topic summary for use in a topic listing."""
+        if self._summary is None:
+            (self._summary, self._text) = self._load_help_text()
+        return self._summary
+
+    def get_text(self):
+        """Get topic content."""
+        if self._text is None:
+            (self._summary, self._text) = self._load_help_text()
+        return self._text
+
+
+class FileHelpTopic(HelpTopic):
+    """A help topic that loads content from a file."""
+
+    path = None
+
+    def __init__(self):
+        super(FileHelpTopic, self).__init__()
+        self._summary = None
+        self._text = None
+
+    def _load(self):
+        """Load and cache summary and text content."""
+        file = open(self.path, "r")
+        self._summary = file.readline().strip()
+        self._text = file.read().strip()
+        # FIXME Test this.
+        file.close()
+
+    def get_summary(self):
+        """Get a short topic summary for use in a topic listing."""
+        if self._summary is None:
+            self._load()
+        return self._summary
+
+    def get_text(self):
+        """Get topic content."""
+        if self._text is None:
+            self._load()
+        return self._text
+
+
+class CommandHelpTopic(DocstringHelpTopic):
+    """
+    A help topic that loads content from a C{bzrlib.commands.Command}
+    docstring.
+    """
+
+    def __init__(self, command):
+        super(CommandHelpTopic, self).__init__()
+        self.command = command
+
+    def _get_docstring(self):
+        """Get the docstring for the command."""
+        return getdoc(self.command)
+
+    def _load_docstring(self, docstring):
+        """Load summary and text content from a docstring."""
+        lines = [line for line in docstring.splitlines()]
+        summary = lines[0]
+        try:
+            text = self.command.get_help_text().strip()
+            text = text.replace("bzr", self.controller.program_name)
+        except NotImplementedError:
+            pass
+        else:
+            return summary, text
+        return summary, ""


Follow ups