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:
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:
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
.
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:
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.
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.
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 -
.
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.
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.
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
.
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:
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.
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
.
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
.
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
.
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.
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.
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.
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.
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.