Skip to content

Elegant mini-DSL for creating command line questionnaires

License

Notifications You must be signed in to change notification settings

kylebebak/questionnaire

Repository files navigation

questionnaire

License PyPI version Build Status Coverage Status Codacy Badge

questionnaire is a mini-DSL for writing command line questionnaires. It prompts a user to answer a series of questions and returns the answers.

Features

  • Compact, intuitive syntax
  • Composable: pipe answers to other programs as JSON or plain text
  • Flexible
    • Conditional questions can depend on previous answers
    • Allow users to reanswer questions
    • Validate and transform answers
    • Question presentation decoupled from answer values
    • Extend questionnaire as it's being run
  • choose one, choose many, and raw input prompters built in
    • Extend questionnaire by writing your own prompter

Installation

questionnaire is written in Python. It works with Python 2 and 3, although it's a bit prettier if you run it with Python 3. The best way to install it is with pip.

pip install questionnaire

Getting Started

Paste the following into a file and save it. Let's assume you save it as questions.py. Go to the command line and run python questions.py.

# questions.py
from questionnaire import Questionnaire
q = Questionnaire()

q.one('day', 'monday', 'friday', 'saturday', prompt='What day is it?')
q.one('time', ('morning', 'in the morning'), ('night', 'at night'), prompt='What time is it?')

q.run()
print(q.format_answers())

What's happening here? We instantiate a questionnaire with q = Questionnaire(), add two questions to it, and run the questionnaire. At the end the answers are printed to stdout as JSON.

Look at the second question with the "time" key. We pass tuples for our options instead of strings. The first value in these tuples is the answer value stored by the questionnaire, and the second value is the option presented to the user. In other words, the presentation of your questionnaire can be decoupled from the answers it returns.

Getting Fancy

Add a group of conditional questions to the questionnaire above. Only one of these questions will be asked, depending on the answers to the first two questions. Run the questionnaire and inspect the answers.

q = Questionnaire()
q.one('day', 'monday', 'friday', 'saturday')
q.one('time', 'morning', 'night')

q.many('activities', 'tacos de pastor', 'go to cantina', 'write code').condition(('time', 'night'))
q.many('activities', 'barbacoa', 'watch footy', 'walk dog').condition(('day', 'saturday'), ('time', 'morning'))
q.many('activities', 'eat granola', 'get dressed', 'go to work').condition(('time', 'morning'))

q.run()
print(q.format_answers(fmt='array'))

As you can see, it's easy to print answers to stdout. In keeping with the UNIX philosophy, Questionnaire is composable. If you don't want to write Python code, you can write a standalone questionnaire that pipes its answers to another program for handling. If you want to handle them in the same script, just use q.answers, which is a nice OrderedDict.

Also, did you notice the fmt='array' argument? This formats the answers as a JSON array instead of a JSON object. This guarantees parsing the answers doesn't screw up their order, although it might make parsing more cumbersome.

Plain Text

If you plan on piping the results of a questionnaire to a shell script, you might want plain text instead of JSON. Just pass fmt='plain' when you format the answers to your Questionnaire. The answers will be returned as plain text, one answer per line.

GitHub API

Here's another example. This one handles raw input. It first prompts the user for their GitHub username and password. Then it pauses the questionnaire and uses these credentials to hit the GitHub API to get a list of all their repos. Finally, it adds another question to the questionnaire and resumes it, prompting the user to choose one of these repos.

It depends on the Requests library, so install it if you want to give it a try. First, add a question using the raw prompter.

q = Questionnaire(show_answers=False, can_go_back=False)
q.raw('user', prompt='Username:')
q.raw('pass', prompt='Password:', secret=True)

q.run()
r = requests.get('https://api.github.com/user/repos', auth=(q.answers.get('user'), q.answers.get('pass')))
if not(r.ok):
    import sys
    print('username/password incorrect')
    sys.exit()

repos = [repo.get('url') for repo in r.json()]
q.one('repo', *repos, prompt='Choose a repo')
q.run()
print(q.answers.get('repo'))

More Examples

Check out clients in the examples directory.

Prompters

The core prompters are currently one, many, raw. The first two depend on the excellent pick package. All three are used in the examples above.

One Option

To require the user to pick one option from a list, invoke questionnaire.one. When the question is answered the chosen option is added to the answers dict. Pass idx to choose the index of the initially selected option.

Many Options

To allow the user to pick many options for a single question, invoke questionnaire.many. When the question is answered, the list of chosen options is added to the answers dict. As with the one prompter, users can use or h to go back. Pass a default index or list of indices to specify initially selected options.

Raw Input

For raw input, invoke questionnaire.raw. Optionally pass a type (int, float, ...) to coerce the answer to a given type. You can also pass a default value to be inserted if the user does not write anything. By default, the user can go back from a raw input question by entering < as the answer. To change this, pass your own go_back string.

If you want to capture password input, or any other secret input, pass secret=True.

Validating and Transforming Answers

questionnaire makes it easy to validate and transform answers. For validation, you chain a call to validate onto a question and pass a validation function. When the user answers, the validation function receives one argument (the answer).

If there's anything wrong with the answer, the function should return a string explaining what's wrong. The explanation is shown to the user when he submits an invalid answer. If there's nothing wrong with the answer, the function shouldn't return anything.

Transforming answers is very similar. Chain a call to transform onto a question and pass a transform function. This function receives the answer as an argument. It should do something with it and return a transformed answer. The transformed answer is the one that will actually be saved in the questionnaire. See how you can use questionnaire to help your users sign up for junk mail.

q = Questionnaire(can_go_back=False)

def email(email):
    import re
    if not re.match(r'[^@]+@[^@]+\.[^@]+', email):
        return 'Enter a valid email'

def one(options):
    if len(options) < 1:
        return 'You must choose at least 1 type of junk mail'

def join(options):
    return ', '.join(options)

q.raw('email').validate(email)
q.many('junk_mail', 'this one weird trick', 'cheap viagra', 'dermatologists hate her').validate(one).transform(join)

print(q.run())

If a question has both a transform and validate function, validation is performed on the answer before the transform is applied.

Conditional Questions

One of questionnaire's coolest features is asking questions conditionally based on previous answers. The API for conditional questions is simple and flexible.

If you add questions with the same key to a questionnaire, the conditions assigned to the questions determine which one is presented to the user. questionnaire iterates through the questions in the order they were added, and presents the first question whose condition is satisfied, or whose condition is None. If none of the questions for a key has a condition that is satisfied, all questions for this key are skipped.

A condition can be added to a question by chaining a call to condition onto the one, many, or raw call. A list of condition tuples must be passed. The first two values in a tuple, the key and the value, are required. The third value, the operator, is optional (the default operator is '==').

A key is a key for a previously answered question in the questionnaire. The key is used to look up the answer, and the answer gets compared with the value. If their relationship is true under the operator, this condition tuple is satisfied.

If all the condition tuples for a question are satisfied, the question's condition is satisfied.

Condition Operators

The default operator is equals. The following operators can be passed as strings: ==, !=, <, >, <=, >=, and their corresponding operator functions are looked up. If you want to define your own operators, make sure they are functions that accept two values (the values to be compared) and return a boolean.

Questionnaire Options

These can be passed to a questionnaire when you instantiate it. You can also change these properties (they have the same names) directly on the questionnaire instance while it's running.

  • show_answers: show all previous answers above question prompt
  • can_go_back: allow users to go back

Writing Your Own Prompters

questionnaire is easy to extend. Write a prompter function that satisfies the prompter API. When you add a question to your questionnaire, instead of invoking one, many, or raw, invoke the generic add function, and pass a function as the prompter arg.

The prompter API is super simple: a prompter is a function that should display a question and capture user input. It returns this input as an answer.

Going Back

If you want your prompter to allow users to go back, simply raise a QuestionnaireGoBack exception in the body of your prompter function instead of returning the answer. This exception can imported like so: from questionnaire.prompters import QuestionnaireGoBack.

When you raise this exception in your prompter, you can pass the number of steps to go back into the exception's constructor. For example, raise QuestionnaireGoBack(2) will go back two questions. If no value is passed to the constructor, the user goes back one question.

Tests

From the root of the repo, run python -m unittest discover -v. Tests in the tests directory are run automatically by Travis. These tests cover the Questionnaire, Question and Condition classes. They don't cover prompters, which are completely decoupled from Questionnaire.

If you want to make sure the core prompters are working, the modules in the examples directory should be used to test them. Run, for example, python -m examples.plans or python -m examples.activities from the root of the repo. If anyone wants to help automate these tests that would be great!

Coverage

To see coverage stats for the questionnaire package, run coverage run --source=questionnaire -m unittest discover -v and coverage report.

Contributing

If you want to improve questionnaire, fork the repo and submit a pull request. Integration tests for the prompters would be nice. I think it would also be nice to refactor the raw prompter to use curses. A boolean (Y/n) prompter might be nice, even though this use case is handled fine by the one prompter.

Gotchas

questionnaire merges stdout with stderr while the prompters are running. If you run a questionnaire and redirect stderr you'll find it contains everything printed to the terminal by curses.

License

This code is licensed under the MIT License.