Grounding yourself as a programmer in the AI era, part 4

MP #26: Refactoring a small project, with AI assistance.

Note: This is the fourth post in a 6-part series about starting to integrate AI tools into your programming workflow. In the previous post we refactored the initial project without using an AI assistant. In the next post we’ll package this project and post it to PyPI.


In the last post we took an exploratory project, and refactored it using a traditional, non AI-assisted approach. Now we’ll start with that same (unrefactored) code, and do the same kind of work with assistance from an AI. In the end, we’ll compare the resulting codebases, and the two workflows.

I’m really excited about this exercise. I’m just starting to use AI tools in programming, and I’m really curious to see what they do well. At this point I’m most curious to see where AI tools take people if you’re not overly specific about how you want it to implement your ideas. So I’m going to be clear that I want to clean up an existing project, but I’m going to say as little as I can about how to implement anything in code. I want to see how the AI’s restructuring ideas compare to my own, and see what direction it might take someone who doesn’t know Python very well.

Getting started

I’m going to approach this as if the single-file, unrefactored add_border.py is about as much as I can do on my own. If I’m new to programming, but I know my code should be more structured than just a single file, where will an AI lead me? For example if I don’t tell it to use argparse, but I ask it to change the padding argument from a positional one to an optional named argument, will it do that cleanly?

In these early AI days, it can be helpful to see exactly what prompts were used, and the full responses of the AI at each step of the process. If you’re curious about this, you can see the full log here. It’s a screenshot, so you won’t be able to copy and paste, but you can see my actual interactions. (I also posted it to ShareGPT, but I have no idea how long that service will remain available.)

Also, I’m using GPT-4 for this work. If you try something similar and get less satisfying results, be aware that GPT-4 has been giving significantly better suggestions than what people are getting with GPT-3. I’ve heard mostly good things about the quality of output from GPT-3.5 as well.

Introducing the project

To get good output, it’s important to give the AI some context about what you’re trying to do. After all, we don’t write code blindly; we either have the idea for a project in our own mind, or we communicate with others at length before committing to any particular code. The AI needs a bit of that as well.

Here’s my first prompt:

Me: I have a single Python file, that I really like and want to keep working with. But it's getting kind of long. Can you help me refactor it so it's easier to keep working with?

It first responded by telling me some general points about how to approach refactoring. I followed up with a more specific request:

Me: I understand how to go about refactoring. I was wondering if you can do the refactoring for me.

It gave me a few options for how to share the code, so I pasted it into the chat box. The output was really interesting:

GPT-4: Here's a possible refactoring of your code into three separate Python files: main.py, cli_args.py, and image_processing.py. This structure separates the command-line argument parsing, image processing, and main execution logic.

This is pretty much the same approach I took to breaking up the project. Let’s look at the actual code.

Here’s main.py:

import sys
from pathlib import Path
from cli_args import parse_cli_args
from image_processing import add_border_to_image

def main():
    # Get the filename and optional CLI args.
    path, border_width, padding, border_color = parse_cli_args(sys.argv)

    # Add border to image and save the new image.
    new_img_path = add_border_to_image(path, border_width,
        padding, border_color)
    print(f"New image saved at {new_img_path}")

if __name__ == "__main__":
    main()

One thing I notice right away is that it’s importing Path, which isn’t used in this file. I also notice that it’s passing sys.argv as an argument to parse_cli_args(). That function could access sys.argv on its own, but I think this is also a pretty reasonable way to approach handling the CLI arguments.

Here’s cli_args.py:

import sys

def parse_cli_args(argv):
    try:
        path = Path(argv[1])
    except IndexError:
        print("You must provide a target image.")
        sys.exit()

    border_width = int(argv[2]) if len(argv) > 2 else 2
    padding = int(argv[3]) if len(argv) > 3 else 0
    border_color = argv[4] if len(argv) > 4 else "lightgray"

    return path, border_width, padding, border_color

Here I notice that it’s using Path without importing it; I’m pretty sure this will generate an error the first time it’s run.1 I also notice it’s using our original approach of returning a bunch of option values from one parsing function.

It has done a really nice job of cleaning up the logic for managing default and optional values. The single line that defines the value for border_width is equivalent to this code:

if len(argv) > 2:
    border_width = int(argv[2])
else:
    border_width = 2

That’s a nice one liner!

Here’s image_processing.py:

from pathlib import Path
from PIL import Image, ImageOps, UnidentifiedImageError

def add_border_to_image(path, border_width, padding, border_color):
    try:
        img = Image.open(path)
    except FileNotFoundError:
        print(f"{path} does not seem to exist.")
        sys.exit()
    except UnidentifiedImageError:
        print(f"{path} does not seem to be an image file.")
        sys.exit()

    new_img = ImageOps.expand(img, border=padding, fill="white")
    new_img = ImageOps.expand(new_img, border=border_width,
            fill=border_color)

    new_path = path.parent / f"{path.stem}_bordered{path.suffix}"
    new_img.save(new_path)

    return new_path

Here I notice it’s importing Path unnecessarily again. It’s really interesting to me that it can come up with relevant names for new files and functions, but imports a library in the two files that don’t need it and misses the import in the one file that does need it.

I’m impressed by the names that GPT generates. Some of these names are drawn from the original code, but some are new. I like the name image_processing.py much better than my own image_functions.py; I’ve always struggled with naming helper modules. GPT’s name is better than mine because it’s more specific about what it does, and less specific about the code structures in the module. What if we have something other than just functions in the module? GPT’s name would still be relevant, but mine would not.

That said, I much prefer cli.py to cli_args.py, because we’re building a CLI, not just a set of arguments. That’s a fairly minor issue at this point though. It’s trivial to keep the names I like, and modify the ones I can improve on.

It reports that this code is ready to run:

GPT-4: Now, you can run the main.py file as your entry point to the script. The cli_args.py file takes care of parsing the command-line arguments, while image_processing.py contains the core image processing functionality. This should make your code more organized and easier to maintain.

Okay, let’s try running main.py:

$ python main.py 
Traceback (most recent call last):
  ...
  File "/Users/.../cli_args.py", line 5, in parse_cli_args
    path = Path(argv[1])
           ^^^^
NameError: name 'Path' is not defined. Did you mean: 'path'?

This error is occurring in cli_args.py, because Path was not imported from pathlib.

I don’t want to paste errors in one by one if I don’t have to, so I’m going to let GPT know there’s an issue and ask if I have to just paste it in:

Me: I got an error when I ran main.py, and I think there’s going to be more. Do you need me to paste the errors in one at a time, or can you reexamine the code and fix it?
GPT-4: Apologies for the oversight. I noticed the error, and I've updated the refactored code below. The main issue was that I forgot to import Path from pathlib in cli_args.py. I've also added the necessary imports in main.py.

In the new code, it removed the import sys from main.py, because it’s no longer sending sys.argv as an argument to parse_cli_args():

from pathlib import Path
from cli_args import parse_cli_args
from image_processing import add_border_to_image

def main():
    # Get the filename and optional CLI args.
    path, border_width, padding, border_color = parse_cli_args()
    ...

It’s still importing Path, which is not needed here.

In cli_args.py it added the needed Path import, and it’s using sys.argv throughout:

import sys
from pathlib import Path

def parse_cli_args():
    try:
        path = Path(sys.argv[1])
    except IndexError:
        print("You must provide a target image.")
        sys.exit()

    border_width = int(sys.argv[2]) if len(sys.argv) > 2 else 2
    padding = int(sys.argv[3]) if len(sys.argv) > 3 else 0
    border_color = sys.argv[4] if len(sys.argv) > 4 else "lightgray"

    return path, border_width, padding, border_color

There are no changes to image_processing.py.

I think it might run this time:

$ python main.py willie_sideeye.jpg
New image saved at willie_sideeye_bordered.jpg

It works:

Puppy looking sideways at camera.
Might as well use a new image in each post. :)

I notice it’s added a confirmation message reporting the new filename. That’s something I was thinking of adding earlier, but hadn’t gotten to yet.

Running tests

I want to run the tests, but I need to update them to match the current filenames. This is a minor change; I just changed all references to add_border.py to main.py, and all the original tests pass.

I like the confirmation message, so I’m going to add an assertion that the confirmation message appears in the output. I don’t feel the need to add this to all tests; I’ll just add it to the simplest test of the default settings.

Further cleaning

I want to stay in the mindset of someone who’s newer to Python, and not ask for specific code changes. I’m going to make general comments and see if GPT is able to respond reasonably:

Me: That ran well, and it passed all the tests I have so far. There seem to be a lot of arguments being passed around. Is there any way to reduce the number of arguments passed between the different files?

The response is pretty interesting:

GPT-4: Yes, you can use a data class or a dictionary to group related arguments together, which can make the code easier to read and maintain. I suggest using a data class from the dataclasses module, which is available in Python 3.7 and later versions. Here's the updated code using a data class named ImageBorderArgs to group the arguments:

I’m really impressed with the clarity of the description and reasoning here. If you haven’t seen them before, a dataclass is a specialized version of a class that lets you use dot notation for accessing attributes, rather than having to use the bracket notation of dictionaries. There’s more to dataclasses than just that, but in this instance that’s a reasonable way to look at them.

Here’s main.py:

from cli_args import parse_cli_args
from image_processing import add_border_to_image

def main():
    # Get the filename and optional CLI args.
    args = parse_cli_args()

    # Add border to image and save the new image.
    new_img_path = add_border_to_image(args)
    print(f"New image saved at {new_img_path}")

if __name__ == "__main__":
    main()

Interestingly, it finally removed the unused Path import without any specific prompting. The main thing to see here is the much cleaner function calls. The function parse_cli_args() returns just one object, args. This makes the call to add_border_to_image() much simpler as well.

GPT did exactly what we asked it to; it simplified the argument-passing aspect of this project.

Let’s see how what parse_cli_args() is actually returning. Here’s cli_args.py:

import sys
from pathlib import Path
from dataclasses import dataclass

@dataclass
class ImageBorderArgs:
    path: Path
    border_width: int
    padding: int
    border_color: str

def parse_cli_args():
    try:
        path = Path(sys.argv[1])
        ...

    ...
    border_color = sys.argv[4] if len(sys.argv) > 4 else "lightgray"

    return ImageBorderArgs(path, border_width, padding, border_color)

This is how it’s managing all those arguments more efficiently. GPT recognizes it needs to manage four arguments, so it created a dataclass called ImageBorderArgs with four attributes. Each attribute in a dataclass needs a type, and you can see the type of each attribute here.

In parse_cli_args(), it’s still processing all the CLI arguments from sys.argv. But instead of just returning all the values individually, it’s creating an ImageBorderArgs object and returning that.

The ImageBorderArgs object is a dataclass, but it’s equivalent to this dictionary:

image_border_args = {
    'path': sys.argv[1],
    'border_width': sys.argv[2],
    'padding': sys.argv[3],
    'border_color': sys.arv[4],
}

I’ve left out the conditional logic, but the comparison is helpful. To work with the border width in the dictionary approach, you have to write image_border_args['border_width']. When using the data class approach, you can just write args.border_width.2

I will note that it’s considering all arguments as equivalent; the file path has been lumped in with all other arguments. I see the path as a fundamentally different kind of entity than the other entries in ImageBorderArgs. But this is a minor thing; if we wanted to pull it out of the dataclass, I’m guessing it would be trivial for GPT to do that for us.

Let’s move on to image_processing.py:

from pathlib import Path
from PIL import Image, ImageOps, UnidentifiedImageError
from cli_args import ImageBorderArgs

def add_border_to_image(args: ImageBorderArgs):
    try:
        img = Image.open(args.path)
    except FileNotFoundError:
        print(f"{args.path} does not seem to exist.")
        sys.exit()
    except UnidentifiedImageError:
        print(f"{args.path} does not seem to be an image file.")
        sys.exit()

    new_img = ImageOps.expand(img, border=args.padding, fill="white")
    new_img = ImageOps.expand(new_img, border=args.border_width,
            fill=args.border_color)

    new_path = (args.path.parent /
            f"{args.path.stem}_bordered{args.path.suffix}")
    new_img.save(new_path)

    return new_path

Here you can see the nice syntax that dataclasses support. I like seeing args.border_width instead of args['border_width'].

At this point, all tests pass. I also note that the project is significantly better than it was when we started. It’s really interesting to be making large, meaningful, nontrivial changes to a project without writing any actual code! I’m paying close attention to the code, but I’m not actually writing any code at all.

One quick takeaway is the importance of directing an AI tool as much as you need to. If you like the use of dataclasses in this project, keep the code that’s been generated. If you want to use a dictionary because you’re unfamiliar with data classes and want to stay with what you know, ask it to reimplement the data class as a dictionary.

One quick takeaway is the importance of directing an AI tool as much as you need to. If you want it to implement something as a dictionary instead of a dataclass because you understand dictionaries, ask it to only use a dictionary.

Cleaning up the CLI

There’s one more major change we want to make to the project. Currently, to set a custom border color, you have to include all of the arguments, even if you’re using default values:

$ python main.py willie_sideeye.jpg 2 0 black

Instead, we want to just use this command:

$ python main.py willie_sideeye.jpg --border-color black

Keeping with the pattern of telling it what we want to do and letting it choose the implementation, here’s the prompt I used:

Me: Right now all of the CLI arguments are positional. Can you modify the code so:
  • the filename is a required positional argument
  • the border width is an optional positional argument
  • the padding is a named optional argument, set by --padding
  • the border color is a named optional argument, set by --border-color

It seems like a lot of people who start using GPT-4 with an open mind have a moment where it generates something that truly shows how different this kind of tool is. The output I saw in response to this prompt was that moment for me. Sometimes that moment happens because it generates code someone wouldn’t have thought of themselves, but a lot of times it’s just abundantly clear how much time this tool can save you.

Here was GPT’s response:

GPT-4: To achieve this, you can use the argparse module to define and parse command-line arguments more easily. I've updated the cli_args.py file to accommodate your requirements:
import sys
import argparse
from pathlib import Path
from dataclasses import dataclass

@dataclass
class ImageBorderArgs:
    ...

def parse_cli_args():
    parser = argparse.ArgumentParser(
            description="Add a border to any image.")
    parser.add_argument("filename", type=Path,
            help="The target image.")
    parser.add_argument("border_width", type=int,
            nargs="?",default=2,
            help="The border width (default: 2).")
    parser.add_argument("--padding", type=int, default=0,
            help="The padding (default: 0).")
    parser.add_argument("--border-color",
            dest="border_color", default="lightgray",
            help="The border color (default: lightgray).")

    args = parser.parse_args()

    return ImageBorderArgs(args.filename, args.border_width,
            args.padding, args.border_color)
GPT-4: Now, the filename is a required positional argument, the border width is an optional positional argument, and the padding and border color are optional named arguments. The other files (main.py and image_processing.py) remain unchanged.

Usage examples:
$ python main.py input.jpg 5 --padding 3 --border-color red
GPT-4: This command will add a border to the image input.jpg with a width of 5, padding of 3, and a red border color.

There’s nothing particularly amazing about this code. It chose to use argparse, just like we did in the last post, and that’s a natural change to make. This was eye-opening for me because even though I’d used argparse before and was reasonably familiar with it, I’d never had to implement the combination of required, positional, and optional arguments this project uses. In particular, I’d never used nargs="?", which is needed to implement an optional positional argument. When writing the last post, I spent 30-45 minutes skimming the argparse tutorial and documentation, some blog posts, and a Stack Overflow discussion in order to implement this correctly. And GPT just spit it out in under a minute! This is why people are saying the cost of an OpenAI subscription can pay for itself in no time.

This doesn’t take away the need to learn about programming, or to learn about Python. I wouldn’t be able to understand this nearly as well if I’d never seen argparse before. But it sure makes developing projects a faster process.

The code isn’t perfect. I don’t like that the argument for path is now filename. It’s a subtle difference, but using the word path indicates to users that they can provide a path to any image file on their system. Calling that parameter filename suggests that maybe it only works on a file in the current directory. This is most easily seen by looking at the help output:

$ python main.py --help
usage: main.py [-h] [--padding PADDING]
    [--border-color BORDER_COLOR] filename [border_width]

Add a border to any image.

positional arguments:
  filename              The target image.
  border_width          The border width (default: 2).

options:
  -h, --help            show this help message and exit
  --padding PADDING     The padding (default: 0).
  --border-color BORDER_COLOR
                        The border color (default: lightgray).

That’s not an argument against using AI assistants at all though; it’s much easier to go back and make these adjustments than to have to write all this boilerplate from scratch. It is an argument against accepting GPT’s output without looking it over carefully. I also note that it added an appropriate description to the CLI, which we didn’t have before.

At this point, the entire test suite passes.

Conclusions

I’m absolutely impressed with how effectively GPT-4 was able to refactor a small project, with very little specific direction. With a small number of prompts written entirely in prose, we’ve improved the structure of our project just as much as we did when refactoring using a non-AI assisted approach.

I would stress the importance of having a test suite that covers the most important functionality of a project before jumping into any refactoring process, but especially an AI-assisted one. I can easily see an assistant creating a subtle bug, or more importantly a subtle change in behavior that doesn’t manifest as a bug. I’m wary of letting an AI generate a test suite, because tests are that boundary of trust that give us confidence that what’s happening inside a project corresponds to what we’re seeing in the output. We should know exactly what we want to see in the output, and we can write tests that check for those exact outcomes. I would happily let an AI generate the boilerplate of a test suite, but I’d want to write, or carefully verify, every assertion that was made about the project.

I’m wary of letting an AI generate a test suite, because tests are that boundary of trust that give us confidence that what’s happening inside a project corresponds to what we’re seeing in the output.

I’m also well aware that this was a simple, single-file project. It’s really helpful to play around with an AI’s capabilities in a small project like this, where we can quickly evaluate the accuracy and helpfulness of the output, both its explanatory text and its code. There’s no doubt, the conversational output is really impressive. I have a much more complex project I’m working on, that I’m doubtful GPT will be quite as helpful with. But now I’m even more curious to try using GPT to assist on that project. I’ll either be more amazed and happily surprised, or come away with a clearer sense of where the boundaries are in how helpful this current generation of tools is.

I’ll end this post with one final caveat, that I’ve said before. I was perfectly happy to dump all this code into GPT, because this is an entirely public codebase. If you’re working on a private project, be really thoughtful about what code and context you dump into an AI assistant. Many of the tools that are cropping up are thin wrappers around GPT, and OpenAI has been really clear that they’re training their models on everything we put into them. I would hesitate to dump private code into a GPT session, or any tool that sits on top of GPT. Instead I’d treat those sessions like a Stack Overflow discussion. I’d develop a minimum working example for the issue I’m trying to solve, give that to GPT, and translate the solution to something I can use in my actual project.

In the next post, we’ll turn this project into a package and post it to PyPI. By the end of the post, you’ll be able to install it using pip.

Please share your experiences and thoughts about using AI assistants. Write a comment, jump in the chat, or reply directly to this email and I’ll be happy to respond.

Resources

You can find the code files from this post in the mostly_python GitHub repository.


  1. Of course it did! But even with all my Python experience, I found myself thinking, “Well, maybe it knows something I don’t know about how to use imports?”

  2. One additional advantage of using a dataclass is type checking. If you try to create a dataclass object with an attribute that doesn’t match the stated type for that attribute, you’ll see an error. In a dictionary values can be any type, so there’s no validation inherent to the dictionary.

    In a project like this, using argparse provides type validation because each argument needs to match the specified type. In that case, validation by the dataclass becomes redundant.