ui.cli: Add do_shell() and associated framework
authorW. Trevor King <wking@tremily.us>
Fri, 15 Feb 2013 03:03:24 +0000 (22:03 -0500)
committerW. Trevor King <wking@tremily.us>
Fri, 15 Feb 2013 03:18:20 +0000 (22:18 -0500)
For more open ended questions, users may need an interactive shell to
develop the answer, and we won't be able to bundle their answer with
setup and teardown instructions in a single script.  This commit
breaks setup and teardown into separate scripts, and moves the
TemporaryDirectory stuff over to quizzer.util.  If it's enabled via
allow_interactive, users can now drop into an interactive command
using !$COMMAND (e.g. !bash) from the cli user interface.

If you want to tweak environment variables for the answer script and
interactive commands, use the new environment dictionary.

For situations where you *do* need setup/teardown stuff in the answer
script (e.g. to check the current working directory or variables that
should have been altered by the user's answer action), you can use
the new pre_answer and post_answer.

Previous versions of ScriptQuestion compared the output of the single
setup/answer/teardown script to check for matches.  With this commit
we only compare the output of the teardown script, unless
compare_answers is set, in which case we compare both the answer
output and teardown output.  When we're not comparing the answer
output, we still plot nonempty user-answer standard errors, because
without seeing stderr, it can be difficult to determine where your
attempted command went wrong.

Existing quizzes were updated accordingly, with a few additional
tweaks and cleanups that I discovered while debugging.

quizzer/cli.py
quizzer/question.py
quizzer/ui/__init__.py
quizzer/ui/cli.py
quizzer/util.py
quizzes/git.json
quizzes/posix-shell.json
quizzes/posix-utilities.json

index d61f815b53b69c43c3abe20bad58125c9adb0b4c..cc4880ca48ea490867de4f3f02b94eb1b583ae24 100644 (file)
@@ -86,6 +86,8 @@ def main():
             print()
         return
     ui = _cli.CommandLineInterface(quiz=quiz, answers=answers, stack=stack)
-    ui.run()
-    ui.answers.save()
+    try:
+        ui.run()
+    finally:
+        ui.answers.save()
     ui.display_results()
index da722df62373c63176490b3a01540ebee0924129..f7fa269f4b78a3f1dbb04b25ec526f07c3ce77aa 100644 (file)
@@ -15,8 +15,7 @@
 # quizzer.  If not, see <http://www.gnu.org/licenses/>.
 
 import logging as _logging
-import os.path as _os_path
-import tempfile as _tempfile
+import os as _os
 
 from . import error as _error
 from . import util as _util
@@ -101,10 +100,37 @@ class ChoiceQuestion (Question):
 
 
 class ScriptQuestion (Question):
+    """Question testing scripting knowledge
+
+    Or using a script interpreter (like the POSIX shell) to test some
+    other knowledge.
+
+    If stdout/stderr capture is acceptable (e.g. if you're only
+    running non-interactive commands or curses applications that grab
+    the TTY directly), you can just run `.check()` like a normal
+    question.
+
+    If, on the other hand, you want users to be able to interact with
+    stdout and stderr (e.g. to drop into a shell in the temporary
+    directory), use:
+
+        tempdir = q.setup_tempdir()
+        try:
+            tempdir.invoke(..., env=q.get_environment())
+            # can call .invoke() multiple times here
+            self.check(tempdir=tempdir)  # optional answer argument
+        finally:
+            tempdir.cleanup()  # occasionally redundant, but that's ok
+    """
     _state_attributes = Question._state_attributes + [
         'interpreter',
         'setup',
+        'pre_answer',
+        'post_answer',
         'teardown',
+        'environment',
+        'allow_interactive',
+        'compare_answers',
         'timeout',
         ]
 
@@ -113,58 +139,104 @@ class ScriptQuestion (Question):
             state['interpreter'] = 'sh'  # POSIX-compatible shell
         if 'timeout' not in state:
             state['timeout'] = 3
-        for key in ['setup', 'teardown']:
+        if 'environment' not in state:
+            state['environment'] = {}
+        for key in ['allow_interactive', 'compare_answers']:
+            if key not in state:
+                state[key] = False
+        for key in ['setup', 'pre_answer', 'post_answer', 'teardown']:
             if key not in state:
                 state[key] = []
         super(ScriptQuestion, self).__setstate__(state)
 
-    def check(self, answer):
+    def run(self, tempdir, lines, **kwargs):
+        text = '\n'.join(lines + [''])
+        try:
+            status,stdout,stderr = tempdir.invoke(
+                interpreter=self.interpreter, text=text,
+                timeout=self.timeout, **kwargs)
+        except:
+            tempdir.cleanup()
+            raise
+        else:
+            return (status, stdout, stderr)
+
+    def setup_tempdir(self):
+        tempdir = _util.TemporaryDirectory()
+        self.run(tempdir=tempdir, lines=self.setup)
+        return tempdir
+
+    def teardown_tempdir(self, tempdir):
+        return self.run(tempdir=tempdir, lines=self.teardown)
+
+    def get_environment(self):
+        if self.environment:
+            env = {}
+            env.update(_os.environ)
+            env.update(self.environment)
+            return env
+
+    def _invoke(self, answer=None, tempdir=None):
+        """Run the setup/answer/teardown process
+
+        If tempdir is not None, skip the setup process.
+        If answer is None, skip the answer process.
+
+        In any case, cleanup the tempdir before returning.
+        """
+        if not tempdir:
+            tempdir = self.setup_tempdir()
+        try:
+            if answer:
+                if not self.multiline:
+                    answer = [answer]
+                a_status,a_stdout,a_stderr = self.run(
+                    tempdir=tempdir,
+                    lines=self.pre_answer + answer + self.post_answer,
+                    env=self.get_environment())
+            else:
+                a_status = a_stdout = a_stderr = None
+            t_status,t_stdout,t_stderr = self.teardown_tempdir(tempdir=tempdir)
+        finally:
+            tempdir.cleanup()
+        return (a_status,a_stdout,a_stderr,
+                t_status,t_stdout,t_stderr)
+
+    def check(self, answer=None, tempdir=None):
+        """Compare the user's answer with expected values
+
+        Arguments are passed through to ._invoke() for calculating the
+        user's response.
+        """
         # figure out the expected values
-        e_status,e_stdout,e_stderr = self._invoke(self.answer)
+        (ea_status,ea_stdout,ea_stderr,
+         et_status,et_stdout,et_stderr) = self._invoke(answer=self.answer)
         # get values for the user-supplied answer
         try:
-            a_status,a_stdout,a_stderr = self._invoke(answer)
-        except _error.CommandError as e:
-            LOG.warning(e)
+            (ua_status,ua_stdout,ua_stderr,
+             ut_status,ut_stdout,ut_stderr) = self._invoke(
+                answer=answer, tempdir=tempdir)
+        except (KeyboardInterrupt, _error.CommandError) as e:
+            if isinstance(e, KeyboardInterrupt):
+                LOG.warning('KeyboardInterrupt')
+            else:
+                LOG.warning(e)
+            return False
+        # compare user-generated output with expected values
+        if answer:
+            if self.compare_answers:
+                if _util.invocation_difference(  # compare answers
+                        ea_status, ea_stdout, ea_stderr,
+                        ua_status, ua_stdout, ua_stderr):
+                    return False
+            elif ua_stderr:
+                LOG.warning(ua_stderr)
+        if _util.invocation_difference(  # compare teardown
+                et_status, et_stdout, et_stderr,
+                ut_status, ut_stdout, ut_stderr):
             return False
-        for (name, e, a) in [
-                ('stderr', e_stderr, a_stderr),
-                ('status', e_status, a_status),
-                ('stdout', e_stdout, a_stdout),
-                ]:
-            if a != e:
-                if name == 'status':
-                    LOG.info(
-                        'missmatched {}, expected {!r} but got {!r}'.format(
-                            name, e, a))
-                else:
-                    LOG.info('missmatched {}, expected:'.format(name))
-                    LOG.info(e)
-                    LOG.info('but got:')
-                    LOG.info(a)
-                return False
         return True
-
-    def _invoke(self, answer):
-        prefix = '{}-'.format(type(self).__name__)
-        if not self.multiline:
-            answer = [answer]
-        script = '\n'.join(self.setup + answer + self.teardown + [''])
-        with _tempfile.NamedTemporaryFile(
-                mode='w', prefix='{}script-'.format(prefix)) as tempscript:
-            tempscript.write(script)
-            tempscript.flush()
-            with _tempfile.TemporaryDirectory(prefix=prefix) as tempdir:
-                status,stdout,stderr = _util.invoke(
-                    args=[self.interpreter, tempscript.name],
-                    cwd=tempdir,
-                    universal_newlines=True,
-                    timeout=self.timeout,
-                    )
-                dirname = _os_path.basename(tempdir)
-        stdout = stdout.replace(dirname, '{}XXXXXX'.format(prefix))
-        stderr = stderr.replace(dirname, '{}XXXXXX'.format(prefix))
-        return status,stdout,stderr
+        
 
 for name,obj in list(locals().items()):
     if name.startswith('_'):
index 2352618cdc0f725fb98a53bf0202db87c538d5ef..4b39468bcdf3bf6e27880076498b7a4dbc5c987c 100644 (file)
@@ -39,8 +39,8 @@ class UserInterface (object):
         if self.stack:
             return self.stack.pop(0)
 
-    def process_answer(self, question, answer):
-        correct = question.check(answer)
+    def process_answer(self, question, answer, **kwargs):
+        correct = question.check(answer=answer, **kwargs)
         self.answers.add(question=question, answer=answer, correct=correct)
         if not correct:
             self.stack.insert(0, question)
index a0f3652968420679a7921fe0e1364b37377395bb..22d4b9b3e017ce25734307f7d850f939d5a52b31 100644 (file)
@@ -27,6 +27,7 @@ except ImportError as e:
         return text
     print(e)
 
+from .. import error as _error
 from . import UserInterface
 
 
@@ -44,6 +45,7 @@ class QuestionCommandLine (_cmd.Cmd):
         self.ui = ui
         if self.ui.quiz.introduction:
             self.intro = '\n\n'.join([self.intro, self.ui.quiz.introduction])
+        self._tempdir = None
 
     def get_question(self):
         self.question = self.ui.get_question()
@@ -57,6 +59,9 @@ class QuestionCommandLine (_cmd.Cmd):
 
     def _reset(self):
         self.answers = []
+        if self._tempdir:
+            self._tempdir.cleanup()  # occasionally redundant, but that's ok
+        self._tempdir = None
         self._set_ps1()
 
     def _set_ps1(self):
@@ -90,7 +95,11 @@ class QuestionCommandLine (_cmd.Cmd):
             answer = self.answers[0]
         else:
             answer = ''
-        correct = self.ui.process_answer(question=self.question, answer=answer)
+        kwargs = {}
+        if self._tempdir:
+            kwargs['tempdir'] = self._tempdir
+        correct = self.ui.process_answer(
+            question=self.question, answer=answer, **kwargs)
         if correct:
             print(_colorize(self.ui.colors['correct'], 'correct\n'))
         else:
@@ -107,6 +116,32 @@ class QuestionCommandLine (_cmd.Cmd):
         """
         return self.default(arg)
 
+    def do_shell(self, arg):
+        """Run a shell command in the question temporary directory
+
+        For example, you can spawn an interactive session with:
+
+          quizzer? !bash
+
+        If the question does not allow interactive sessions, this
+        action is a no-op.
+        """
+        if getattr(self.question, 'allow_interactive', False):
+            if not self._tempdir:
+                self._tempdir = self.question.setup_tempdir()
+            try:
+                self._tempdir.invoke(
+                    interpreter='/bin/sh', text=arg, stdout=None, stderr=None,
+                    universal_newlines=False,
+                    env=self.question.get_environment())
+            except (KeyboardInterrupt, _error.CommandError) as e:
+                if isinstance(e, KeyboardInterrupt):
+                    LOG.warning('KeyboardInterrupt')
+                else:
+                    LOG.warning(e)
+                self._tempdir.cleanup()
+                self._tempdir = None
+
     def do_quit(self, arg):
         "Stop taking the quiz"
         self._reset()
index d67bbaedf02f459f87668edf8945eaa169675a8e..9f9b6dca8c0299bec9c672c1c584f42d7f1adcd9 100644 (file)
 # You should have received a copy of the GNU General Public License along with
 # quizzer.  If not, see <http://www.gnu.org/licenses/>.
 
+import logging as _logging
+import os.path as _os_path
 import subprocess as _subprocess
+import tempfile as _tempfile
 
 from . import error as _error
 
 
-def invoke(args, stdin=None, universal_newlines=False, timeout=None,
-           expect=None, **kwargs):
+LOG = _logging.getLogger(__name__)
+
+
+def invoke(args, stdin=None, stdout=_subprocess.PIPE, stderr=_subprocess.PIPE,
+           universal_newlines=False, timeout=None, expect=None, **kwargs):
     if stdin:
         stdin_pipe = _subprocess.PIPE
     else:
         stdin_pipe = None
     try:
         p = _subprocess.Popen(
-            args, stdin=stdin_pipe, stdout=_subprocess.PIPE,
-            stderr=_subprocess.PIPE, universal_newlines=universal_newlines,
-            **kwargs)
+            args, stdin=stdin_pipe, stdout=stdout, stderr=stderr,
+            universal_newlines=universal_newlines, **kwargs)
     except FileNotFoundError as e:
         raise _error.CommandError(arguments=args, stdin=stdin) from e
     try:
@@ -49,3 +54,86 @@ def invoke(args, stdin=None, universal_newlines=False, timeout=None,
             args=args, stdin=stdin, stdout=stdout, stderr=stderr,
             status=status)
     return (status, stdout, stderr)
+
+def invocation_difference(a_status, a_stdout, a_stderr,
+                          b_status, b_stdout, b_stderr):
+    for (name, a, b) in [
+        ('stderr', a_stderr, b_stderr),
+        ('status', a_status, b_status),
+        ('stdout', a_stdout, b_stdout),
+        ]:
+        if a != b:
+            LOG.info(format_invocation_difference(name=name, a=a, b=b))
+            return (name, a, b)
+
+def format_invocation_difference(name, a, b):
+    if name == 'status':
+        return 'missmatched {}, expected {!r} but got {!r}'.format(name, a, b)
+    else:
+        return '\n'.join([
+                'missmatched {}, expected:'.format(name),
+                a,
+                'but got:',
+                b,
+                ])
+
+
+class TemporaryDirectory (object):
+    """A temporary directory for testing answers
+
+    >>> t = TemporaryDirectory()
+
+    Basic command execution:
+
+    >>> t.invoke('/bin/sh', 'touch a b c')
+    (0, '', '')
+    >>> t.invoke('/bin/sh', 'ls')
+    (0, 'a\nb\nc\n', '')
+
+    Captured stdout and stderr have instances of the random temporary
+    directory name normalized for easy comparison:
+
+    >>> t.invoke('/bin/sh', 'pwd')
+    (0, '/tmp/TemporaryDirectory-XXXXXX\n', '')
+
+    >>> t.cleanup()
+    """
+    def __init__(self):
+        self.prefix = '{}-'.format(type(self).__name__)
+        self.tempdir = _tempfile.TemporaryDirectory(prefix=self.prefix)
+
+    def cleanup(self):
+        if self.tempdir:
+            self.tempdir.cleanup()
+            self.tempdir = None
+
+    def __del__(self):
+        self.cleanup()
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, type, value, traceback):
+        self.cleanup()
+
+    def invoke(self, interpreter, text, universal_newlines=True, **kwargs):
+        if not self.tempdir:
+            raise RuntimeError(
+                'cannot invoke() on a cleaned up {}'.format(
+                    type(self).__name__))
+        with _tempfile.NamedTemporaryFile(
+                mode='w', prefix='{}script-'.format(self.prefix)
+                ) as tempscript:
+            tempscript.write(text)
+            tempscript.flush()
+            status,stdout,stderr = invoke(
+                args=[interpreter, tempscript.name],
+                cwd=self.tempdir.name,
+                universal_newlines=universal_newlines,
+                **kwargs)
+            dirname = _os_path.basename(self.tempdir.name)
+        if stdout:
+            stdout = stdout.replace(dirname, '{}XXXXXX'.format(self.prefix))
+        if stderr:
+            stderr = stderr.replace(dirname, '{}XXXXXX'.format(self.prefix))
+        return (status, stdout, stderr)
index bc30d9fa81d60905b5f838d782af91ad35ff66a8..e9b74f2056c35c033a7c7ab8ed165f993ecf9fd7 100644 (file)
@@ -6,8 +6,9 @@
                        "class": "ScriptQuestion",
                        "intepreter": "sh",
                        "id": "git help config",
-                       "prompt": "Get help for Git's `config` command",
+                       "prompt": "Get help for Git's `config` command.",
                        "answer": "git help config",
+                       "compare_answers": true,
                        "help": "http://www.kernel.org/pub/software/scm/git/docs/git-help.html",
                        "tags": [
                                "help"
@@ -19,9 +20,9 @@
                        "id": "git config --global user.name",
                        "prompt": "Configure your user-wide name to be `A U Thor`.",
                        "answer": "git config --global user.name 'A U Thor'",
-                       "setup": [
-                                       "export HOME=."
-                                       ],
+                       "environment": {
+                               "HOME": "."
+                               },
                        "teardown": [
                                "cat .gitconfig"
                                ],
@@ -36,9 +37,9 @@
                        "id": "git config --global user.email",
                        "prompt": "Configure your user-wide email to be `author@example.com`.",
                        "answer": "git config --global user.email 'author@example.com'",
-                       "setup": [
-                                       "export HOME=."
-                                       ],
+                       "environment": {
+                               "HOME": "."
+                               },
                        "teardown": [
                                "cat .gitconfig"
                                ],
@@ -59,6 +60,8 @@
                                        "git init"
                                        ],
                        "teardown": [
+                               "cd my-project",
+                               "git rev-parse --git-dir",
                                "git status"
                                ],
                        "help": "http://www.kernel.org/pub/software/scm/git/docs/git-init.html",
                                "git add README",
                                "git commit -m 'Add a README'"
                                ],
+                       "environment": {
+                               "GIT_AUTHOR_NAME": "A U Thor",
+                               "GIT_AUTHOR_EMAIL": "author@example.com",
+                               "GIT_COMMITTER_NAME": "C O Mitter",
+                               "GIT_COMMITTER_EMAIL": "committer@example.com",
+                               "GIT_AUTHOR_DATE": "1970-01-01T00:00:00Z",
+                               "GIT_COMMITTER_DATE": "1970-01-01T00:00:00Z"
+                               },
                        "setup": [
-                               "export GIT_AUTHOR_NAME='A U Thor'",
-                               "export GIT_AUTHOR_EMAIL=author@example.com",
-                               "export GIT_COMMITTER_NAME='C O Mitter'",
-                               "export GIT_COMMITTER_EMAIL=committer@example.com",
-                               "export GIT_AUTHOR_DATE=1970-01-01T00:00:00Z",
-                               "export GIT_COMMITTER_DATE=\"$GIT_AUTHOR_DATE\"",
                                "git init",
                                "echo 'This project is wonderful' > README"
                                ],
                        "teardown": [
-                               "git ls-files",
+                               "git log -p",
                                "git status"
                                ],
                        "help": [
                                "How would you check?"
                                ],
                        "answer": "git status",
+                       "compare_answers": true,
                        "setup": [
                                "git init",
                                "echo 'This project is wonderful' > README",
                                ],
                        "timeout": null,
                        "answer": "git commit -am 'Reformat widgets'",
+                       "environment": {
+                               "GIT_AUTHOR_NAME": "A U Thor",
+                               "GIT_AUTHOR_EMAIL": "author@example.com",
+                               "GIT_COMMITTER_NAME": "C O Mitter",
+                               "GIT_COMMITTER_EMAIL": "committer@example.com",
+                               "GIT_AUTHOR_DATE": "1970-01-01T00:01:00Z",
+                               "GIT_COMMITTER_DATE": "1970-01-01T00:01:00Z"
+                               },
                        "setup": [
                                "export GIT_AUTHOR_NAME='A U Thor'",
                                "export GIT_AUTHOR_EMAIL=author@example.com",
                                "echo 'Lots of widgets' > widgets",
                                "git add README widgets",
                                "git commit -m 'Add some widgets'",
-                               "export GIT_AUTHOR_DATE=1970-01-01T00:01:00Z",
-                               "export GIT_COMMITTER_DATE=\"$GIT_AUTHOR_DATE\"",
                                "echo 'Take a look in the widgets file.' >> README",
                                "echo 'Widget-1 should be blue' >> widgets"
                                ],
                                "git rm widgets",
                                "git commit -am \"Remove 'widgets'\""
                                ],
+                       "environment": {
+                               "GIT_AUTHOR_NAME": "A U Thor",
+                               "GIT_AUTHOR_EMAIL": "author@example.com",
+                               "GIT_COMMITTER_NAME": "C O Mitter",
+                               "GIT_COMMITTER_EMAIL": "committer@example.com",
+                               "GIT_AUTHOR_DATE": "1970-01-01T00:01:00Z",
+                               "GIT_COMMITTER_DATE": "1970-01-01T00:01:00Z"
+                               },
                        "setup": [
                                "export GIT_AUTHOR_NAME='A U Thor'",
                                "export GIT_AUTHOR_EMAIL=author@example.com",
                                "git init",
                                "echo 'Lots of widgets' > widgets",
                                "git add widgets",
-                               "git commit -m 'Add some widgets'",
-                               "export GIT_AUTHOR_DATE=1970-01-01T00:01:00Z",
-                               "export GIT_COMMITTER_DATE=\"$GIT_AUTHOR_DATE\""
+                               "git commit -m 'Add some widgets'"
                                ],
                        "teardown": [
                                "git log -p"
                                "Ask Git to display the changes you've make (but not staged)."
                                ],
                        "answer": "git diff",
+                       "compare_answers": true,
                        "setup": [
                                "export GIT_AUTHOR_NAME='A U Thor'",
                                "export GIT_AUTHOR_EMAIL=author@example.com",
                                "including any changes that you may have already staged."
                                ],
                        "answer": "git diff HEAD --",
+                       "compare_answers": true,
                        "setup": [
                                "export GIT_AUTHOR_NAME='A U Thor'",
                                "export GIT_AUTHOR_EMAIL=author@example.com",
                                "including any changes that you may have already staged."
                                ],
                        "answer": "git diff HEAD -- README",
+                       "compare_answers": true,
                        "setup": [
                                "export GIT_AUTHOR_NAME='A U Thor'",
                                "export GIT_AUTHOR_EMAIL=author@example.com",
                                "Ask Git to display only the changes you've staged."
                                ],
                        "answer": "git diff --cached",
+                       "compare_answers": true,
                        "setup": [
                                "export GIT_AUTHOR_NAME='A U Thor'",
                                "export GIT_AUTHOR_EMAIL=author@example.com",
                        "id": "git log",
                        "prompt": "Print the commits leading up to your current state.",
                        "answer": "git log",
+                       "compare_answers": true,
                        "setup": [
                                "export GIT_AUTHOR_NAME='A U Thor'",
                                "export GIT_AUTHOR_EMAIL=author@example.com",
                                "showing a patch for each commit."
                                ],
                        "answer": "git log -p",
+                       "compare_answers": true,
                        "setup": [
                                "export GIT_AUTHOR_NAME='A U Thor'",
                                "export GIT_AUTHOR_EMAIL=author@example.com",
                                "showing the files changed by each commit."
                                ],
                        "answer": "git log --stat",
+                       "compare_answers": true,
                        "setup": [
                                "export GIT_AUTHOR_NAME='A U Thor'",
                                "export GIT_AUTHOR_EMAIL=author@example.com",
                        "id": "git log --all",
                        "prompt": [
                                "Print every commit in your repository reachable from a reference",
-                               "(e.g from any tag or branch)"
+                               "(e.g from any tag or branch)."
                                ],
                        "answer": "git log --all",
+                       "compare_answers": true,
                        "setup": [
                                "export GIT_AUTHOR_NAME='A U Thor'",
                                "export GIT_AUTHOR_EMAIL=author@example.com",
                                "with each commit only using a single line."
                                ],
                        "answer": "git log --oneline",
+                       "compare_answers": true,
                        "setup": [
                                "export GIT_AUTHOR_NAME='A U Thor'",
                                "export GIT_AUTHOR_EMAIL=author@example.com",
                                "and an ASCII-art inheritence graph."
                                ],
                        "answer": "git log --oneline --graph",
+                       "compare_answers": true,
                        "setup": [
                                "export GIT_AUTHOR_NAME='A U Thor'",
                                "export GIT_AUTHOR_EMAIL=author@example.com",
                        "prompt": [
                                "Print the commits leading up to your current state,",
                                "with each commit only using a single line",
-                               "and reference (e.g. tag and branch) names before the summary"
+                               "and reference (e.g. tag and branch) names before the summary."
                                ],
                        "answer": "git log --oneline --decorate",
+                       "compare_answers": true,
                        "setup": [
                                "export GIT_AUTHOR_NAME='A U Thor'",
                                "export GIT_AUTHOR_EMAIL=author@example.com",
                        "answer": [
                                "git commit --amend -am 'Add a README'"
                                ],
+                       "environment": {
+                               "GIT_AUTHOR_NAME": "A U Thor",
+                               "GIT_AUTHOR_EMAIL": "author@example.com",
+                               "GIT_COMMITTER_NAME": "C O Mitter",
+                               "GIT_COMMITTER_EMAIL": "committer@example.com",
+                               "GIT_AUTHOR_DATE": "1970-01-01T00:01:00Z",
+                               "GIT_COMMITTER_DATE": "1970-01-01T00:01:00Z"
+                               },
                        "setup": [
                                "export GIT_AUTHOR_NAME='A U Thor'",
                                "export GIT_AUTHOR_EMAIL=author@example.com",
                                "echo 'This project is terrible' > README",
                                "git add README",
                                "git commit -am 'Add a README'",
-                               "export GIT_AUTHOR_DATE=1970-01-01T00:01:00Z",
-                               "export GIT_COMMITTER_DATE=\"$GIT_AUTHOR_DATE\"",
                                "echo 'This project is wonderful' > README"
                                ],
                        "teardown": [
                        "id": "git branch",
                        "prompt": "List all the local branches in your repository.",
                        "answer": "git branch",
+                       "compare_answers": true,
                        "setup": [
                                "export GIT_AUTHOR_NAME='A U Thor'",
                                "export GIT_AUTHOR_EMAIL=author@example.com",
                        "id": "git branch -a",
                        "prompt": "List all the branches (local and remote-tracking) in your repository.",
                        "answer": "git branch -a",
+                       "compare_answers": true,
                        "setup": [
                                "export GIT_AUTHOR_NAME='A U Thor'",
                                "export GIT_AUTHOR_EMAIL=author@example.com",
                        "id": "git branch -r",
                        "prompt": "List the remote-tracking branches in your repository.",
                        "answer": "git branch -r",
+                       "compare_answers": true,
                        "setup": [
                                "export GIT_AUTHOR_NAME='A U Thor'",
                                "export GIT_AUTHOR_EMAIL=author@example.com",
                        "class": "ScriptQuestion",
                        "interpreter": "sh",
                        "id": "git branch -d",
-                       "prompt": "Delete the local `widget-x` branch",
+                       "prompt": "Delete the local `widget-x` branch, which you just merged.",
                        "answer": "git branch -d widget-x",
                        "setup": [
                                "export GIT_AUTHOR_NAME='A U Thor'",
                                "git commit --allow-empty -m 'Dummy commit'",
                                "git branch widget-x"
                                ],
+                       "teardown": [
+                               "git branch",
+                               "git status"
+                               ],
                        "help": "http://www.kernel.org/pub/software/scm/git/docs/git-branch.html",
                        "tags": [
                                "branch"
                        "class": "ScriptQuestion",
                        "interpreter": "sh",
                        "id": "git checkout",
-                       "prompt": "Change your working directory to the `widget-x` branch",
+                       "prompt": "Change your working directory to the `widget-x` branch.",
                        "answer": "git checkout widget-x",
                        "setup": [
                                "export GIT_AUTHOR_NAME='A U Thor'",
                        "class": "ScriptQuestion",
                        "interpreter": "sh",
                        "id": "git checkout -b",
-                       "prompt": "Create and change to a new `widget-x` branch",
+                       "prompt": "Create and change to a new `widget-x` branch.",
                        "answer": "git checkout -b widget-x",
                        "setup": [
                                "export GIT_AUTHOR_NAME='A U Thor'",
                        "class": "ScriptQuestion",
                        "interpreter": "sh",
                        "id": "git merge",
-                       "prompt": "Merge the `widget-x` branch into the current branch",
+                       "prompt": "Merge the `widget-x` branch into the current branch.",
                        "answer": "git merge widget-x",
+                       "environment": {
+                               "GIT_AUTHOR_NAME": "A U Thor",
+                               "GIT_AUTHOR_EMAIL": "author@example.com",
+                               "GIT_COMMITTER_NAME": "C O Mitter",
+                               "GIT_COMMITTER_EMAIL": "committer@example.com",
+                               "GIT_AUTHOR_DATE": "1970-01-01T00:02:00Z",
+                               "GIT_COMMITTER_DATE": "1970-01-01T00:02:00Z"
+                               },
                        "setup": [
                                "export GIT_AUTHOR_NAME='A U Thor'",
                                "export GIT_AUTHOR_EMAIL=author@example.com",
                                "echo 'Widget X will be wonderful' > README",
                                "git add README",
                                "git commit -am 'Add widget-x documentation'",
-                               "export GIT_AUTHOR_DATE=1970-01-01T00:02:00Z",
-                               "export GIT_COMMITTER_DATE=\"$GIT_AUTHOR_DATE\"",
                                "git checkout master"
                                ],
                        "teardown": [
                        "id": "git remote -v",
                        "prompt": "List your configured remotes and their associated URLs.",
                        "answer": "git remote -v",
+                       "compare_answers": true,
                        "setup": [
                                "git init",
                                "git remote add alice git://alice.au/widgets.git",
                                "git init",
                                "git remote add widgets ../origin"
                                ],
+                       "pre_answer": [
+                               "cd test"
+                               ],
                        "teardown": [
-                               "cat .git/config"
+                               "cd test",
+                               "git log --all --oneline --graph --decorate"
                                ],
                        "help": "http://www.kernel.org/pub/software/scm/git/docs/git-fetch.html",
                        "tags": [
                                "git add README",
                                "git commit -am 'Add widget-x documentation'"
                                ],
+                       "pre_answer": [
+                               "cd test"
+                               ],
                        "teardown": [
-                               "cd ../origin",
+                               "cd origin",
                                "git log --oneline"
                                ],
                        "help": "http://www.kernel.org/pub/software/scm/git/docs/git-push.html",
index 693f6e34759e34df5afc2e8c5f1b04b4e1cdc2fa..30b966b15cd49d0bc3231a0c847dd066d8b7cef2 100644 (file)
@@ -8,6 +8,7 @@
                        "id": "quoting spaces",
                        "prompt": "Call `ls` and pass it two arguments: `a` and `b c`.",
                        "answer": "ls a 'b c'",
+                       "compare_answers": true,
                        "help": "http://pubs.opengroup.org/onlinepubs/009696699/utilities/xcu_chap02.html#tag_02_02"
                },
                {
@@ -16,6 +17,7 @@
                        "id": "echo constant",
                        "prompt": "Print the string `hello, world` to stdout.",
                        "answer": "echo 'hello, world'",
+                       "compare_answers": true,
                        "help": "http://pubs.opengroup.org/onlinepubs/009696699/utilities/echo.html"
                },
                {
@@ -24,6 +26,7 @@
                        "id": "parameter expansion",
                        "prompt": "Print the contents of the PATH variable to stdout.",
                        "answer": "echo \"$PATH\"",
+                       "compare_answers": true,
                        "help": "http://pubs.opengroup.org/onlinepubs/009696699/utilities/xcu_chap02.html#tag_02_06_02",
                        "dependencies": [
                                "echo constant"
                        "id": "variable assign constant",
                        "prompt": "Set the ABC variable to the string `xyz`.",
                        "answer": "ABC='xyz'",
-                       "teardown": [
+                       "post_answer": [
                                "echo \"ABC: '${ABC}'\""
                                ],
+                       "compare_answers": true,
                        "help": "http://tldp.org/LDP/abs/html/varassignment.html"
                },
                {
                        "class": "ScriptQuestion",
                        "interpreter": "sh",
+                       "id": "variable assign altered",
                        "prompt": "Prepend the string `/some/path:` to the PATH variable.",
                        "answer": "PATH=\"/some/path:$PATH\"",
-                       "teardown": [
+                       "post_answer": [
                                "echo \"PATH: '${PATH}'\""
                                ],
-                       "help": "",
+                       "compare_answers": true,
+                       "help": [
+                               "http://pubs.opengroup.org/onlinepubs/009696699/utilities/xcu_chap02.html#tag_02_06_02",
+                               "http://tldp.org/LDP/abs/html/varassignment.html"
+                               ],
                        "dependencies": [
+                               "parameter expansion",
                                "variable assign constant"
                                ]
                }
index a7b6481757866ef9ea3db2b9d6d621054479a476..cc2f88052d08774dd5ac86a2e15992563df2b049 100644 (file)
@@ -5,8 +5,10 @@
                {
                        "class": "ScriptQuestion",
                        "interpreter": "sh",
+                       "id": "ls",
                        "prompt": "List all the files in the current directory.",
                        "answer": "ls",
+                       "compare_answers": true,
                        "setup": [
                                "touch file-1 file-2 file-3"
                                ],
                        "interpreter": "sh",
                        "prompt": "Print the current directory to stdout.",
                        "answer": "pwd",
+                       "compare_answers": true,
                        "help": "http://pubs.opengroup.org/onlinepubs/009696699/idx/utilities.html"
                },
                {
                        "class": "ScriptQuestion",
                        "interpreter": "sh",
+                       "id": "cd",
                        "prompt": "Change to your home directory.",
                        "answer": "cd",
-                       "teardown": [
+                       "post_answer": [
                                "pwd"
                                ],
+                       "compare_answers": true,
                        "help": "http://pubs.opengroup.org/onlinepubs/009696699/idx/utilities.html"
                },
                {
                        "class": "ScriptQuestion",
                        "interpreter": "sh",
+                       "id": "cd ..",
                        "prompt": "Change to the parent of your current working directory.",
                        "answer": "cd ..",
-                       "teardown": [
+                       "post_answer": [
                                "pwd"
                                ],
+                       "compare_answers": true,
                        "help": "http://pubs.opengroup.org/onlinepubs/009696699/idx/utilities.html",
                        "dependencies": [
-                               "change to your home directory"
+                               "cd"
                                ]
                },
                {
                        "class": "ScriptQuestion",
                        "interpreter": "sh",
+                       "id": "cat",
                        "prompt": "Print the contents of README file to the terminal.",
                        "answer": "cat README",
+                       "compare_answers": true,
                        "setup": [
                                "echo 'This project is wonderful' > README"
                                ],
@@ -55,7 +64,7 @@
                                "http://pubs.opengroup.org/onlinepubs/009696699/idx/utilities.html"
                                ],
                        "dependencies": [
-                               "list all the files in the current directory"
+                               "ls"
                                ]
                }
        ]