From 6e70a3327ade3bd87ab54be2e7843f161d876eee Mon Sep 17 00:00:00 2001 From: "W. Trevor King" Date: Thu, 14 Feb 2013 22:03:24 -0500 Subject: [PATCH] ui.cli: Add do_shell() and associated framework 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 | 6 +- quizzer/question.py | 162 +++++++++++++++++++++++++---------- quizzer/ui/__init__.py | 4 +- quizzer/ui/cli.py | 37 +++++++- quizzer/util.py | 98 +++++++++++++++++++-- quizzes/git.json | 118 ++++++++++++++++++------- quizzes/posix-shell.json | 16 +++- quizzes/posix-utilities.json | 17 +++- 8 files changed, 365 insertions(+), 93 deletions(-) diff --git a/quizzer/cli.py b/quizzer/cli.py index d61f815..cc4880c 100644 --- a/quizzer/cli.py +++ b/quizzer/cli.py @@ -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() diff --git a/quizzer/question.py b/quizzer/question.py index da722df..f7fa269 100644 --- a/quizzer/question.py +++ b/quizzer/question.py @@ -15,8 +15,7 @@ # quizzer. If not, see . 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('_'): diff --git a/quizzer/ui/__init__.py b/quizzer/ui/__init__.py index 2352618..4b39468 100644 --- a/quizzer/ui/__init__.py +++ b/quizzer/ui/__init__.py @@ -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) diff --git a/quizzer/ui/cli.py b/quizzer/ui/cli.py index a0f3652..22d4b9b 100644 --- a/quizzer/ui/cli.py +++ b/quizzer/ui/cli.py @@ -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() diff --git a/quizzer/util.py b/quizzer/util.py index d67bbae..9f9b6dc 100644 --- a/quizzer/util.py +++ b/quizzer/util.py @@ -14,22 +14,27 @@ # You should have received a copy of the GNU General Public License along with # quizzer. If not, see . +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) diff --git a/quizzes/git.json b/quizzes/git.json index bc30d9f..e9b74f2 100644 --- a/quizzes/git.json +++ b/quizzes/git.json @@ -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", @@ -96,18 +99,20 @@ "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": [ @@ -128,6 +133,7 @@ "How would you check?" ], "answer": "git status", + "compare_answers": true, "setup": [ "git init", "echo 'This project is wonderful' > README", @@ -150,6 +156,14 @@ ], "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", @@ -162,8 +176,6 @@ "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" ], @@ -191,6 +203,14 @@ "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", @@ -201,9 +221,7 @@ "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" @@ -226,6 +244,7 @@ "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", @@ -256,6 +275,7 @@ "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", @@ -287,6 +307,7 @@ "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", @@ -317,6 +338,7 @@ "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", @@ -344,6 +366,7 @@ "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", @@ -376,6 +399,7 @@ "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", @@ -408,6 +432,7 @@ "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", @@ -437,9 +462,10 @@ "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", @@ -475,6 +501,7 @@ "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", @@ -508,6 +535,7 @@ "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", @@ -538,9 +566,10 @@ "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", @@ -611,6 +640,14 @@ "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", @@ -622,8 +659,6 @@ "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": [ @@ -661,6 +696,7 @@ "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", @@ -690,6 +726,7 @@ "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", @@ -719,6 +756,7 @@ "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", @@ -746,7 +784,7 @@ "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'", @@ -759,6 +797,10 @@ "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" @@ -768,7 +810,7 @@ "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'", @@ -793,7 +835,7 @@ "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'", @@ -850,8 +892,16 @@ "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", @@ -867,8 +917,6 @@ "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": [ @@ -906,6 +954,7 @@ "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", @@ -944,8 +993,12 @@ "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": [ @@ -983,8 +1036,11 @@ "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", diff --git a/quizzes/posix-shell.json b/quizzes/posix-shell.json index 693f6e3..30b966b 100644 --- a/quizzes/posix-shell.json +++ b/quizzes/posix-shell.json @@ -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" @@ -35,21 +38,28 @@ "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" ] } diff --git a/quizzes/posix-utilities.json b/quizzes/posix-utilities.json index a7b6481..cc2f880 100644 --- a/quizzes/posix-utilities.json +++ b/quizzes/posix-utilities.json @@ -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" ], @@ -17,36 +19,43 @@ "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" ] } ] -- 2.26.2