Let’s Update My Magic The Gathering Cube Generation Program, Part Two

Tue Dec 30 2025

Last Time…

If you’re just joining us, you can read the first part of the blog here. In short, we added added some features that had to do with the card quantity the program would write, and we began adding feature flags.

This time around we want to:

  • Add two more feature flags
    1. Allowing the user to pass in a desired quantity to use across the csv
    2. Exclude specific cards.
  • Add some exception handling
  • Write some tests.

Feature Flags

Desired Qty

There may be instances where a user wants to define a default quantity across the set of which they’re generating a csv. We will allow them to define that quantity by adding a -cq flag with a single integer argument.

Notably, this argument will not work with the -r flag, as the are both trying to define quantity. Because of this fact, the first thing I am going to do is add a #TODO: Comment in cube_driver.py to remind me of this later on. It will be right under the rarity conditional on line 141.

cube_driver.py
#TODO: handle exception if -r and -cq both in flags

Let’s move over to cli_util.py . We’re going to continue working on the set_options() function. It currently looks like:

cli_util.py
def set_options(self, options:list)-> None:
    for option in options:
        if option == '-r':
            self.options.append('-r')
        if option == '-cq':
            ## validate flag parameter
            pass
        if option == '-e':
            ## validate flag parameter
            pass

We’re going to modify the second conditional that says if option == ‘-cq’: do something. First, we’re going to pretty much do the same operation we did on the -r flag, and just add -cq to the internal options list.

cli_util.py
if option == '-cq':
    self.options.append('-cq')

Now, since the parameter we pass into this function is a list type, we know that invariably the index of the next argument will be the one after -cq’s index. So, we’ll add this line to the operation:

cli_util.py
self.options.append(options[options.index('-cq')+1])

There will be cases of bad input, where a user doesn’t pass the required argument or uses the wrong type, but we’ll handle that in the exception part.

Moving back over to cube_driver.py we’ll go to work on the write_csv() function below our #TODO: comment and add the control statement:

cube_driver.py
if '-cq' in flags:

The argument (flags) we pass into the write_csv() function appears as a tuple, so before anything, we need to convert that to a list.

The next line we’ll add is this:

cube_driver.py
flag_list = list(flags)

Now, we’ll apply a combination of the logic we used above with the operation we used in on the ‘desired_quantity’ entry in the ['row_object'] we used for -r.

cube_driver.py
row_obj['desired_qty'] = flag_list[flag_list.index('-cq')+1]

Not exactly the nicest statement, but easy enough to understand. Okay, so this should be good to go (barring the exception handling) now!

Let’s give it a whirl.

With no arguments, this what our program outputs:

Let’s declare a quantity of 2.

source venv/bin/activate
python3 app.py ons -cq 2
>>> gathering cards in set: ons
>>> writing to ./out/ons_cards.csv
>>> complete!

Cool! Okay, now let’s test another number just to make sure. How about…1000 copies of each card.

python3 app.py ons -cq 1000
>>> gathering cards in set: ons
>>> writing to ./out/ons_cards.csv
>>> complete!

Wowza, that’s a lot of cardboard!!

Okay, great - I am happy with this. Let’s move on to the next flag.

Exclude An Arbitrary Number of Cards By Collector Number

Next, we want to exclude certain cards (an arbitrary amount) based on their collector number. If you are unfamiliar with magic, the collector number is the identifying number on the bottom of a card. For instance, on Akroma’s Vengeance, the collector number is 2.

God, what crazy ass artwork. 😤 The collector number is reflected in our csv too, and in the row_object a[‘collector_num’] .

This flag, as opposed to -r and -cq, will be allowed to be used in conjunction with either of the other flags. We need to keep this mind, because the user conceptually could pass the -e flag ahead of the -r flag, and since there’s an arbitrary number of collector numbers allowed, we will have to essentially loop through the arguments until we either hit the end of the array or the another flag.

I think I am going to add a helper function or two in the flags module to assist us with this.

The first one will be flag_is_integer. I think this one will help use beyond just this flag too.

In flags.py :

flags.py
   def is_integer(self, flag:any)->bool:
       if flag is int:
           return True
       return False

I can’t remember what the other helper I was going to put here was…oh well, maybe it will come back to me. (A note from the future: it did not come back to me)

Next, we’ll hop over to cli_util.py and fill out the last part of the set_option() function:

cli_util.py
def set_options(self, options:list)-> None:
    for option in options:
        if option == '-r':
            self.options.append('-r')
        if option == '-cq':
            self.options.append('-cq')
            self.options.append(options[options.index('-cq')+1])
        if option == '-e':
            e_index = options.index('-e')
            for i, option in enumerate(options):
                if option in ('-r', '-cq'):
                    break
                #only append the number once
                if option not in self.options and i >= e_index:
                    self.options.append(option)

First off, we’re going to set the index of the -e flag and we’ll need that in a minute. Next we’re going to use the enumerate function in python to iterate through the list of flags. If the flag is -r or -cq we’ll break the loop. Otherwise we’ll see if the option is already is already in the options flag. If it is not, and i is equal to or greater than the e_index we will append it to our options flag.

We’re going to slightly reconfigure the entire function, so that it will write the csv the way we want it to, using conditionals. Back to cube_driver.py.

cube_driver.py
def write_csv(self, *flags)-> None:
    """
    builds and outputs a .csv file
    """
    with open(f'./out/{self.set_code.lower()}_cards.csv', 'w', newline='') as csvfile:
        fieldnames = [
            'card_name',
            'collector_num',
            'colors', 'rarity',
            'desired_qty',
            'owned',
            'qty_owned']
        writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
        print(flags)
        writer.writeheader()
        for card in self.cards:
            row_obj ={
                'card_name': card["card_name"],
                'collector_num': card["collector_num"],
                'colors' : card["colors"],
                'rarity': card["rarity"],
                'desired_qty' : 1,
                'owned': 'N', #defaults to no
                'qty_owned': 0,
            }
            if '-r' in flags:
                    row_obj['desired_qty'] = self.calc_desired_qty(card["rarity"])
            #TODO: handle exception if -r and -cq both in flags
            if '-cq' in flags:
                flag_list = list(flags)
                row_obj['desired_qty'] = flag_list[flag_list.index('-cq')+1]
            if '-e' in flags:
                flag_list = list(flags)
                if card["collector_num"] not in flag_list:
                    writer.writerow(row_obj)
            else:
                writer.writerow(row_obj)

So, what’s going on here is if the card collector number is not in the flag list, we will write the object. That way, we can keep the card quantity customizations we set just above. Otherwise, we write the row with everything as is.

Lets test it.

python3 app.py ons -e 100 112 -r
>>> gathering cards in set: ons
>>> writing to ./out/ons_cards.csv
>>> ('-e', '100', '112', '-r')
>>> complete!

And we’ll check the output….

Nice!

Okay, I think we’re all set. Let’s move to the new exceptions and the tests!

Exceptions

Removing Exceptions

To start, we’ll remove all exceptions we no longer need. Firstly in app.py: We’ll get rid of the TooManyArgumentsError from the body and the imports.

The code will now look as follows:

app.py
import sys
from utils import cube_driver, cli_util
from utils.exceptions import TooFewArgumentsError
cli = cli_util.CLIUtil()


try:
   args = sys.argv
   SET_CODE = ''
  
   if cli.validate_num_args(args) is True:
       SET_CODE = args[1]
       if cli.has_flags(args) is True:
           cli.set_options(args)
   else:
       if len(args) < 2:
           raise TooFewArgumentsError
except TooFewArgumentsError as e:
   print(e)




if cli.validate_set_code(SET_CODE) is True:
   driver = cube_driver.CubeDriver(SET_CODE)
   print(f'gathering cards in set: {driver.set_code}')
   driver.add_all_cards()
   print(f'writing to ./out/{driver.set_code}_cards.csv')
   if len(cli.get_options())==0:
       driver.write_csv()
   if len(cli.get_options())>=1:
       flags=cli.get_options()
       driver.write_csv(*flags)
   print("complete!")

That’s actually the only exception we’ll need to remove. We’ll also remove it from the exceptions.py module.

exceptions.py
class TooFewArgumentsError(Exception):
   def __init__(self):
       self.msg = """
       Error! You didn't pass enough arguments.
       You'll need a set code. Try `python3 app.py ONS`"""
       super().__init__(self.msg)


   def __str__(self):
       return f'{self.msg}'


class SetCodeLengthError(Exception):
   def __init__(self,):
       self.msg = "Error! Set Code Must Be Exaclty 3 Characters ie SCG "
       super().__init__()


   def __str__(self):
       return f'{self.msg}'


class LookupSetError(Exception):
   def __init__(self):
       self.msg = "Error! Could not locate set on scryfall"
       super().__init__()


   def __str__(self):
       return f'{self.msg}'

New Exceptions

The main exception I want to add is the handling of the two conflicting flags -r and -cq

In exception.py lets write a new exception:

exception.py
class ConflictingFlagsError(Exception):
   def __init__(self):
       self.msg = "Error! You cannot use -r and -cq in the same command. Please only use one of those flags"
       super().__init__()
  
   def __str__(self):
       return f'{self.msg}'

Breaking this down: We declare a new exception class called ConflictingFlagsError, and this is extending the built-in Exception class.

In the init function, we’re setting the message, and then passing the super function to initialize our base class (the Exception). Then, we write the __str__ function, which returns the message we initialized in the first function.

We’ll save that, and go to the app.py file. We’ll add the ConflictingFlags error to the exception block on line 3.

Above the first if state on line 9, we’ll add the following code:

app.py
   if '-cq' and '-r' in args:
       raise ConflictingFlagsError

Then, on line 21 below the TwoFewArgumentsErrow we’ll add :

app.py
except ConflictingFlagsError as e:
   print(e)
   sys.exit()

sys.exit() closes the program upon invocation, which works for us in this case.

The whole of that block in app.py will look like:

app.py
if cli.validate_set_code(SET_CODE) is True:
   driver = cube_driver.CubeDriver(SET_CODE)
   print(f'gathering cards in set: {driver.set_code}')
   driver.add_all_cards()
   print(f'writing to ./out/{driver.set_code}_cards.csv')
   if len(cli.get_options())==0:
       driver.write_csv()
   if len(cli.get_options())>=1:
       flags=cli.get_options()
       driver.write_csv(*flags)
   print("complete!")

Voila! Okay, now we’ll add another exception for when a user does not pass an argument after -cq.

Back in exception.py I wrote the following exception:

exception.py
class NoCustomQuantityError(Exception):
   def __init__(self):
       self.msg = "Error! You did not pass a quantity after the -cq flag. Try something like `python3 app.py LGN -cq 4`"
       super().__init__()
  
   def __str__(self):
       return f'{self.msg}'

And then, I’ll add this in cli_util.py. First, I’ll import the exception and the sys module.

cli_util.py
import sys
from utils.exceptions import LookupSetError, SetCodeLengthError, NoCustomQuantityError

Then, I’m going to wrap the set_options function body in a try block so we can raise the exception when it occurs.

We’re going to add the conditional if statement (of which there are two) so the whole function will look like this:

cli_util.py
def set_options(self, options:list)-> None:
       try:
           for option in options:
               if option == '-r':
                   self.options.append('-r')
               if option == '-cq':
                   self.options.append('-cq')
                   if options[-1]=='-cq' or options[options.index('-cq')+1]== '-e':
                       raise NoCustomQuantityError
                   self.options.append(options[options.index('-cq')+1])
               if option == '-e':
                   e_index = options.index('-e')
                   for i, option in enumerate(options):
                       if option in ('-r', '-cq'):
                           break
                       #only append the number once
                       if option not in self.options and i >= e_index:
                           self.options.append(option)
       except NoCustomQuantityError as e:
           print(e)
           sys.exit()

Tests

Lastly, we’ll do the tests. This entire blog is taking me way longer than I anticipated so I’m not going to go crazy here. I’m really just going to test the argument validation because this has gotten too long.

So first in tests.py I’ll remove this line, which is no longer relevant under the test_bad_arguments function in the TestCLiUtil class

Then, I’ll add a function in the cli_util.py to validate the arguments:

tests.py
def validate_args(self, args:list)->bool:
    if args[2] not in ['-r', '-cq', '-e']:
        return False
    if args in ['-r', '-cq']:
        return False
    if args[2] == '-cq':
        if args[3] is None:
            return False
        if args[3]:
            reg = re.findall(r"\d", args[3])
            if len(reg) == 0:
                return False
    if args[2] == '-r':
        if len(args) >= 4:
            if args[3] is not None:
                return False
    if args[2] == '-e':
        if len(args) < 4:
            return False
        if args[3] == '-cq' or args[3] == '-r':
            return False
        if len(args) >=4:
            reg = re.findall(r"\d", args[3])
            if len(reg) == 0:
                return False               
    return True

I’m using the re module to look for digits where appropriate.

Then back we go to the tests.py and will add the following tests in the TestCliUtil Class:

tests.py
   def test_good_arguments_cq(self):
       cli = cli_util.CLIUtil()
       self.assertTrue(cli.validate_args(["app.py", "ons", "-cq", "123"]))
      
   def test_good_arguments_r(self):
       cli = cli_util.CLIUtil()
       self.assertTrue(cli.validate_args(["app.py", "ons", "-r"]))   
  
   def test_good_arguments_e(self):
       cli = cli_util.CLIUtil()
       self.assertTrue(cli.validate_args(["app.py", "ons", "-e", "123"]))   
   def test_bad_arguments_cq(self):
       cli = cli_util.CLIUtil()
       self.assertFalse( cli.validate_args(["app.py", "ons", "-cq", "abc"]))
       self.assertFalse( cli.validate_args(["app.py", "ons", "-cq", "-r"]))
  
   def test_bad_arguments_r(self):
       cli = cli_util.CLIUtil()
       self.assertFalse( cli.validate_args(["app.py", "ons", "-r", "123"]))
       self.assertFalse( cli.validate_args(["app.py", "ons", "-r", "abc"]))
  
   def test_bad_arguments_e (self):
       cli = cli_util.CLIUtil()
       self.assertFalse( cli.validate_args(["app.py", "ons", "-e", "abv"]))
       self.assertFalse( cli.validate_args(["app.py", "ons", "-e"]))

Now, we’ll test it by running the following command (inside of our virtual environment, naturally.)

python3 tests.py

And we’ll see the following output

>>> ....Error! Set Code Must Be Exaclty 3 Characters ie SCG 
>>> ..............()
>>> {'card_name': "Akroma's Blessing", 'collector_num': '1', 'colors': "['W']", 'rarity': 'uncommon', 'desired_qty': '1', 'owned': 'N', 'qty_owned': '0'}
>>> {'card_name': "Akroma's Vengeance", 'collector_num': '2', 'colors': "['W']", 'rarity': 'rare', 'desired_qty': '1', 'owned': 'N', 'qty_owned': '0'}
>>> {'card_name': "Ancestor's Prophet", 'collector_num': '3', 'colors': "['W']", 'rarity': 'rare', 'desired_qty': '1', 'owned': 'N', 'qty_owned': '0'}
>>> {'card_name': 'Astral Slide', 'collector_num': '4', 'colors': "['W']", 'rarity': 'uncommon', 'desired_qty': '1', 'owned': 'N', 'qty_owned': '0'}
>>> {'card_name': 'Aura Extraction', 'collector_num': '5', 'colors': "['W']", 'rarity': 'uncommon', 'desired_qty': '1', 'owned': 'N', 'qty_owned': '0'}
>>> ..
>>> ----------------------------------------------------------------------
>>> Ran 20 tests in 1.801s
>>> OK

Awesome! We’re good to go.

Conclusion

Okay, finally, we are done! Wait…no we’re not. 🤪 I have to add documentation and type hints to all my functions! I”m not going to go over that here, but they will be in the release. Thanks for reading this tome, and maybe I’ll add more tests in the future (probably not).

You can find the new release here.

Until next time! 👋

All Rights Reserved © 2026