PROJECT 10
Do you want a chance to be the teacher? This project tests users on their times tables and displays times tables so a user can practice. It keeps score and records how long it took to take the test.
Here’s where you put your class skills to the test and give your brain a workout.
Your math trainer should ask for answers about the times tables (from 1 through 12) and make sure the answer is right.
But does your best friend need help memorizing the tables? The math trainer could print out a times table. Maybe your other BFF knows the smaller numbers already, so the trainer should set a bottom limit (just avoid the 1s, for instance). If you like, you can add a higher upper limit than 12.
Add a score and time how long it takes to take the test. Game on!
Set up your file and put some code in that asks a question and works out whether the answer is correct.
Do the following to get the project up and running:
Use a hash comment to mark these sections: Imports, Constants, Function, and Testing.
You know from the silly sentences project that if you put a tuple after a formatting operator %, Python unpacks the tuple into formatting specifiers in the formatting string. That means that "What is %sx%s?"%(4,6) becomes "What is 4x6?". You’re going to store each question as a tuple with two numbers.
Make a question to use for testing. Create a TEST_QUESTION constant in the Constants section. Choose two numbers as a test and set TEST_QUESTION equal to the tuple containing them.
This example is (4,6). You can use it or think up some other numbers.
Create a constant called QUESTION_TEMPLATE.
It’ll be a formatting template for your test question. Set it to "What is %sx%s" (or another template if you can think of one).
Create a new variable question and make it equal to TEST_QUESTION.
Use that new variable in your code. Later you’ll list questions and get them one by one from the list. You’ll change the value of question, and the rest of the program will work with the new questions — without any other changes.
Here’s my code:
"""
math_trainer.py
Train your times tables.
Initial Features:
* Print out times table for a given number.
* Limit tables to a lower number (default is 1)
and an upper number (default is 12).
* Pose test questions to the user
* Check whether the user is right or wrong
* Track the user's score.
Brendan Scott
February 2015
"""
#### Constants Section
TEST_QUESTION = (4, 6)
QUESTION_TEMPLATE = "What is %sx%s? "
#### Function Section
#### Testing Section
question = TEST_QUESTION
prompt = QUESTION_TEMPLATE%question
correct_answer = question[0]*question[1] # indexes start from 0
answer = raw_input(prompt)
if int(answer)== correct_answer:
print("Correct!")
else:
print("Incorrect")
Run to test it:
>>> ================================ RESTART ================================
>>>
What is 4x6? 24
Correct!
>>> ================================ RESTART ================================
>>>
What is 4x6? 25
Incorrect
Test twice — once for when the answer is right and once for when the answer is wrong.
You could create the questions in this trainer using the random.randint function or even random.choice to create the questions. The problem is that you can’t make sure that the whole times table is tested. Your random number might never give you an 8, for example.
Instead, generate the whole list of possible questions (12x12 = 144 of them) and pick questions from that list. That way you can keep track of what you’ve picked and throw out some other questions. Way back in Project 2 I told you that using the range built-in in Python 2.7 can use up memory. This many entries is manageable, so using range here is okay.
To create your question list, follow along:
Specify the upper and lower limits of each number in the questions.
To do that, add lower and upper as arguments to your function. Add default values to the arguments in the function: lower = 1 and upper = 12:
def make_question_list(lower=LOWER, upper=UPPER):
This structure — lower=LOWER — looks odd, but it makes sense. LOWER is a constant with a default value, and lower is the name of the variable that will be used within the function.
return [(x+1, y+1) for x in range(lower-1, upper)
for y in range(lower-1, upper)]
Comment out the existing Testing section.
Don’t delete it; you’ll need it again later.
I added constants to the Constants section to avoid magic numbers (numbers that appear magically without any explanation in your code):
#### Constants
TEST_QUESTION = (4, 6)
QUESTION_TEMPLATE = "What is %sx%s? "
LOWER = 1
UPPER = 12
The Function section has a new function in it:
#### Function Section
def make_question_list(lower=LOWER, upper=UPPER):
""" prepare a list of questions in the form (x,y)
where x and y are in the range from LOWER to UPPER inclusive
"""
return [(x+1, y+1) for x in range(lower-1, upper)
for y in range(lower-1, upper)]
This function really only has one line of code — the double list comprehension. The list comprehension was tough because range(lower, upper) goes up to, but doesn’t include upper. That’s not what was specified in the statement of requirements. To account for this the generated tuples add 1 to both the numbers (x+1, y+1). It’s also the reason that lower value has -1 in both list comprehensions.
Minus (math pun) the code commented out, the Testing section now looks like this:
#### Testing Section
question_list = make_question_list()
print(question_list)
When you run it, you get a list of 144 tuples ranging from (1,1), (1,2)…(12,11), (12,12). That’s good.
This test shows that the function works for the default values. Test it with a couple other values just in case. Change the Testing section to this:
This is the new Testing section:
#### Testing Section
for lower,upper in [(2, 5), (4, 6), (7, 11)]:
question_list = make_question_list(lower, upper)
print(question_list)
Can you see what’s happening? The for loop runs through a list with three elements in it. Each element is a two-tuple. Each element is unpacked into the variables lower and upper, in order. For each of those values, the function is called and the question list it creates is printed.
This is what I got:
[(2, 2), (2, 3), (2, 4), (2, 5), (3, 2), (3, 3), (3, 4), (3, 5), (4, 2), (4, 3), (4, 4), (4, 5), (5, 2), (5, 3), (5, 4), (5, 5)]
[(4, 4), (4, 5), (4, 6), (5, 4), (5, 5), (5, 6), (6, 4), (6, 5), (6, 6)]
[(7, 7), (7, 8), (7, 9), (7, 10), (7, 11), (8, 7), (8, 8), (8, 9), (8, 10), (8, 11), (9, 7), (9, 8), (9, 9), (9, 10), (9, 11), (10, 7), (10, 8), (10, 9), (10, 10), (10, 11), (11, 7), (11, 8), (11, 9), (11, 10), (11, 11)]
You have a list of questions and you know how to ask any one of them. The next step is to bombard your user with questions — boom, boom, boom. If you try to step through the questions one at a time, you run into two problems:
No worries! You can solve these problems.
You can sort the table into random order:
Add an optional argument random_order to the function make_question_list.
The argument should have a default value of True (or False, if you prefer your questions to be in the right order).
Store the list of questions generated in a dummy variable.
It’s a dummy variable, so call it something short. I’m going to call it spam.
Test whether random_order is True.
if random_order: is good enough, but if that makes you uncomfortable, try if random_order is True:
If it is True, apply random.shuffle(spam) to the dummy variable. You shouldn’t need an else: block. Use help(random.shuffle) in the IDLE Shell window to confirm what random.shuffle does and what its return value is (warning: trick!).
This is the new Imports section:
#### Imports Section
import random
The new Function section looks like this:
#### Function Section
def make_question_list(lower=LOWER, upper=UPPER, random_order=True):
""" prepare a list of questions in the form (x,y)
where x and y are in the range from LOWER to UPPER inclusive
If random_order is true, rearrange the questions in a random order
"""
spam = [(x+1, y+1) for x in range(lower-1,upper)
for y in range(lower-1,upper)]
if random_order:
random.shuffle(spam)
return spam
This is what you get when you run it:
[(4, 2), (3, 4), (4, 4), (5, 2), (5, 4), (2, 5), (3, 2), (2, 4), (3, 5), (5, 3), (2, 3), (4, 3), (5, 5), (2, 2), (4, 5), (3, 3)]
[(5, 6), (4, 5), (6, 5), (5, 4), (6, 4), (5, 5), (4, 4), (4, 6), (6, 6)]
[(11, 7), (8, 11), (9, 8), (11, 10), (9, 7), (7, 7), (10, 8), (9, 10), (8, 10), (10, 10), (9, 11), (7, 10), (10, 7), (7, 11), (8, 9), (11, 8), (11, 11), (8, 7), (10, 11), (9, 9), (7, 8), (10, 9), (11, 9), (7, 9), (8, 8)]
Your shuffled list will look different because it’s, yanno, random. Compare these to the unshuffled versions. Just as an example, the first unshuffled list ought to start with (2,2).
You’ve added a new variable, random_order, which can be True or False. It defaults to True, so you’ve only tested one option so far. You should also test what happens when random_order is False. (The function should return unshuffled lists like before you added the variable.) To do that, change the code so it passes False into the function (question_list = make_question_list(lower, upper, False)) and check that it returns an unshuffled list.
It’s pretty easy to pose multiple questions — put them in a loop. When you ask multiple questions, though, the next logical thing to do is to keep track of the score.
To ask multiple questions and keep track of the score:
Create a constant for the total number of questions to be asked (MAX_QUESTIONS = 3). Add it to the Constants section.
Use a small number so you don’t have too many numbers to test against. I chose 3.
In that function, create a variable to hold the user’s score. Initialize to 0.
Initializing is when you assign a value to a variable for the first time.
for i, question in enumerate(question_list):
For each iteration of the loop, enumerate(question_list) returns a number (i in this case) and a question (a two-tuple) from the list.
The number i is how far through the list you are (starting at 0).
Each iteration tests to see if i is larger than or equal to the MAX_QUESTIONS constant. If it is, you’ve asked enough questions so use break to get out of the loop.
Go back to the code in the Testing section that you commented out in Step 6 of the “Create Questions” section earlier in this project.
Uncomment it, apart from the line question=TEST_QUESTION — you don’t need that. Move it to the end of the do_testing function and then indent it one level to make it part of the for loop’s code block.
The new Constants section looks like this:
#### Constants Section
TEST_QUESTION = (4, 6)
QUESTION_TEMPLATE = "What is %sx%s? "
LOWER = 1
UPPER = 12
MAX_QUESTIONS = 3 # for testing, you can increase it later
The new do_testing function is mostly recycled code:
def do_testing():
""" conduct a round of testing """
question_list = make_question_list()
score = 0
for i, question in enumerate(question_list):
if i >= MAX_QUESTIONS:
break
prompt = QUESTION_TEMPLATE%question
correct_answer = question[0]*question[1]
# indexes start from 0
answer = raw_input(prompt)
if int(answer) == correct_answer:
print("Correct!")
score = score+1
else:
print("Incorrect, should have been %s"%(correct_answer))
print("You scored %s"%score)
Now enumerate will stop of its own accord at the end of the list, regardless of how big MAX_QUESTIONS is. Added bonus: enumerate is the more Pythonic way to do it.
And in the new Testing section, I deleted everything that wasn’t reused:
#### Testing Section
do_testing()
Running this code gives you this:
What is 7x6? 42
Correct!
What is 11x12? 132
Correct!
What is 6x7? 24
Incorrect, should have been 42
You scored 2
Everything is working as it should be.
Printing out a times table for a given number isn’t too hard. Given a value like (4,6), you already know how to calculate the answer and print it in a formatted way. You shouldn’t have any trouble putting together a format string to print each entry of the times table.
What is a little tricky is how you want it to look. Should it be one column with each problem on a separate line? That wastes a lot of screen space. Should you have multiple columns? You don’t know how wide the screen is, and how many columns you can use, until you get to the end of the line. You have to decide all these interface design questions.
Start printing out the whole times table:
TIMES_TABLE_ENTRY = "%s x %s = %s"
You’re going to accumulate these entries and then print them all at once.
Create a function called display_times_tables, in the Functions section, that takes one argument upper.
Make upper default to UPPER, the constant you defined earlier. UPPER equals 12:
def display_times_tables(upper=UPPER):
The argument upper is the largest times in the table. Change UPPER to get larger tables if you want them, but know this — they’ll get big quickly.
In the function, create two for loops, one inside the other.
Each loop should range up to the local variable upper. (Note the lowercase upper.) Call the dummy variables x and y. They’re going to be the two numbers being multiplied together.
for x in range(upper):
for y in range(upper):
Inside the for y loop, use the template string to create a string to print out the number, the index, and the product of the two of them. Then print it.
This code will prepare the output. Then you need to print entry:
entry = TIMES_TABLE_ENTRY%(x+1, y+1, (x+1)*(y+1))
This is the template string in the Constants section:
TIMES_TABLE_ENTRY = "%s x %s = %s"
Here’s how the display function looks:
def display_times_tables(upper=UPPER):
"""
Display the times tables up to UPPER
"""
for x in range(upper):
for y in range(upper):
entry = TIMES_TABLE_ENTRY%(x+1, y+1, (x+1)*(y+1))
print(entry)
When I first did this function, I forgot to add 1 to y, so the times table started at 1x0 and ended at 1x11.
The Testing section looks like this:
#### Testing Section
#do_testing()
display_times_tables()
Run it to get this:
1 x 1 = 1
1 x 2 = 2
[140 lines omitted]
12 x 11 = 132
12 x 12 = 144
It’s working right, but the alignment is a little wonky. Double digits throw things off; triple-digit numbers will, too.
On the left, make a width of 2 (since the largest number is 12, which has two digits) and on the right side of 3 (since the largest number will be 144). This is called left padding.
Here’s a tidy format string:
TIMES_TABLE_ENTRY = "%2i x %2i = %3i"
If you make this change and rerun the program, you get this:
1 x 1 = 1
1 x 2 = 2
[140 lines omitted]
12 x 11 = 132
12 x 12 = 144
Hey, that’s much neater! All the numbers are lined up on the right. This code won’t fail if you specify a large number (like 1000) for the times table, but the output won’t be neatly aligned.
The times tables are too far down the screen. A lot of the horizontal space on the screen isn’t being used. This’ll be better if tables are horizontal. To do that, you need to know how wide each entry is.
The len() built-in gives you the length of an object like a string or a list.
In the IDLE Shell window, make a sample entry and get its length. Like this:
>>> TIMES_TABLE_ENTRY = "%2i x %2i = %3i"
>>> entry = TIMES_TABLE_ENTRY%(12,12,144)
>>> len(entry)
13
This means that there are 13 characters in 12 x 12 = 144. Adding a space to separate them horizontally makes 14. Programs tend to assume that a screen is about 70 characters wide. You’ll use this as your benchmark. 70 divided by 14 is 5, so you can fit five times tables across the width of the screen.
To print a more compact series of times tables, you have to replace the display_times_tables function with code that does the following:
TIMES_TABLE_ENTRY = "%2i x %2i = %3i ".
tables_to_print = range(1, upper+1)
batch = tables_to_print[:5]
tables_to_print = tables_to_print[5:]
for x in range(1, upper+1):
In this for x loop, create an empty list to hold one line of times tables entries: accumulator = [].
You make this assignment here so that it resets each time you go through the for x loop.
accumulator.append(TIMES_TABLE_ENTRY%(y, x, x*y))
Slice off another batch and shrink tables_to_print.
This is the same code from Step 4 (but indented).
The final code has one space added at the end of TIMES_TABLE_ENTRY in the Constants section:
TIMES_TABLE_ENTRY = "%2i x %2i = %3i "
And a revised display_times_tables function:
def display_times_tables(upper=UPPER):
"""
Display the times tables up to UPPER
"""
tables_per_line = 5
tables_to_print = range(1, upper+1)
# get a batch of 5 to print
batch = tables_to_print[:tables_per_line]
# remove them from the list
tables_to_print = tables_to_print[tables_per_line:]
while batch != []: # stop when there's no more to print
for x in range(1, upper+1):
# this goes from 1 to 12 and is the rows
accumulator = []
for y in batch:
# this covers only the tables in the batch
# it builds the columns
accumulator.append(TIMES_TABLE_ENTRY%(y, x, x*y))
print("".join(accumulator)) # print one row
print("
") # vertical separation between blocks of tables.
# now get another batch and repeat.
batch = tables_to_print[:tables_per_line]
tables_to_print = tables_to_print[tables_per_line:]
The while loop splits the tables (1 through 12) into batches of five at a time. The y loop puts together each row for any given x. For the first row, x+1 is 1, and y goes from 1 to 5, so the first row is 1x1, 2x1, up to 5x1. For the second row, x+1 will be 2. The y still goes from 1 to 5, so the second row will be 2x1, 2x2, and on up to 5x2. This goes until all tables are printed.
So far in this project, you’ve been working on back-end functionality — things that go on behind the user interface. While users have a little bit of interaction, they can’t control what the program does. For example, they have no instructions and can’t choose testing over training.
Because you only have text to work with, your user interface will be pretty simple. That said, not having to worry about beauty helps you focus on how the program will go from the time the user runs it.
Your math trainer’s user interface should:
The good news is most of this is already done! Now start on these by laying down a skeleton of the code:
In the Constants section, create a constant called INSTRUCTIONS. Assign to this constant some introductory text and some instructions.
When putting together this string, imagine that it’s a docstring. Use triple double quotes to start it, and let it go over multiple lines.
Don’t worry if you come up blank. If you’re having trouble, leave INSTRUCTIONS empty, do the other steps, and then come back to it. Maybe something like:
INSTRUCTIONS = """Welcome to Math Trainer
This application will train you on your times tables.
It can either print one or more of the tables for you
so that you can revise (training) or it can test
your times tables.
"""
In that block, print the instructions.
Then use "Press 1 for training. Press 2 for testing. Press 3 to quit" as a prompt for a raw_input. Store the value you get from raw_input in a variable called selection.
The quit function is in the next section. To end this program you’ll need to use Ctrl+C.
Test the value that the user gives you. Use the strip method on selection: selection = selection.strip().
This is a tiny bit of data cleaning. It removes blank spaces from the start and end of a string.
If selection isn’t "1" or "2" or "3", (they’re strings, remember?), then create a message asking the user to choose again.
This process should loop until the user chooses one of those three options.
Create one function stub for do_quit().
You already have two functions that you can use for these options: do_testing and display_times_tables for testing and training.
Create a docstring for it.
Include a print statement identifying the function. It’s useful for testing.
The Constants section has changed by adding this constant:
INSTRUCTIONS = """Welcome to Math Trainer
This application will train you on your times tables.
It can either print one or more of the tables for you
so that you can revise (training) or it can test
your times tables.
"""
This is the stub added to the Functions section:
def do_quit():
""" quit the application"""
print("In quit")
The Testing section is now entirely commented out. The following was added as a new Main section:
#### Main Section
if __name__ == "__main__":
while True:
print(INSTRUCTIONS)
raw_input_prompt = "Press: 1 for training,"+
" 2 for testing, 3 to quit.
"
selection = raw_input(raw_input_prompt)
selection = selection.strip()
while selection not in ["1", "2", "3"]:
selection = raw_input("Please type either 1, 2 or 3: ")
selection = selection.strip()
if selection == "1":
display_times_tables()
elif selection == "2":
do_testing()
else: # has to be 1, 2 or 3 so must be 3 (quit)
do_quit()
Run through this four times to make sure things are working properly. Why four? Once to check that each of the three functions is called when the option is chosen and once to test the behavior when you make an invalid choice.
Now you need to get the quit feature working. Shouldn’t be hard though, because you’ve dealt with that before in Project 5, where you improved your guessing game.
In the Imports section, add import sys.
You’ll use this for its sys.exit() function, which will cause the program to end.
In the do_quit function, call confirm_quit().
If the quit is confirmed, call sys.exit(). Otherwise, do nothing. The function will return and continue from where it was called.
This is the new Imports section:
#### Imports Section
import random
import sys
Awesome, huh? This constant had to be added to the Constants section. It came across from Project 5 with confirm_quit:
CONFIRM_QUIT_MESSAGE = 'Are you sure you want to quit (Y/n)? '
Just add this to the end of the constants that are already there.
The confirm_quit function was copied from Project 5:
def do_quit():
""" quit the application"""
if confirm_quit():
sys.exit()
print("In quit (not quitting, returning)")
def confirm_quit():
"""Ask user to confirm that they want to quit
default to yes
Return True (yes, quit) or False (no, don't quit) """
spam = raw_input(CONFIRM_QUIT_MESSAGE)
if spam == 'n':
return False
else:
return True
Keep the print statement in do_quit because it’s useful for testing. (It only prints if you choose to quit, then change your mind.) The call to sys.exit() makes the application exit.
If you run the code from IDLE (like you do with all of the other code in the book), you might get a message like this:
Traceback (most recent call last):
File "/data-current/dummies book/code folder/math_trainer_6.py", line 122, in <module>
do_quit()
File "/data-current/dummies book/code folder/math_trainer_6.py", line 68, in do_quit
sys.exit()
SystemExit
It happens because of the way IDLE integrates running scripts while making the shell available. If you run this from outside IDLE, Python won’t complain.
Tie up a couple of loose ends:
It’s downhill all the way from here.
To time a round of questions, use the time module. Here’s your 20-second introduction to it:
>>> import time
>>> time.time() # current time
1433075973.088198
Don’t believe that’s the current time? Check the docs: It’s the current time in seconds since the Epoch. It’s the exact time, down to one-millionth of a second. Don’t ask why. It’s just another one of those historical accidents that infest everything that has to do with time calculations.
You can use the time module to get the difference in the times when two things happen, like this:
>>> t1 = time.time() # current time
>>> t2 = time.time()# current time again (I waited a smidge)
>>> t2-t1 # number of seconds between first and second calls
5.041269063949585
Have a look at time.ctime and time.gmtime sometime.
Now work on the timing stuff:
start_time = time.time()
Print the time taken, along with the score.
Also print out what percentage of the questions was correct (score divided by total questions multiplied by 100). You can use the following as your template (add it to the Constants section). The doubled %% sign will print as a single %. The escape code %.1f says it’s a floating point number with one decimal place:
SCORE_TEMPLATE = "You scored %s (%i%%) in %.1f seconds"
The new Imports section looks like this:
#### Imports Section
import random
import sys
import time
There’s a new constant in the Constants section:
SCORE_TEMPLATE = "You scored %s (%i%%) in %.1f seconds"
The reworked do_testing function looks like this now:
def do_testing():
""" conduct a round of testing """
question_list = make_question_list()
score = 0
start_time = time.time()
for i, question in enumerate(question_list):
if i >= MAX_QUESTIONS:
break
prompt = QUESTION_TEMPLATE%question
correct_answer = question[0]*question[1]
# indexes start from 0
answer = raw_input(prompt)
if int(answer) == correct_answer:
print("Correct!")
score = score+1
else:
print("Incorrect, should have been %s"%(correct_answer))
end_time = time.time()
time_taken = end_time-start_time
percent_correct = int(score/float(MAX_QUESTIONS)*100)
print(SCORE_TEMPLATE%(score, percent_correct, time_taken))
Time to loop in the main application, the number of questions per test, and so on. You can do this! This stuff hardly even deserves its own section.
In the Constants section, amp up MAX_QUESTIONS to something like 10 or 20 (like MAX_QUESTIONS = 10).
This will change the number of questions that will be asked in each round of do_testing. Don’t make it too many, though.
The print(INSTRUCTIONS) code is within the loop. You could leave it out of the loop and add another option “4” to print the instructions again if you like.
While you were making your math trainer, you did this, too: