Advanced collections

Day 24 Project: Dice Roller

Welcome to the day 24 project in the 30 Days of Python series! In this project we're going to be using a module in the standard library called argparse to take in configuration from a user before the program even runs.

The program we're going to be writing to demonstrate this is a command line dice roller that can simulate the rolls of various configurations of dice.

Before we can really do this, we need to learn a little bit about what repl.it does when we press the "Run" button, and we need to learn a little bit about the argparse module itself.

Also, we've got a video walkthrough of this entire blog post available!

Running Python code

Those of you who have been working in local development environments may have already learnt how to run a Python program yourselves, but for the rest of us, this step has been hidden away behind repl.it's "Run" button.

So, what actually happens when we press this button?

When we press the button, repl.it runs a command which looks like this:

python main.py

This is the reason that repl.it always runs main.py. It runs main.py because this is the file it specifies as part of the default run command.

We can actually configure repl.it to use a different run command if we want to, and we do this by creating a special file in the repl called .replit. This file is written in a format called TOML (which stands for Tom's Obvious, Minimal Language), which is a common format for configuration files, since it's so easy to write and read.

Let's have a go at changing the run command, since this is something we're going to be doing a fair bit in this project.

First, create a file called app.py and put some code in there so that you can verify when it runs. Something like this would do:

app.py
print("Hello from app.py")

Now create a file called .replit and put the following code inside:

run = "python app.py"

Now press the "Run" button. If everything worked, your app.py should have run instead of main.py.

Running a program with flags and arguments

One thing we can do with many real world console applications is run them with flags and arguments. These flags are used to configure how the program is run, either by turning on certain settings, or by providing values for various options.

For example, if we wanted to run our app.py file with the help flag, we could do something like this:

python app.py --help

This --help flag is generally used to find out information regarding how to use the program.

However, at the moment we can't use this flag. Our program has no idea what it means. This is where argparse comes in: it let's us specify which flags and arguments we're going to accept, and it gives us a way to access the values the user specified when calling our application.

A quick look at argparse

Here we're going to create a small program that returns a number raised to a certain power to learn about some argparse concepts.

Creating a parser

In order to use argparse we first need to import the module and create an ArgumentParser like this:

app.py
import argparse

parser = argparse.ArgumentParser()

If we like, we can specify a description for our program by passing in a value when creating this ArgumentParser.

app.py
parser = argparse.ArgumentParser(description="Returns a number raised to a specified power.")

Specifying positional arguments

Now that we have this parser, it's time to start specifying arguments. To start with, let's make it so that we can accept a positional argument when the user calls our application.

To accept this argument, we need to write the following:

app.py
import argparse

parser = argparse.ArgumentParser(description="Returns a number raised to a specified power.")
parser.add_argument("base", help="A number to raise to the specified power")

Here we've called the add_argument method on our parser, passing in two values.

The first, "base" is the name of the parameter which is going to accept the argument from the user. We're going to use this name to get hold of the value later on.

The second value we specified using a keyword argument, and it's going to be used by argparse to create documentation for our program.

In order to process the values the user passes in, we need one more thing: we need to parse the arguments the user passed in.

app.py
import argparse

parser = argparse.ArgumentParser(description="Returns a number raised to a specified power.")
parser.add_argument("base", help="A number to raise to the specified power")

args = parser.parse_args()

print(args.base)  # access the value of the base argument

Now we can change our .replit file to something like this:

run = "python app.py --help"

Just make sure to change the file name to wherever you wrote all of your code. If everything worked, you should be able to press the run button and get output like this:

usage: app.py [-h] base

Returns a number raised to a specified power.

positional arguments:
  base        A number to raise to the specified power

optional arguments:
  -h, --help  show this help message and exit

This is the documentation that argparse created for us.

Now let's change the .replit file to something like this instead:

run = "python app.py 23"

Now we should get the number 23 printed to the console, as we specified in our file.

Specifying optional arguments

Now let's take a quick look at optional arguments, which are passed in using flags. We create these in just the same way, but we use a -- in front of the name.

We're going to specify an exponent using an optional argument, and we're going to set a default value of 2 if the user doesn't provide a value.

app.py
import argparse

parser = argparse.ArgumentParser(description="Returns a number raised to a specified power.")

parser.add_argument("base", help="A number to raise to the specified power")
parser.add_argument("--exponent", help="A power to raise the provided base to")

args = parser.parse_args()

print(args.base)
print(args.exponent)

You may have noticed from the help output, that --help has a shortcut form: -h. We can do the same thing for our optional arguments by providing a second name with a single -.

app.py
import argparse

parser = argparse.ArgumentParser(description="Returns a number raised to a specified power.")

parser.add_argument("base", help="A number to raise to the specified power")
parser.add_argument("-e", "--exponent", help="A power to raise the provided base to")

args = parser.parse_args()

print(args.base)
print(args.exponent)

There are a couple of final things we can do to improve our program. First, we should set a default value for exponent, and we should specify the types we expect for each value.

app.py
import argparse

parser = argparse.ArgumentParser(description="Returns a number raised to a specified power.")

parser.add_argument("base", type=float, help="A number to raise to the specified power")
parser.add_argument(
    "-e",
    "--exponent",
    type=float,
    default=2,
    help="A power to raise the provided base to"
)

args = parser.parse_args()

print(args.base ** args.exponent)

Now we can change our .replit file to something like this:

run = "python app.py 2 -e 5"

And our program outputs 32.0, with is 2⁵.

If you want to look into argparse in more detail, you can find a really good tutorial in the documentation here.

The brief

Now that we've learnt a little bit about argparse we can get to the meat of the project. For this project we're going to be creating a dice roller for n-sided dice.

The user is going to be able to specify a selection of dice using the following syntax, where the number before the d represents the number of dice, and the number after the d represents how many sides those dice have.

python main.py 3d6

In this case, the user has requested three six-sided dice.

Using the random module, we're going to simulate the dice rolls the user requested, and we're going to output some results in the console, like this:

Rolls: 1, 2, 4
Total: 7
Average: 2.33

Here we have the numbers rolled, the sum of the values, and the average of the rolls.

In addition to printing this result to the console, we're also going to keep a permanent log of the rolls in a file called roll_log.txt. The user can specify a different log file if they wish with an option argument called --log.

python main.py 2d10 --log rolls.txt

In addition to specifying a custom log file, the user can specify a number of times to roll the dice set using a --repeat flag.

python main.py 6d4 --repeat 2

Both --repeat and --log should have appropriate documentation, and the user should be able to use -r and -l as short versions of the flags. The user can also use both the --repeat and --log flags if they want to.

Good luck!

Our solution

First things first, let's set up our parser. I'm going to put this in its own parser.py file along with any code that deals with parsing the arguments.

parser.py
import argparse

parser = argparse.ArgumentParser(description="A command line dice roller")

args = parser.parse_args()

We need to register three arguments for our application: one positional and two optional.

The position argument is going to catch the dice configuration that the user specifies using our xdy syntax.

The two optional parameters are going to catch the number of repetitions for the roll, and the place to log the rolls. Both of these arguments are going to need default values.

Let's start with the positional argument, which I'm just going to call dice.

parser.py
import argparse

parser = argparse.ArgumentParser(description="A command line dice roller")

parser.add_argument("dice",  help="A representation of the dice you want to roll")

args = parser.parse_args()

We don't really have to do anything special here. The input is going to be a string, and we don't need to specify any flags or default values. The only thing we need to do is specify some help text for the program documentation.

The two optional arguments are a fair bit more complicated. First let's tackle the --repeat argument.

--repeat should have a default value of 1, because if the user doesn't specify a repeat value, it's probably because they don't want to repeat the roll. It's also important that we make sure we get an integer here, and not a float, or something which can't be represented as an integer. It doesn't make much sense to repeat a roll 2.6 times, for example.

With this in mind, I think a decent implementation for this argument would be something like this:

parser.py
import argparse

parser = argparse.ArgumentParser(description="A command line dice roller")

parser.add_argument("dice",  help="A representation of the dice you want to roll")
parser.add_argument(
    "-r",
    "--repeat",
    metavar="number",
    default=1,
    type=int,
    help="How many times to roll the specifed set of dice"
)

args = parser.parse_args()

One thing that I've added here is a value for the  metavar parameter. This is just going to change what shows up as a placeholder for the value in the program documentation.

Now let's add the --log argument configuration.

In this case we want to specify a default file name for the logs, which can be whatever you want. I'm going to use roll_log.txt.

We also probably want to make sure that the value we get is a string, so I'm going to specify a type of str for this argument.

parser.py
import argparse

parser = argparse.ArgumentParser(description="A command line dice roller")

parser.add_argument("dice",  help="A representation of the dice you want to roll")
parser.add_argument(
    "-r",
    "--repeat",
    metavar="number",
    default=1,
    type=int,
    help="How many times to roll the specifed set of dice"
)
parser.add_argument(
    "-l",
    "--log",
    metavar="path",
    default="roll_log.txt",
    type=str,
    help="A file to use to log the result of the rolls"
)

args = parser.parse_args()

Looking good!

The only thing left to do in this parser.py file is to actually parse the dice specification. Assuming everything is okay with the the user's value, this should be as simple as splitting the string by the character "d" and converting the values in the resulting list to integers.

parser.py
def parse_roll(args):
    quantity, die_size = [int(arg) for arg in args.dice.split("d")]

    return quantity, die_size

However, there's always the change that the user enters and invalid configuration, so we need to do a bit of exception handling.

We're going to catch a ValueError first, which is going to catch cases where the user enters something like fd6, d6, or 6d.

d6 and 6d are going to be caught because if nothing features before or after the "d", "" will be in the list returned by strip. If we try to pass "" to int, we get a ValueError.

parser.py
def parse_roll(args):
    try:
        quantity, die_size = [int(arg) for arg in args.dice.split("d")]
    except ValueError:
        raise ValueError("Invalid dice specification. Rolls must be in the format of 2d6") from None

    return quantity, die_size

In this case we can't really do anything to properly handle the error, since we don't know what the user intended, but I'm raising a new ValueError with a more helpful exception for the user. I've decided to raise using from None so that the user gets a trimmed down version of the traceback.

The ValueError is actually also catching another issue for us as well: having too many values to unpack.

Attempting to do something like the example below results in a ValueError:

x, y = [1, 2, 3]

If we wanted to provide more helpful feedback to the user, we could break this comprehensions up into different steps, but I think this is good enough for our case.

Now let's turn to main.py where we're going to make use of these things we defined in parser.py.

main.py is going to be very short and is really just here to compose the various functions we define in our other files into a useful application.

First, we're going to import parser and random, and we're going to get hold of the args variable we defined in parser.py.

main.py
import parser
import random

args = parser.args

Now that we have hold of this, we can call parser.parse_rolls, passing in this args value. We can also get hold of the specified number of repetitions, and the specified log file, assigning them to nicer names.

main.py
import parser
import random

args = parser.args

quantity, die_size = parser.parse_roll(args)
repetitions = args.repeat
log_file = args.log

Now we have all the information we need, we can start actually simulating the dice rolls. For a single roll, the logic is going to look something like this:

rolls = [random.randint(1, die_size)  for _ in  range(quantity)]
total = sum(rolls)
average = total / len(rolls)

We use a list comprehension to call randint once for each die the user specified. So if we got 3d6, we're going to generate a list of 3 results.

randint chooses a number for us from an inclusive range, so we just need to specify 1 to the size of the die.

Once we have our results stored in rolls, we can calculate the total and average.

All of this logic is going to go in a loop, however, since we may want to perform several repetitions of the roll.

main.py
import parser
import random

args = parser.args

quantity, die_size = parser.parse_roll(args)
repetitions = args.repeat
log_file = args.log

for _ in  range(repetitions):
    rolls = [random.randint(1, die_size)  for _ in  range(quantity)]
    total = sum(rolls)
    average = total / len(rolls)

At the moment we're not really doing anything with any of the results, so let's fix that by writing some functions to take care of the formatting of our results, and the writing to our log file.

I'm going to keep all of this functionality in a third file called output.py. Feel free to name it whatever you like.

The content of this file is very easy to understand, so we can breeze through it.

output.py
roll_template = """Rolls: {}
Total: {}
Average: {}
"""

def format_result(rolls, total, average):
    rolls = ", ".join(str(roll) for roll in rolls)
    return roll_template.format(rolls, total, average)

def log_result(rolls, total, average, log_file):
    with open(log_file, "a")  as log:
        log.write(format_result(rolls, total, average))
        log.write("-" * 30 + "\n")

First I'm defining a template which we can populate with values. Using a multi-line string like this helps avoid lots of "\n" characters.

I've then defined a function called format_results which actually populates this template with values. It also takes care of joining the rolls together so that we don't have any square brackets in our ouput.

The log_results function is entirely concerned with writing to the log file. It takes in a log file as an argument, and it uses a context manager to open this file in append mode. If the file does not exist, this will create it.

After opening the file, log_results then formats the rolls and writes the result to the file, followed by 30 - characters on a new line. This is going to serve as a separator in the file.

With that, we just have to import output in main.py and call our functions.

main.py
import output
import parser
import random

args = parser.args

quantity, die_size = parser.parse_roll(args)
repetitions = args.repeat
log_file = args.log

for _ in  range(repetitions):
    rolls = [random.randint(1, die_size)  for _ in  range(quantity)]
    total = sum(rolls)
    average = total / len(rolls)

    print(output.format_result(rolls, total, average))
    output.log_result(rolls, total, average, log_file)

Now it's time to test our program with various test cases. Here is what things look like for a valid set of arguments:

> python main.py 8d10 -r 3
Rolls: 3, 3, 1, 3, 8, 8, 8, 9
Total: 43
Average: 5.375

Rolls: 10, 7, 5, 6, 10, 2, 3, 6
Total: 49
Average: 6.125

Rolls: 10, 6, 10, 6, 2, 4, 6, 4
Total: 48
Average: 6.0

And here is the content of roll_log.txt:

Rolls: 3, 3, 1, 3, 8, 8, 8, 9
Total: 43
Average: 5.375
------------------------------
Rolls: 10, 7, 5, 6, 10, 2, 3, 6
Total: 49
Average: 6.125
------------------------------
Rolls: 10, 6, 10, 6, 2, 4, 6, 4
Total: 48
Average: 6.0
------------------------------

We can also use the -h or --help flags to see our lovely generated documentation:

usage: main.py [-h] [-r number] [-l path] dice

A command line dice roller

positional arguments:
  dice                  A representation of the dice you want to roll

optional arguments:
  -h, --help            show this help message and exit
  -r number, --repeat number
                        How many times to roll the specifed set of dice
  -l path, --log path   A file to use to log the result of the rolls

Hopefully you were able to tackle this on your own, even if you did it in a very different way to me. We'd love to see some of your solutions over on our Discord server if you feel like sharing!

Additional Resources

If you want to dig into argparse further, then you should definitely check out the tutorial and main documentation page.