← Back to team overview

yellow team mailing list archive

[Merge] lp:~frankban/lpsetup/interactive-execution into lp:lpsetup

 

Francesco Banconi has proposed merging lp:~frankban/lpsetup/interactive-execution into lp:lpsetup.

Requested reviews:
  Yellow Squad (yellow)

For more details, see:
https://code.launchpad.net/~frankban/lpsetup/interactive-execution/+merge/117062

This branch adds interactive and dry execution for lpsetup commands.
The diff is very long, I am sorry.

== Changes ==

Fixed *ArgumentParser.get_args_from_namespace*: now `store_false` actions are correctly handled, i.e. if a '--option' is a boolean flag with action `store_false`, then '--option' is added as an argument if the corresponding namespace value is False. Added missing tests.


Added *StepsBasedSubCommand.has_interactive_run* attribute, defaulting to True. If *has_interactive_run* is True, a --yes option is automatically added to the subcommand. This attribute is also used to decide if the current run is an interactive one (i.e. if we need to prompt the user at the beginning of the process). All the commands except *update* and *version* are currently interactive.


Introduced steps' descriptions: the *description* attributes of each step are collected and used as command description. If the attribute is not found, the description is an empty string. It is possible to format description using using the Python's builtin templating system *string.Template* supporting $-based substitutions. The current namespace is used as context for substitutions.


Implemented the *confirm* function, and the RawInputReturning context manager that is used to temporarily mock raw_input so that it is possible to easily test *confirm*.


Added descriptions for all the relevant steps.


Added a --dry option to force the command to just display steps' descriptions and then exit.


Updated *handle_testing* to automatically imply a non-interactive run.


Updated *install_lxc* to pass --yes to the interactive sub commands re-invoked from inside the LXC.


Added --yes to integration tests.

-- 
https://code.launchpad.net/~frankban/lpsetup/interactive-execution/+merge/117062
Your team Yellow Squad is requested to review the proposed merge of lp:~frankban/lpsetup/interactive-execution into lp:lpsetup.
=== modified file 'lpsetup/argparser.py'
--- lpsetup/argparser.py	2012-06-27 12:00:03 +0000
+++ lpsetup/argparser.py	2012-07-27 13:46:17 +0000
@@ -17,6 +17,10 @@
 import sys
 
 from lpsetup.exceptions import ValidationError
+from lpsetup.utils import (
+    confirm,
+    get_step_description,
+    )
 
 
 class ArgumentParser(argparse.ArgumentParser):
@@ -106,12 +110,19 @@
             dest = action.dest
             option_strings = action.option_strings
             value = getattr(namespace, dest, None)
+            isbool = isinstance(value, bool)
+            # If the value is boolean and the action is 'store_false', we
+            # invert the value. This way the following `if value:` block
+            # is executed if the original value is False, and the argument
+            # is correctly added.
+            if isbool and isinstance(action, argparse._StoreFalseAction):
+                value = not value
             if value:
                 if option_strings:
                     args.append(option_strings[0])
                 if isinstance(value, list):
                     args.extend(value)
-                elif not isinstance(value, bool):
+                elif not isbool:
                     args.append(value)
         return args
 
@@ -332,6 +343,7 @@
         ...     trace.append('step2 received {0} and {1}'.format(foo, bar))
 
         >>> class SubCommand(StepsBasedSubCommand):
+        ...     has_interactive_run = False
         ...     steps = (
         ...         (step1, 'foo'),
         ...         (step2, 'foo', 'bar'),
@@ -419,9 +431,11 @@
     """
 
     steps = ()
+    has_interactive_run = True
 
     def add_arguments(self, parser):
         super(StepsBasedSubCommand, self).add_arguments(parser)
+        # Add steps related arguments.
         step_names = [self._get_step_name(i[0]) for i in self.steps]
         parser.add_argument(
             '-s', '--steps', nargs='+', choices=step_names,
@@ -429,6 +443,13 @@
         parser.add_argument(
             '--skip-steps', nargs='+', choices=step_names,
             help='Skip one or more internal functions.')
+        # Add developer interaction related arguments.
+        if self.has_interactive_run:
+            parser.add_argument(
+                '-y', '--yes', action='store_false', dest='interactive',
+                help='Assume yes to all queries.')
+        parser.add_argument(
+            '--dry', action='store_true', help='Dry run.')
 
     def _get_step_name(self, step):
         """Return the string representation of a step callable.
@@ -487,9 +508,38 @@
         """Default callable used to run a `step`, using given `args`."""
         return step(*args)
 
+    def is_interactive(self, namespace):
+        """Return True if this is an interactive run, False otherwise."""
+        return self.has_interactive_run and namespace.interactive
+
+    def get_steps_description(self, namespace, steps):
+        """Retrieve steps' descriptions from the given *steps*.
+
+        Return a string containing all the descriptions.
+        """
+        context = namespace.__dict__
+        descriptions = [get_step_description(i[1], **context) for i in steps]
+        return '\n'.join(filter(None, descriptions))
+
     def run(self, namespace):
+        steps = self.get_steps(namespace)
+        if namespace.dry or self.is_interactive(namespace):
+            # Collect and display the description of each step.
+            description = self.get_steps_description(namespace, steps)
+            if description:
+                print 'This command will perform the following actions:\n'
+                print description + '\n'
+            # Quit without errors if this is a dry run.
+            if namespace.dry:
+                return
+            # If this is not a dry run, then it is an interactive one.
+            # Prompt the user for confirmation to proceed and quit if
+            # requested (in this case with exit code 1).
+            if not confirm('Do you want to proceed?'):
+                return 1
+        # Execute all the steps.
         default_step_runner = self._call_step
-        for step_name, step, args in self.get_steps(namespace):
+        for step_name, step, args in steps:
             # Run the step using a dynamic dispatcher.
             step_runner = getattr(
                 self, 'call_' + step_name, default_step_runner)

=== modified file 'lpsetup/handlers.py'
--- lpsetup/handlers.py	2012-07-23 15:01:16 +0000
+++ lpsetup/handlers.py	2012-07-27 13:46:17 +0000
@@ -280,6 +280,7 @@
     if getattr(namespace, 'testing', False):
         namespace.create_scripts = True
         namespace.install_subunit = True
+        namespace.interactive = False
         namespace.no_checkout = True
         namespace.stop_lxc = True
         namespace.use_http = True

=== modified file 'lpsetup/subcommands/finish_inithost.py'
--- lpsetup/subcommands/finish_inithost.py	2012-07-20 10:40:43 +0000
+++ lpsetup/subcommands/finish_inithost.py	2012-07-27 13:46:17 +0000
@@ -52,6 +52,10 @@
     # Change owner of /srv/launchpad.dev/.
     pwd_database = pwd.getpwnam(user)
     os.chown('/srv/launchpad.dev/', pwd_database.pw_uid, pwd_database.pw_gid)
+setup_launchpad.description = """
+    Set up the Launchpad database: this will destroy any other Postgres db!
+    Make and install Launchpad (buildout dependencies, Apache configuration).
+"""
 
 
 class SubCommand(argparser.StepsBasedSubCommand):

=== modified file 'lpsetup/subcommands/inithost.py'
--- lpsetup/subcommands/inithost.py	2012-07-24 15:27:08 +0000
+++ lpsetup/subcommands/inithost.py	2012-07-27 13:46:17 +0000
@@ -139,6 +139,10 @@
     # Create the user (if he does not exist).
     if not user_exists(user):
         call('useradd', '-m', '-s', '/bin/bash', '-U', user)
+initialize_base.description = """
+    Update your system and install necessary deb packages ({packages}).
+    Create the user $user if it does not exist.
+""".format(packages=' '.join(BASE_PACKAGES))
 
 
 def initialize(user):
@@ -169,6 +173,11 @@
     make_backup(HOSTS_FILE)
     for line in lines:
         file_append(HOSTS_FILE, line)
+initialize.description = initialize_base.description + """
+    Create Apache document roots for launchpad and enable required Apache
+    modules ({modules}).
+    Set up hosts file for Launchpad ({hosts}).
+""".format(modules=LP_APACHE_MODULES, hosts=HOSTS_FILE)
 
 
 def setup_home(
@@ -195,6 +204,14 @@
         call('bzr', 'whoami', formataddr([full_name, email]))
         if valid_ssh_keys:
             subprocess.call(['bzr', 'launchpad-login', lpuser])
+setup_home.description = """
+    Set up the user's ssh directory ($home_dir/.ssh).
+    Create, if it does not exist, the ssh key $ssh_key_path.
+    Authorize this key for the user $user.
+    Add bazaar.launchpad.net to known hosts.
+    Set up bazaar authentication: $full_name <$email>.
+    Set up Launchpad user id: $lpuser.
+"""
 
 
 def initialize_lxc():
@@ -239,6 +256,10 @@
     call('apt-get', 'update')
     # Install base and Launchpad deb packages.
     apt_get_install(*LP_PACKAGES, LC_ALL='C', caller=call)
+setup_apt.description = """
+    Add required APT repositories and install Launchpad dependencies:
+    {dependencies}.
+""".format(dependencies=' '.join(LP_PACKAGES))
 
 
 class SubCommand(argparser.StepsBasedSubCommand):

=== modified file 'lpsetup/subcommands/initlxc.py'
--- lpsetup/subcommands/initlxc.py	2012-07-24 15:34:58 +0000
+++ lpsetup/subcommands/initlxc.py	2012-07-27 13:46:17 +0000
@@ -63,6 +63,10 @@
     # haveged is used to fill /dev/random, avoiding
     # entropy exhaustion during automated parallel tests.
     apt_get_install('haveged', caller=call)
+initialize.description = inithost.initialize_base.description + """
+    Install haveged in order to fill /dev/random, avoiding
+    entropy exhaustion during automated parallel tests.
+"""
 
 
 def create_lxc(lxc_name, lxc_arch, lxc_os, user, install_subunit):
@@ -120,11 +124,20 @@
         file_append(
             '/var/lib/lxc/{0}/fstab'.format(lxc_name),
             'none dev/shm tmpfs defaults 0 0\n')
+create_lxc.description = """
+    Create an LXC container named $lxc_name ($lxc_os $lxc_arch), bind mounting
+    $home_dir, and disabling apparmor profiles for lxc so that we don't have
+    problems installing Postgres.
+    Allow root access to the container.
+"""
 
 
 def start_lxc(lxc_name):
     """Start the lxc instance named `lxc_name`."""
     call('lxc-start', '-n', lxc_name, '-d')
+start_lxc.description = """
+    Start the LXC container named $lxc_name.
+"""
 
 
 def wait_for_lxc(lxc_name, ssh_key_path):
@@ -193,10 +206,13 @@
                     private_key, public_key, ssh_key_name, home_dir):
     """Prepare the Launchpad environment inside an LXC."""
     # Use ssh to call this script from inside the container.
-    args = ['init-host', '-u', user, '-E', email, '-f', full_name,
+    args = ['init-host', '--yes', '-u', user, '-E', email, '-f', full_name,
             '-l', lpuser, '-S', ssh_key_name, '--skip-steps', 'setup_home']
     cmd = this_command(home_dir, args)
     ssh(lxc_name, cmd, key=ssh_key_path)
+inithost_in_lxc.description = """
+    Initialize the LXC instance $lxc_name.
+"""
 
 
 def stop_lxc(lxc_name, ssh_key_path):

=== modified file 'lpsetup/subcommands/initrepo.py'
--- lpsetup/subcommands/initrepo.py	2012-07-20 10:40:43 +0000
+++ lpsetup/subcommands/initrepo.py	2012-07-27 13:46:17 +0000
@@ -82,6 +82,10 @@
             except subprocess.CalledProcessError as err:
                 msg = 'Error: unable to create the lightweight checkout: '
                 raise exceptions.ExecutionError(msg + err.output)
+fetch.description = """
+    Set up a Launchpad repository in $repository,
+    retrieving the source code from $source.
+"""
 
 
 def setup_bzr_locations(
@@ -111,6 +115,11 @@
         with open(path, 'w') as f:
             f.write(get_file_header() + '\n')
             parser.write(f)
+setup_bzr_locations.description = """
+    If bzr+ssh is used, update bazaar locations
+    ($home_dir/.bazaar/locations.conf) to include repository
+    $repository and branch $branch_name.
+"""
 
 
 class SubCommand(argparser.StepsBasedSubCommand):

=== modified file 'lpsetup/subcommands/install_lxc.py'
--- lpsetup/subcommands/install_lxc.py	2012-07-24 14:07:50 +0000
+++ lpsetup/subcommands/install_lxc.py	2012-07-27 13:46:17 +0000
@@ -68,6 +68,10 @@
     procfile = '/proc/sys/kernel/yama/protected_nonaccess_hardlinks'
     with open(procfile, 'w') as f:
         f.write('0\n')
+create_scripts.description = """
+    If requested, create helper script /usr/locl/bin/lp-setup-*:
+    they can be used to build Launchpad and start a parallel test run.
+"""
 
 
 def cmd_in_lxc(lxc_name, ssh_key_path, home_dir, args, as_user=None):
@@ -81,13 +85,16 @@
     args = [
         'init-repo', '--source', source,
         '--branch-name', branch_name, '--checkout-name', checkout_name,
-        '--repository', repository,
+        '--repository', repository, '--yes',
         ]
     if use_http:
         args.append('--use-http')
     if no_checkout:
         args.append('--no-checkout')
     cmd_in_lxc(lxc_name, ssh_key_path, home_dir, args, as_user=user)
+init_repo_in_lxc.description = """
+    Initialize the Launchpad repository inside the LXC container $lxc_name.
+""" + initrepo.fetch.description + initrepo.setup_bzr_locations.description
 
 
 def update_in_lxc(
@@ -97,12 +104,20 @@
     if use_http:
         args.append('--use-http')
     cmd_in_lxc(lxc_name, ssh_key_path, home_dir, args, as_user=user)
+update_in_lxc.description = (
+    update.initialize_directories.description +
+    update.update_dependencies.description +
+    update.update_tree.description)
 
 
 def finish_inithost_in_lxc(
     lxc_name, ssh_key_path, home_dir, user, target_dir):
-    args = ['finish-init-host', target_dir, '--user', user]
+    args = ['finish-init-host', target_dir, '--user', user, '--yes']
     cmd_in_lxc(lxc_name, ssh_key_path, home_dir, args)
+finish_inithost_in_lxc.description = """
+    Set up the database, make and install Launchpad inside the LXC instance
+    $lxc_name.
+"""
 
 
 class SubCommand(initlxc.SubCommand):
@@ -154,4 +169,4 @@
         parser.add_argument(
             '--testing', action='store_true',
             help='Alias for --create-scripts --install-subunit --no-checkout '
-                 '--stop_lxc --use-http.')
+                 '--stop_lxc --use-http --yes.')

=== modified file 'lpsetup/subcommands/update.py'
--- lpsetup/subcommands/update.py	2012-07-20 10:40:43 +0000
+++ lpsetup/subcommands/update.py	2012-07-27 13:46:17 +0000
@@ -28,6 +28,9 @@
     """
     for dir_ in ['eggs', 'yui', 'sourcecode']:
         mkdirs(os.path.join(target_dir, external_path, dir_))
+initialize_directories.description = """
+    Initialize the eggs, yui, and sourcecode directories in $target_dir.
+"""
 
 
 def update_dependencies(target_dir, external_path, use_http):
@@ -53,12 +56,18 @@
         run(cmd,
             '--target', target_dir,
             '--parent', external_path)
+update_dependencies.description = """
+    Update Launchpad external dependencies in $target_dir.
+"""
 
 
 def update_tree(target_dir):
     """Update the tree at target_dir with the latest LP code."""
     with cd(target_dir):
         run('bzr', 'pull')
+update_tree.description = """
+    Update the tree at $target_dir with the latest LP code.
+"""
 
 
 class SubCommand(argparser.StepsBasedSubCommand):
@@ -67,6 +76,7 @@
     Gets new versions of Launchpad source and external sources.
     """
 
+    has_interactive_run = False
     steps = (
         (initialize_directories, 'target_dir', 'external_path'),
         (update_dependencies, 'target_dir', 'external_path', 'use_http'),

=== modified file 'lpsetup/tests/examples.py'
--- lpsetup/tests/examples.py	2012-06-27 09:20:50 +0000
+++ lpsetup/tests/examples.py	2012-07-27 13:46:17 +0000
@@ -28,6 +28,11 @@
 step2.step_name = 'mystep'
 
 
+def step_with_description(foo):
+    pass
+step_with_description.description = 'step description'
+
+
 def bad_step(foo):
     raise subprocess.CalledProcessError(1, 'command')
 
@@ -48,6 +53,7 @@
 class StepsBasedSubCommand(argparser.StepsBasedSubCommand):
     """An example steps based sub command."""
 
+    has_interactive_run = False
     steps = (
         (step1, 'foo'),
         (step2, 'foo', 'bar'),
@@ -76,3 +82,10 @@
         print 'running step1 with {args} while bar is {bar}'.format(
             args=','.join(args), bar=namespace.bar)
         step(*args)
+
+
+class InteractiveStepsBasedSubCommand(StepsBasedSubCommand):
+    """An example interactive steps based sub command."""
+
+    has_interactive_run = True
+    steps = [(step_with_description, 'foo')]

=== modified file 'lpsetup/tests/integration/test_init_host.py'
--- lpsetup/tests/integration/test_init_host.py	2012-07-25 13:11:25 +0000
+++ lpsetup/tests/integration/test_init_host.py	2012-07-27 13:46:17 +0000
@@ -27,7 +27,7 @@
         # Since the most common scenario is to have bzr whoami setup, we do
         # that instead of providing the arguments directly to lpsetup.
         super(InitHostTest, self).do_test()
-        self.on_remote('cd lpsetup; ./lp-setup init-host')
+        self.on_remote('cd lpsetup; ./lp-setup init-host --yes')
 
 
 if __name__ == '__main__':

=== modified file 'lpsetup/tests/integration/test_install_lxc.py'
--- lpsetup/tests/integration/test_install_lxc.py	2012-07-25 17:44:02 +0000
+++ lpsetup/tests/integration/test_install_lxc.py	2012-07-27 13:46:17 +0000
@@ -88,7 +88,7 @@
         urlparts = list(urlparse.urlsplit(self.push_location))
         urlparts[0] = 'http'
         branch_location = urlparse.urlunsplit(urlparts)
-        cmd = 'lpsetup/lp-setup install-lxc --use-http -B {} -r {}'.format(
+        cmd = 'lpsetup/lp-setup install-lxc --use-http -B {} -r {} -y'.format(
             branch_location, self.repo)
         self.on_remote(cmd)
 

=== modified file 'lpsetup/tests/subcommands/test_initrepo.py'
--- lpsetup/tests/subcommands/test_initrepo.py	2012-07-19 14:17:15 +0000
+++ lpsetup/tests/subcommands/test_initrepo.py	2012-07-27 13:46:17 +0000
@@ -96,6 +96,7 @@
             '--repository', self.repository,
             '--branch-name', branch_name,
             '--checkout-name', checkout_name,
+            '--yes',
             )
         self.branch = os.path.join(self.repository, branch_name)
         self.checkout = os.path.join(self.repository, checkout_name)

=== modified file 'lpsetup/tests/test_argparser.py'
--- lpsetup/tests/test_argparser.py	2012-06-27 09:20:50 +0000
+++ lpsetup/tests/test_argparser.py	2012-07-27 13:46:17 +0000
@@ -4,6 +4,7 @@
 
 """Tests for the argparser module."""
 
+from contextlib import nested
 import subprocess
 import unittest
 
@@ -13,6 +14,7 @@
 from lpsetup.tests.utils import (
     capture_error,
     capture_output,
+    RawInputReturning,
     SubCommandTestMixin,
     )
 
@@ -61,6 +63,27 @@
         args = self.parser.get_args_from_namespace(namespace)
         self.assertSequenceEqual(['--foo', 'changed', 'spam'], args)
 
+    def test_args_from_namespace_with_multiple_values(self):
+        # Ensure *get_args_from_namespace* correcty handles options
+        # accepting multiple values.
+        self.parser.add_argument('foo')
+        self.parser.add_argument('--bar', nargs='+')
+        namespace = self.parser.parse_args('foo --bar eggs spam'.split())
+        namespace.bar.append('another argument')
+        args = self.parser.get_args_from_namespace(namespace)
+        expected = ['foo', '--bar', 'eggs', 'spam', 'another argument']
+        self.assertSequenceEqual(expected, args)
+
+    def test_args_from_namespace_with_boolean_values(self):
+        # Ensure *get_args_from_namespace* correcty handles options
+        # accepting boolean values.
+        self.parser.add_argument('--foo', action='store_true')
+        self.parser.add_argument('--bar', action='store_false')
+        expected = ['--foo', '--bar']
+        namespace = self.parser.parse_args(expected)
+        args = self.parser.get_args_from_namespace(namespace)
+        self.assertSequenceEqual(expected, args)
+
     def test_help_subcommand(self):
         # Ensure the help sub command is added if other commands exist.
         self.parser.register_subcommand('foo', self.get_sub_command())
@@ -170,7 +193,9 @@
     def test_failing_step(self):
         # Ensure the steps execution is stopped if a step raises
         # `subprocess.CalledProcessError`.
-        with self.assertRaises(subprocess.CalledProcessError):
+        with nested(
+            capture_output(),
+            self.assertRaises(subprocess.CalledProcessError)):
             self.parse_and_call_main('--foo', 'eggs')
 
 
@@ -189,3 +214,50 @@
             'step2 received eggs and spam'
             ]
         self.check_output(expected, output)
+
+
+class InteractiveStepsBasedSubCommandTest(
+    SubCommandTestMixin, unittest.TestCase):
+
+    sub_command_class = examples.InteractiveStepsBasedSubCommand
+    step_description = examples.step_with_description.description
+
+    def test_command_description(self):
+        # Ensure the command description is generated collecting steps'
+        # descriptions.
+        with capture_output() as output:
+            with RawInputReturning('yes'):
+                self.parse_and_call_main()
+        self.assertIn(self.step_description, output.getvalue())
+
+    def test_interactive_execution_granted(self):
+        # Ensure the command executes if the user confirms to proceed.
+        with nested(capture_output(), RawInputReturning('yes')):
+            retcode = self.parse_and_call_main()
+        self.assertFalse(retcode)
+
+    def test_interactive_execution_denied(self):
+        # Ensure the command exits with an error if the user denies execution.
+        with nested(capture_output(), RawInputReturning('no')):
+            retcode = self.parse_and_call_main()
+        self.assertEqual(1, retcode)
+
+    def test_assume_yes(self):
+        # Ensure confirmation is not asked if `--yes` is provided.
+        with capture_output():
+            with RawInputReturning('') as cm:
+                self.parse_and_call_main('--yes')
+        self.assertEqual(0, cm.call_count)
+
+    def test_dry_run(self):
+        # Ensure a dry run is never interactive, exits without errors and
+        # prints out the command description.
+        with capture_output() as output:
+            with RawInputReturning('') as cm:
+                retcode = self.parse_and_call_main('--dry')
+        # Confirm has not been called.
+        self.assertEqual(0, cm.call_count)
+        # The command exits without errors.
+        self.assertFalse(retcode)
+        # The command description is displayed.
+        self.assertIn(self.step_description, output.getvalue())

=== modified file 'lpsetup/tests/test_handlers.py'
--- lpsetup/tests/test_handlers.py	2012-07-23 15:01:16 +0000
+++ lpsetup/tests/test_handlers.py	2012-07-27 13:46:17 +0000
@@ -319,17 +319,21 @@
 
     def test_true(self):
         # Ensure aliased options are set to True if testing is True.
-        namespace = argparse.Namespace(testing=True, **self.ctx)
+        namespace = argparse.Namespace(
+            testing=True, interactive=True, **self.ctx)
         handle_testing(namespace)
         for key in self.ctx:
             self.assertTrue(getattr(namespace, key))
+        self.assertFalse(namespace.interactive)
 
     def test_false(self):
         # Ensure no changes are made to aliased options if testing is False.
-        namespace = argparse.Namespace(testing=False, **self.ctx)
+        namespace = argparse.Namespace(
+            testing=False, interactive=True, **self.ctx)
         handle_testing(namespace)
         for key, value in self.ctx.items():
             self.assertEqual(value, getattr(namespace, key))
+        self.assertTrue(namespace.interactive)
 
 
 class HandleUserTest(HandlersTestMixin, unittest.TestCase):

=== modified file 'lpsetup/tests/test_utils.py'
--- lpsetup/tests/test_utils.py	2012-07-18 14:27:32 +0000
+++ lpsetup/tests/test_utils.py	2012-07-27 13:46:17 +0000
@@ -19,13 +19,19 @@
     LXC_LP_TEST_DIR_PATTERN,
     LXC_NAME,
     )
+from lpsetup.tests.utils import (
+    capture_output,
+    RawInputReturning,
+    )
 from lpsetup.utils import (
     ConfigParser,
+    confirm,
     get_container_path,
     get_file_header,
     get_lxc_gateway,
     get_network_interfaces,
     get_running_containers,
+    get_step_description,
     render_to_file,
     retry,
     Scrubber,
@@ -48,6 +54,39 @@
         self.assertEqual('value2', items['option2:colon'])
 
 
+class ConfirmTest(unittest.TestCase):
+
+    def test_yes(self):
+        # Ensure *confirm* returns True if the response is 'yes'.
+        for response in ('y', 'Y', 'yes', 'Yes'):
+            with RawInputReturning(response) as cm:
+                self.assertTrue(confirm('Question'))
+                self.assertEqual(1, cm.call_count)
+
+    def test_no(self):
+        # Ensure *confirm* returns False if the response is 'no'.
+        for response in ('n', 'N', 'no', 'No'):
+            with RawInputReturning(response) as cm:
+                self.assertFalse(confirm('Question'))
+                self.assertEqual(1, cm.call_count)
+
+    def test_default(self):
+        # Ensure default is honored if no input is given.
+        with RawInputReturning('') as cm:
+            self.assertFalse(confirm('Question', default=False))
+            self.assertEqual(1, cm.call_count)
+        with RawInputReturning('') as cm:
+            self.assertTrue(confirm('Question', default=True))
+            self.assertEqual(1, cm.call_count)
+
+    def test_wrong_input(self):
+        # Ensure the question is asked again if the answer is wrong.
+        with capture_output():
+            with RawInputReturning('Nope', 'Yep', 'y') as cm:
+                self.assertTrue(confirm('Question'))
+                self.assertEqual(3, cm.call_count)
+
+
 class GetContainerPathTest(unittest.TestCase):
 
     def test_root_path(self):
@@ -169,6 +208,58 @@
         self.assertRunning([], ['c1', 'c2', 'c3'])
 
 
+class GetStepDescriptionTest(unittest.TestCase):
+
+    def get_step(self, description=None):
+        step = lambda: None
+        step.description = description
+        return step
+
+    def test_with_context(self):
+        # Ensure the description is correctly retrieved and formatted.
+        step = self.get_step('This step will do $stuff.')
+        description = get_step_description(step, stuff='nothing')
+        self.assertEqual('This step will do nothing.', description)
+
+    def test_without_context(self):
+        # The description can be still retrieved if no context is provided.
+        expected = 'This step will do $stuff.'
+        description = get_step_description(self.get_step(expected))
+        self.assertEqual(expected, description)
+
+    def test_without_placeholder(self):
+        # Ensure the original placeholder is returned if missing
+        # from the context.
+        expected = 'This step will do $stuff.'
+        description = get_step_description(self.get_step(expected), foo='bar')
+        self.assertEqual(expected, description)
+
+    def test_missing_description(self):
+        # Ensure an empty string is returned if the description is not found.
+        description = get_step_description(lambda: None)
+        self.assertEqual('', description)
+
+    def test_dedent(self):
+        # Ensure the description is correctly dedented.
+        original = """
+            Hi there!
+        """
+        description = get_step_description(self.get_step(original))
+        self.assertEqual('Hi there!', description)
+
+    def test_empty_lines(self):
+        # Ensure empty lines in description are removed.
+        original = 'Hello.\n  \nGoodbye.'
+        description = get_step_description(self.get_step(original))
+        self.assertEqual('Hello.\nGoodbye.', description)
+
+    def test_wrapping(self):
+        # Ensure the description is correctly wrapped.
+        original = 'ten chars.' * 8  #  80 chars, wrapping is at 79.
+        description = get_step_description(self.get_step(original))
+        self.assertEqual(2, len(description.splitlines()))
+
+
 class RenderToFileTest(unittest.TestCase):
 
     def setUp(self):

=== modified file 'lpsetup/tests/utils.py'
--- lpsetup/tests/utils.py	2012-07-19 14:17:15 +0000
+++ lpsetup/tests/utils.py	2012-07-27 13:46:17 +0000
@@ -124,6 +124,30 @@
     lpuser is None, 'You need to set up a Launchpad login to run this test.')
 
 
+class RawInputReturning(object):
+    """Mocks the *raw_input* builtin function.
+
+    This context manager takes one or more pre-defined answers and
+    keeps track of mocked *raw_input* call count.
+    """
+    def __init__(self, *args):
+        self.answers = iter(args)
+        self._builtin = __import__('__builtin__')
+        self.call_count = 0
+        self.original = self._builtin.raw_input
+
+    def __enter__(self):
+        self._builtin.raw_input = self.input
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        self._builtin.raw_input = self.original
+
+    def input(self, question):
+        self.call_count += 1
+        return self.answers.next()
+
+
 class SubCommandTestMixin(object):
 
     sub_command_class = examples.SubCommand

=== modified file 'lpsetup/utils.py'
--- lpsetup/utils.py	2012-07-24 14:07:50 +0000
+++ lpsetup/utils.py	2012-07-27 13:46:17 +0000
@@ -7,11 +7,13 @@
 __metaclass__ = type
 __all__ = [
     'call',
+    'confirm',
     'get_container_path',
     'get_file_header',
     'get_lxc_gateway',
     'get_network_interfaces',
     'get_running_containers',
+    'get_step_description',
     'lxc_in_state',
     'lxc_ip',
     'lxc_stopped',
@@ -35,6 +37,7 @@
 import re
 import subprocess
 import shutil
+import string
 import sys
 import textwrap
 import time
@@ -79,6 +82,24 @@
         )
 
 
+def confirm(question, default=False):
+    """Ask the given yes/no *question*.
+
+    Return True if the answer is 'yes', False otherwise.
+    When the user presses Enter without typing anything, *default* is assumed.
+    """
+    suffix = 'Y/n' if default else 'y/N'
+    while True:
+        response = raw_input('{0} [{1}] '.format(question, suffix)).lower()
+        if not response:
+            return default
+        if response in ('y', 'yes'):
+            return True
+        if response in ('n', 'no'):
+            return False
+        print("I didn't understand you. Please specify '(y)es' or'(n)o'.")
+
+
 def get_container_path(lxc_name, path='', base_path=LXC_PATH):
     """Return the path of LXC container called `lxc_name`.
 
@@ -148,6 +169,27 @@
             visited[container] = 1
 
 
+def get_step_description(step, **kwargs):
+    """Retrieve step description from the given *step* callable.
+
+    *kwargs*, if provided, will be used as context to format the description.
+    Formatting is done using the Python's builtin templating system
+    *string.Template* supporting $-based substitutions.
+
+    If placeholders are missing from *kwargs* the original placeholder
+    (e.g. `$user`) will appear in the resulting string.
+    """
+    description = getattr(step, 'description', '')
+    if kwargs:
+        s = string.Template(description)
+        description = s.safe_substitute(**kwargs)
+    # Retrieve all the non empty lines and strip them.
+    lines = filter(None, [line.strip() for line in description.splitlines()])
+    # For each line, wrap the contents. Note that we can't wrap the text of
+    # the entire paragraph because we want to preserve existing new lines.
+    return '\n'.join('\n'.join(textwrap.wrap(line, 79)) for line in lines)
+
+
 def lxc_in_state(state, lxc_name, timeout=30):
     """Return True if the LXC named `lxc_name` is in state `state`.
 


Follow ups