Learn Python by Doing Projects – Part 8: Exceptions, Argparse, and the Vigenère Cipher

Had WillHad Will
22 min read

< Previous Post

Hello! This post is part of my Learn Python by doing projects tutorial series.

In this Python tutorial, you’ll build a command-line encryption tool using the Vigenère cipher. Along the way, you’ll learn how to handle runtime errors with try/except blocks. You will write more robust Python code using exceptions. You will also use argparse to create CLI subcommands.

If you're new to this series, you can start from the beginning.

Now, on to our program!

What you'll learn today

  • CLI skills: using argparse to create subcommands

  • Error handling & exceptions

    • Using exceptions and try-except blocks to make your program more robust

    • Error messages, the error method, and stderr

    • The difference between exceptions and errors

  • How to write encryption and decryption algorithms from scratch with a few functions

The Vigenère cipher

First of all, a cipher is a fancy word meaning an encryption algorithm.

What's the point of cryptography?

Humans are devious creatures. Often, they don't want the whole world to know about their secrets. Since antiquity, we've tried to hide info from our enemies. The point of cryptography is to take a message and to make it unreadable in a deterministic manner.

The right person (and only the right person) should be able to revert it back to its original state.

Cryptography uses algorithms to change the text and revert it. Through the ages, mathematicians have devised lots of different algorithms.

Vigenère

The Vigenère cipher takes a word or a phrase as a key to change the original text's letters. It is part of the category of 'shift ciphers', or 'substitution ciphers'.

The key is a secret template to decide by how many places to shift each letter. Very primitive ciphers used a fixed number to shift all the message's letters (e.g., the Caesar cipher).

Let's work through an example.

Original message: HELLO
Key : CRYPTO

Let's first convert each letter to a number (e.g., by taking its place in the alphabet, starting at 0).

H, E, L, L, O      -> 7, 4, 11, 11, 14
C, R, Y, P, T, O   -> 2, 17, 24, 15, 19, 14

Then, add the numbers.

H (7)  + C (2)  = 9
E (4)  + R (17) = 21
L (11) + Y (24) = 35
L (11) + P (15) = 26
O (14) + T (19) = 33

Some numbers are higher than 25 (letter Z). Apply a modulo 26 to wrap them around to A.

9             -> J
21            -> V
35 mod 26 = 9 -> J
26 mod 26 = 0 -> A
33 mod 26 = 7 -> H

After encryption, our message is 'JVJAH'.

You see that both 'H' and the first 'L' become 'J'. Different letters can become the same encrypted character. Both 'L's become different characters: 'J' and 'A'. This shows that the Vigenère cipher is not a straightforward substitution algorithm.

What happens if the key is shorter than the message? Then you repeat the key over and over. For example:

Message: SUPERCALIFRAGILISTICEXPIALIDOCIOUS
Key: KEY

You would expand the key like this.

SUPERCALIFRAGILISTICEXPIALIDOCIOUS
KEYKEYKEYKEYKEYKEYKEYKEYKEYKEYKEYK

The Vigenère algorithm was very secure long ago. Nowadays, it's easy for a computer to break it and my next post will cover that. But first, let's learn how to encrypt and decrypt!

The program's architecture

I have decided to divide the program according to functionality:

  • Command line arguments with the argparse module

  • File I/O

  • The Vigenère algorithm proper.

I want to call the program from the command line. I would like to write something like this:

% python3 vigenere.py encrypt mykey origin_file.txt

Or still,

% python3 vigenere.py decrypt mykey crypted_file.txt

We could also store results in a file, like this.

% python3 vigenere.py -o output_file.txt encrypt mykey origin_file.txt

Subcommands with argparse

We will use the argparse module to capture arguments from the command line. If you don't know the basics of using the argparse module or if you want a refresher, check out this post first.

As always, start by creating a parser and add an optional argument for the output file.

parser = argparse.ArgumentParser(description="Vigenere cryptography")

parser.add_argument("-o", "--output",
                    default=None,
                    help="where to write the result")

Now, we have to deal with arguments for encryption, decryption, and cracking. It would be straightforward to add two arguments: a key and an input file. That would make sense, but by definition, cracking doesn't need any key. Enter subcommands: we’ll give encrypt, decrypt and crack each their own parser so that only crack can omit a key.

Create subcommands by calling the add_subparsers method on the main parser.

The result of the add_subparsers method is a special kind of object. It's sole use is that you can attach smaller parsers to it. You use those little subparsers as you would a normal parser. Here's the workflow to using subcommands.

  1. Call the add_subparsers method

  2. Add subparsers using the add_parser method

  3. Add arguments to the subparsers with add_argument

Here's how to add the encrypt command.

subparsers = parser.add_subparsers(dest="command", required=True)

We only call the add_subparsers method once, for all three commands.

To add the encrypt, decrypt, and crack commands, add parsers to the subparsers object.

enc = subparsers.add_parser("encrypt",
                             help="Encrypt a text with a secret key")

enc.add_argument("key", help="secret key to use")

enc.add_argument("origin", nargs="?",
                 default=None,
                 help="file to encrypt")

The add_argument method can take lots of different arguments. Here, "key" and "origin" are the argument names.

  • nargs is an optional keyword argument which tells how many arguments to consume. The "?" means origin takes one argument if possible, zero otherwise. This is logical, since the user either specifies an origin filename, or doesn't.

  • default is an optional keyword argument. It tells what value to use if the user doesn't give a filename.

  • I added a help string, which will end up in the program's help page.

The add_argument method takes many more optional arguments, making it flexible. You can read about them here.

To add other commands, call the add_parser on the subparsers object.

dec = subparsers.add_parser("decrypt",
                            help="Decrypt a text with a secret key")
dec.add_argument("key", help="secret key to use")
dec.add_argument("origin", nargs='?',
                 default=None,
                 help="string to decrypt")

crack = subparsers.add_parser("crack",
                              help="Crack a code without a secret key")
crack.add_argument("origin", nargs='?',
                   default=None,
                   help="string to decrypt")

As you can see, this can get repetitive once you get the hang of it.

Now, call parse_args on the parser object.

args = parser.parse_args()

Automatic help page generation

To recap, we call our program with a choice of three commands:

  • encrypt

  • decrypt

  • crack

One neat feature of argparse is that it can generate help pages without us doing any more work.

Let's assume you called your program's file vigenere.py. You can check out this functionality by typing these commands.

% python3 vigenere.py -h
usage: project8.py [-h] [-o OUTPUT] {encrypt,decrypt,crack} ...

Vigenere cryptography

positional arguments:
  {encrypt,decrypt,crack}
    encrypt             Encrypt a text with a secret key
    decrypt             Decrypt a text with a secret key
    crack               Crack a code without a secret key

options:
  -h, --help            show this help message and exit
  -o OUTPUT, --output OUTPUT
                        where to write the result

Each subcommand has its own -h page.

% python3 viginere.py encrypt -h

In the help pages, square brackets denote optional arguments.

You can add a print expression at the end of your program to check the value of args with different inputs.

print(args)

argparse wrap-up

Let's wrap up the command line code in a function.

def get_args():
    """
    Parse and return command-line arguments for the Vigenere tool.

    Sets up a global -o/--output option and subcommands:
    encrypt, decrypt, and crack, each with their own required
    arguments. Performs key validation for encrypt and decrypt.
    """
    parser = argparse.ArgumentParser(description="Vigenere cryptography")

    parser.add_argument("-o", "--output",
                        default=None,
                        help="where to write the result")

    subparsers = parser.add_subparsers(dest="command", required=True)

    enc = subparsers.add_parser("encrypt",
                                help="Encrypt a text with a secret key")
    enc.add_argument("key", help="secret key to use")
    enc.add_argument("origin", nargs="?",
                     default=None,
                     help="file to encrypt")

    dec = subparsers.add_parser("decrypt",
                                help="Decrypt a text with a secret key")
    dec.add_argument("key", help="secret key to use")
    dec.add_argument("origin", nargs='?',
                     default=None,
                     help="string to decrypt")

    crack = subparsers.add_parser("crack",
                                  help="Crack a code without a secret key")
    crack.add_argument("origin", nargs='?',
                       default=None,
                       help="string to decrypt")

    args = parser.parse_args()

    return args

Robust file input and output (I/O)

Two functions will handle I/O:

  • get_origin will handle the input side. If the user provided a filename, try to read it. Otherwise, ask the user to enter the input by hand.

  • output_results will handle the output. If the user provided a file name, write to it. Otherwise, print the results to the screen.

Here are get_origin and output_results. Read them first and try to understand the code the best you can.

def get_origin(args):
    """
    Load the origin text from a file or prompt the user.

    If args.origin is a filename, attempts to read it; otherwise, falls
    back to input(). Exits with an error message on file I/O failure.
    """
    if args.origin:
        try:
            with open(args.origin) as f:
                origin = f.read()
        except OSError as e:
            sys.exit(f"Error reading {args.origin!r}: {e}")
    else:
        try:
            origin = input("Enter text: ")
        except EOFError as e:
            sys.exit(f"Error reading from stdin: {e}")

    return origin


def output_results(args, results):
    """
    Write results to a file or print them to standard output.

    If args.output is specified, writes results there; otherwise,
    prints to the console. Exits with an error on file I/O failure.
    """
    if args.output:
        try:
            with open(args.output, mode='w') as f:
                f.write(results)
        except OSError as e:
            print(results)
            sys.exit(f"Error writing {args.output!r}: {e}")
    else:
        print(results)

What are Exceptions in Python?

In both functions, I used try/except statements. They are one way Python programmers deal with exceptions.

Exceptions are objects that signal an unexpected problem during program execution. As their name implies, they should be exceptional. Exceptions interrupt the program's flow to allow you to deal with them.

An analogy would be a cake recipe. The recipe controls the 'normal' steps to make a cake. Take the ingredients, measure them, mix them, bake the cake. Now let's say you drop all your eggs mid-recipe and break them. That's not supposed to happen and is an exceptional occurrence. If you want to finish your cake, you'll have to stop what you're doing and go buy some new eggs.

In this analogy, the recipe is the normal program flow. Breaking the eggs is an exception. Buying new eggs then resuming your baking is handling the exception.

Python comes with built-in exceptions. While it is possible to define your own, I won't cover that in this post. Indeed, built-in exceptions cover most of a beginner's needs.

Here are some useful built-in Python exceptions that you can already use.

  • EOFError. The input function for when it hits an end-of-file character (EOF) without reading any data. It means the function has received nothing.

  • IndexError. Use it when your program tries to access a sequence element with an out-of bounds index. For example, if the list my_list has four elements, my_list[10] will give an IndexError.

  • KeyError. Use it when your program tries to access a dictionary's key that doesn't exist.

  • MemoryError. Use this when your program runs out of memory.

  • OSError. Sometimes, exceptions come not from within your program, but from the operating system. For example, if the OS can't open a file for you, or if it doesn't find it.

  • TypeError. This happens when a function receives an object of the wrong type. For example, if you try to add a number and a string, there'll be a TypeError.

  • ValueError. This exceptions happens when a part of your code receives an object of the right type, but of the wrong value.

You can find the official list of built-in exceptions here.

In the code, I envisioned two possible exceptions:

  1. A problem occurring when reading or writing to files: OSError.

  2. A problem occurring when receiving no user input: EOFError.

Python Exception Handling with try/except

One way to use exceptions is to insert them in try/except blocks. They have this basic structure:

try:
    <block of code>
except <exception class> as <exception_name>:
    <handle exception_name>

You read that as "Try to execute this block of code. If it raises this particular class of exceptions, handle it this way."

try:
    with open(args.origin) as f:
        origin = f.read()
except OSError as e:
    sys.exit(f"Error reading {args.origin!r}: {e}")

This piece of code reads like this: "Try to open the origin file and read its contents. If there's a problem caused by the operating system, call it "e" and display it while exiting the program".

At the end of the program's execution, the output_results function mirrors this structure.

try:
    with open(args.output, mode='w') as f:
        f.write(results)
except OSError as e:
    print(results)
    sys.exit(f"Error writing {args.output!r}: {e}")

Back to get_origin, we can now tackle the last bit of exception handling in our program. Try to make sense of it by yourself first.

try:
    origin = input("Enter text: ")
except EOFError as e:
    sys.exit(f"Error reading from stdin: {e}")

This reads like this: "Try to get input from the user. If you get an end-of-file rather than legitimate data, end the program with an error message."

Writing the cryptographic functions

By this point, we still need to write the meat of the program: the actual cryptographic functions.

First, prepare the key to match the origin input's length.

def prepare_key(key, text_len):
    """
    Repeat the key to match the length of the input text.

    Given a key string and the desired text_len, returns a new string
    formed by repeating key as many times as needed and then truncating
    to exactly text_len.

    e.g.: key: "I love Python."
    "This string is the origin text."
    "I love Python.I love Python. I "
    """
    expanded_key = (key * (text_len//len(key) + 1))[:text_len]
    return expanded_key

First, create a longer string than needed (key * (text_len//len(key) + 1)). Then, slice it to the desired length ([:text_len]).

Notice how the * operator repeats a string.

Now onto encrypting and decrypting. The first thing to realize is that those two operations are very similar. Loop through the text, character by character, and apply a shift function.

At this point we need to think about the character set we'll use, because that will condition what we do next. For example, classic Vigenère cipher implementations only encrypt alphabetic characters. Numbers, punctuation and whitespace stay as is. In that case, "Hello, world! 123" would become "Zincs, pgvnu! 123" with the key "secret".

I have decided to use a custom character set, including most useful characters. This will include whitespace. Store it in a ALPHABET constant.

ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890#@&\"\'(!)-_*$%+=:/;.,?<>[]{}\n\t\\ éàçèù^¨`°"

To make my life easier, I create two other constants:

  • the alphabet's length

  • a dictionary of characters and their indexes.

ALPHA_LEN = len(ALPHABET)

INDEX = {c: i for i, c in enumerate(ALPHABET)}

If you forgot about the enumerate function, you can read about it back here or at the official Python docs.

Next, we can tackle looping through a string to shift each character.

def crypt_iter(origin, expanded_key, crypt_fun):
    """
    Apply a shift function to each character in the origin text.

    Iterates over origin, and for each character that exists in the
    ALPHABET, applies crypt_fun to its index and the corresponding
    key character’s index. Characters not in the alphabet are passed through
    unchanged.
    """
    dest = []
    for i, ch in enumerate(origin):
        if ch in INDEX:
            a = INDEX[ch]
            k = INDEX[expanded_key[i]]
            res_num = crypt_fun(a, k)
            res_letter = ALPHABET[res_num]
            dest.append(res_letter)
        else:
            dest.append(ch)

    return ''.join(dest)

The function takes as argument

  • the string to transform

  • an "expanded key", which is a key that went through the prepare_key function

  • and a cryptographic function (how you shift the character). This is the difference between encryption and decryption.

This function

  1. goes through each character in origin

  2. checks if it is part of the ALPHABET

  3. converts it to a number

  4. converts the corresponding key character to a number

  5. calls the cryptographic function

  6. converts the result back to a character

  7. puts everything back into a string.

Here is another version, changing only lowercase letter characters. It would have been simpler. Let's assume we did the necessary adjustments for an all-lowercase origin and key. The key would only contain letters, too.

dest = []

for i, ch in enumerate(origin):
    if ch.isalpha():
        a = ord(ch)
        k = ord(expanded_key[i])
        res_num = crypt_fun(a, k)
        res_letter = chr(res_num)
        dest.append(res_letter)
    else:
        dest.append(ch)
return ''.join(dest)

Can you guess what ord and chr do? Here are links to their official docs: ord, chr.

Now, I write the encryption/decryption at once, in three functions: crypto, encrypt and decrypt.

def crypto(origin, key, crypt_fun):
    """
    Core Vigenère routine: expand key and transform the origin text.

    1. Computes expanded_key to align with origin length.
    2. Calls crypt_iter to perform the per-character shifts.
    """
    text_len = len(origin)

    expanded_key = prepare_key(key, text_len)

    result = crypt_iter(origin,
                        expanded_key,
                        crypt_fun)

    return result


def encrypt(origin, key):
    """
    Encrypt the origin text using the Vigenere cipher.

    Applies a modular addition shift (plaintext + key) over the full
    ALPHABET, returning the resulting ciphertext.
    """
    encrypted = crypto(origin,
                       key,
                       lambda p, k: (p + k) % ALPHA_LEN)

    return encrypted


def decrypt(origin, key):
    """
    Decrypt a Vigenere-encrypted text using the provided key.

    Applies a modular subtraction shift (ciphertext - key) over the full
    ALPHABET, returning the recovered plaintext.
    """
    decrypted = crypto(origin,
                       key,
                       lambda c, k: (c - k + ALPHA_LEN) % ALPHA_LEN)

    return decrypted

The only difference between encrypt and decrypt is one little function

  • encrypt: lambda p, k: (p + k) % ALPHA_LEN)

  • decrypt: lambda c, k: (c - k + ALPHA_LEN) % ALPHA_LEN)

Correcting a bug: Errors

There's a little bug in our code. What if the user enters an empty string as a key? Right now, get_args will populate args.key with an empty string. So far so good, right?

Except the prepare_key function divides by the length of the key: (key * (text_len//len(key) + 1))[:text_len]. And we can't divide by 0!

The user should be able to correct that right at the end of get_args. You can add this snippet of code just before the function returns.

if args.command in ("encrypt", "decrypt"):
    if not args.key.strip():
        parser.error(f"Key cannot be empty or only whitespace.")
    for ch in args.key:
        if ch not in ALPHABET:
            parser.error(f"Invalid character in key: {ch!r}")

There's a new concept here: error messages and the standard error. It appears in parser.error("..."). We could print the error messages using print. This is not common practice for a few reasons.

  • error messages go to stderr (standard error). The print function sends messages to stdout (standard output).

  • Sometimes you want to redirect all program output to a file (in Linux or Mac OS, you would do, e.g., my_program > output.txt). You don't want potential errors polluting the file. Those errors will keep appearing to the screen for debugging.

  • You might want to do the opposite: redirecting errors to a file and printing the normal output to the screen. That's how you log a system's errors.

  • stderr and stdout are separate and you can read them in parallel. For example, a function monitoring errors shouldn't go through all the non-error data.

Errors vs exceptions

This brings a question: what's the difference between errors and exceptions?

After all, built-in exceptions have names like OSError, TypeError, etc.

The official Python documentation is unclear on the distinction between the two.

On one hand, errors are subclasses of the BaseException class. This means that errors are implemented as exceptions.

But the Python docs contradict themselves by stating that exceptions are a type of error. In fact, there are two types of errors:

  • exceptions

  • syntax errors

The difference between the two is when the error happens. Syntax errors happen at parse time, when the interpreter tries to make sense of your program. Exceptions happen at run time, when the interpreter executes the program.

For example, a missing parenthesis will generate a syntax error. Python will catch it before it tries to run the expression.

Trying to open a file that doesn't exist will generate a runtime exception. The Python interpreter has to try to open the file before realizing it's impossible.

This is confusing so I will give you a more pragmatic way to think about errors and exceptions.

💡
Errors are problems arising because of incorrect code.
💡
Exceptions are problems arising despite correct code.

Example of errors are

  • Referencing a variable that doesn't exist. Error names are NameError, or the more specific UnboundLocalError for local variables.

  • Improper indentation: IndentationError, or the more specific TabError.

  • Importing a function that doesn't exist: ImportError or the more specific ModuleNotFoundError.

You should correct them as you're writing the code, by testing each function as soon as you've written it. Now imagine if someone tried catching these errors at runtime...

try:
    try:
        <import a bunch of modules>
        try:
            <rest of program>
        except Exception as catch_all_e:
            print(f"What could go wrong??? {catch_all_e}")
    except ModuleNotFoundError as module_e:
        print(f" Modules dreamt up in the throes of a fever nightmare: {module_e}")
except SyntaxError as distracted_e:
    print(f"The programmer hasn't bothered debugging his terrible code: {distracted_e}")

Now let's say your program needs to download a file, open it and read its contents. For some reason your internet connection had a problem and you end up with mangled data. That's not a bug, but you need to plan for it. It's also a rare occurrence. Some might say... an exception?

ConceptPurposeSyntax Example
ErrorSerious issue, often uncatchableSyntaxError, MemoryError
ExceptionRuntime issue, can be handledZeroDividionError, ValueError
try/exceptCatch and handle exceptionstry: risky(); except Error:

Exceptions are part of writing resilient programs. Errors are signals that your code still needs debugging.

What you learned

In this project, you practiced:

  • Writing robust Python code using try/except blocks

  • Catching and handling file I/O and user input exceptions

  • Using argparse with subcommands to build a CLI interface

  • Creating an encryption and decryption system using the Vigenère cipher

  • Differentiating between errors and exceptions in Python

You now have a much richer skillset to create robust Python tools!

Recap: argparse subcommands, exceptions, try/except blocks, errors

Argparse subcommands

Splitting the CLI: We broke the tool into three distinct modes: encrypt, decrypt, and crack. Each command has its own options and arguments.

Creating subparsers: The main parser dispatches each command to a smaller parser. Generate the subcommand scaffolding in two steps.

subparsers = parser.add_subparsers(dest="command", required=True)

enc = subparsers.add_parser("encrypt", help="…")

Defining arguments per command: Each subparser is like an independent ArgumentParser. Add arguments to a subparser with add_argument.

enc.add_argument("key", help="secret key to use")

enc.add_argument("origin", nargs="?", help="file to encrypt")

Dispatching: At the end, you call args = parser.parse_args(). argparse inspects the first positional token (encrypt, decrypt, or crack). Then, it parses only that subparser’s arguments into args.

Automatic help pages: You get -h/--help for the main program and each subcommand for free.

$ python vigenere.py encrypt -h

usage: vigenere.py encrypt [-h] key [origin]

Error messages and stderr

We caught a bug where a user could provide an invalid cryptographic key. This would crash the program down the line. Rather than dealing with this issue when it's too late, I decided to catch it at argument parse time.

Calling parser.error("…") prints your message to stderr and exits the program.

stderr is like stdout, but for errors instead of standard output. Separating normal output (stdout) from error messages (stderr) has a slew of advantages. For example, you might want to write logging functions to monitor errors. This function would need to access your error messages, but not your standard output. The inverse is saving your output to a file. You don't want error messages to pollute it.

Exceptions & the try-except pattern

Sometimes, correct code might fail. Exceptions capture those failures and let you deal with them as you see fit.

We used the common try/except pattern in our code. Here is its basic form.

try:
    result = risky_operation()
except SpecificException as e:
    handle_exception_code(e)

This structure isolates the failure-prone code.

You could use a bare except: statement instead of the except SpecificException as e:. This is not good practice. Always try to catch the narrowest exceptions as possible. This way you won't swallow and miss unexpected bugs by accident.

Exceptions vs errors

Syntax errors occur at parse time. They prevent your code from running at all (e.g. missing colon, mismatched parentheses).

Exceptions occur at runtime. They signal conditions you can either recover from or report. For example, trying to open a file that's not there raises OSError.

Pragmatic distinction:

  • Errors (like SyntaxError, IndentationError) mean your code has bugs. You must fix it.

  • Exceptions (like ValueError, FileNotFoundError) mean your code encountered an unexpected situation during execution.

Exercises

Easy

  1. Encrypt a message: Use the encrypt command to encrypt the message "HELLO" with the key "KEY". Call our program from the command line.

  2. Decrypt the message and confirm it matches. Use the output from the previous step. Try decrypting it using the same key and confirm that you get "HELLO" back.

  3. Encrypt from a file, output to another file. Create a text file message.txt with some text. Encrypt it using a key and save the result in encrypted.txt.

  4. Trigger a missing input file error. Run encrypt or decrypt on a nonexistent file, like ghost.txt. Observe the OSError and the clean error message.

  5. Try an empty key. Call encrypt "" message.txt. Verify that it fails with a key-related error from parser.error.

  6. Manually input text (no file). Run the tool without specifying an input file. When prompted, type in a message. Try with and without EOF (e.g. Ctrl+D) to test exception handling.

  7. Use an invalid character in the key. Try a key like abc🙂xyz and confirm that the validation code rejects it with a useful message.

  8. Experiment with --output vs. no --output. Encrypt or decrypt once with -o output.txt, once without. Check that one writes to a file, and one prints to console.

  9. Use encrypt and decrypt subcommand help pages. Try python3 vigenere.py encrypt -h and decrypt -h. Explore what each argument does.

  10. Encrypt multiline input. Use a file containing complex data, for example, the program's source code. Encrypt it, and ensure the formatting is the same in the result.

Medium

  1. Add a --force overwrite flag. Change the program to refuse overwriting an output file unless the user passes a --force flag. Raise a custom message if the file already exists without the flag. A useful function is os.path.exists(); you'll have to import the os module. Use an exception (a simple Exception will do the trick) if the file exists and the user provided no force flag.

  2. Add a --strict flag that restricts unknown characters. Right now, unknown characters pass through crypt_iter without change. Add a --strict flag: if it’s set, raise an exception (ValueError) when encountering any character not in ALPHABET.

  3. Support lowercase-only alphabet mode. Add an option --mode=full|lowercase. The user would type --mode=lowercase to toggle it or --mode=full to prevent it. The default should be --mode=full. If he chooses lowercase, restrict ALPHABET to "abcdefghijklmnopqrstuvwxyz" and adjust validation.

  4. Add --keyfile option to load the key from a file. Users can pass --keyfile path.txt instead of typing the key. Confirm that the file has a single non-empty line, and contains valid key characters. Use a try-except block to open the file.

Hard

  1. Add a --dry-run option. Change the code so that, if you pass it --dry-run, the result is printed but never saved to a file, even if you specify --output.

  2. Refactor encrypt and decrypt to accept any shift function. Allow the user to pass in a --func argument to pass cryptographic lambda functions.

  3. Display readable error messages with color (optional). Use sys.stderr.write() with ANSI color codes to make error messages more noticeable (e.g. red text). You will have to import the sys module. Bonus: only use colors if sys.stderr.isatty() is True. It is a good exercise for you to read through and make sense of unfamiliar documentation. (Hint: look for sys.stderr…)

That’s it for today. I hope you enjoyed this post. If so, stay tuned because the next one will be about cracking the Vigenère cipher.

< Previous Post

0
Subscribe to my newsletter

Read articles from Had Will directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Had Will
Had Will