209: Testing argparse Applications
Hey. Before we jump into the episode, I wanna let you know that I've already had a little bit of feedback on this episode already, and I wanted to do a guess, maybe a little disclaimer here. This episode was a bit of an experiment talking about code functions, and it's a little hard to do in audio form. I thought I did an okay job of it, but it might be hard to follow. From one person I heard, it's, easier to follow if you look at the code while listening to it, but that's not always convenient for a podcast.
Brian:And from somebody else that maybe the what to do is more suited for a blog post and, the why to do it, the why part was better in an audio form, and I'll keep those things in mind. So if you're having trouble following along in this episode, you're not alone. My apologies. But I'm gonna leave it up anyway because why not? It's an experiment of a podcast anyway.
Brian:I was asked recently about how to test the argument parsing part of an application that uses argparse. Argparse is a built in Python library for dealing with parsing command line arguments for command line interfaces or CLIs. You know, like git where you would maybe say git clone repo address? The git is the application. Clone is well, it's not an argument.
Brian:It's a subcommand. But the repo address is a command line argument. Never mind the subcommand bit. Actually, this may have been a bad example. The example I'm gonna use and link to as a code example does not use subcommands, but the most of what I'm gonna talk about does apply to sub commands also, so stick with me.
Brian:Loads of application use command line arguments, And, also, sometimes you might hear command line arguments referred to as flags or options or something. It's really just everything after the application name. Even small utility scripts that start out with no arguments can grow with argument to have arguments over time. And getting the parsing part of the options right can be tricky and weird even if you've done it before because maybe you did it before, like, a year ago and you kinda forgot how to do it. So testing that bit totally makes sense.
Brian:Okay. So first, my smug answer. My first answer to this is, well, just use click or typer. There are CLI frameworks that handle all the art parsing part, And they're great to work with and they have built in they have a built in thing called CLI runner objects. Then those are designed explicitly to help with testing part.
Brian:However, Arcparts doesn't have one of those. It doesn't have a lot of built in testing tools. There's a few things there, but, but it doesn't have like a test interface. However, there are sometimes really good reason to just use arc parse. First off, it's part of the standard library and maybe you don't have any other external dependencies, so why would you add another just for argument parsing?
Brian:Also, single file scripts and applications, that you're not packaging and they're just sharing with single files, well, especially those kind of things, those aren't gonna have external dependencies. So, yeah, it's not gonna work. So just use argparse for that. And, also, just maybe you really like argparse. And to be honest, I usually use click or typer.
Brian:But after looking into this, honestly, I'm pretty impressed with arcdparse, and I might use it in more applications going forward. Let's talk about designing for test. So I will get to the ark parse part, but let's, let's think about the design of an application or a script. First off, let's design the application so that it's ready for testing. And this applies to all applications, even small scripts.
Brian:There's a few good things to do with all CLI applications. First, have a main function called by a if dunder equals dunder main block and have that contain have the main function contain all of your logic or have it call other functions. Have a parse args function that contains all of your interactions with argparse and have all of the logic of your application have it all be in a function. Don't have it be just raw stuff that happens when you run the script. It should be part of a function and called by me.
Brian:The reason to do this is so that your test code can import your application or your script, and then it can call the individual parts. It can call your main function. It can call all of the different pieces and it can call parse args so we can test the argument parsing part completely separate. That's awesome. This makes testing a whole lot easier.
Brian:So I'm gonna go through all of those in more detail and the code examples are gonna help too. But let's go through that and the reasons why and a little bit of some talking about how this is gonna work with testing. First off, you're gonna have a if dunder equals main block, and that block is gonna just have one single line. It's gonna have a single function called main with no arguments. Not gonna pass any arguments to main.
Brian:It's just gonna be there by itself. Okay. Next off, so that's the the if dunder name equals dunder main block. Now I'm gonna actually define my main function, and it's going to take arguments even though I'm not didn't pass any n. It's gonna take an arg list.
Brian:It's gonna be a list of strings or none. The default is gonna be none for main so that when it gets called from dunder name equals main, it's gonna get passed none. When we're testing, we can pass in the argument list. When we're actually running, it's gonna get none. This seems weird at first.
Brian:Bear with me. It makes sense. In your main function, the first thing you do is you're gonna call a parse args function and pass it the argument list and then you're gonna assign the return value to some variable named to args or params or whatever you wanna call. That's gonna contain the arguments that get passed in. You can look those up later.
Brian:The parse args function also takes either a list of strings or none. It doesn't have to have a default. Now this function, that's the one that you're gonna set up the parser and the argparse parser and add arguments and then you're gonna call parseargs in that function with and then you're gonna pass in this arg list. The the magic here is that it's either gonna be our test list, a list of arguments, or it's going to be none. And the cool thing about argparse's parse args function is that if you pass in none, it will automatically go out and look at sys argv instead.
Brian:Cool. That's exactly what we wanted to do. We wanted to look at the command line arguments during normal running but when testing, I wanna be able to pass those in. Perfect. Okay.
Brian:Now we're gonna get to our test code. In our test code, we can import the application or the script or file, whatever you wanna call it, and we can pull out the main and the parse args function and the other bits and test it either in the different parts or we can test the whole by calling main. The parse args function, it's a really great thing to unit test by itself. Even if you don't have any other unit tests, it's kinda neat to to test that parse args function. It'll also help you understand how to use argparse if you haven't, used it before and it'll help other people looking at your code figure out what's going on with this parse args function.
Brian:Another great thing about importing is that when we're testing like that, it runs within the same process. The the code under test is running in the same process as the test code so that mocking and stuff like that works if you wanna if you wanna mock some stuff or fake or stub or whatever. Now let's kinda hop back to our code under test for a second because I wanna add a few things. I wanna I often will add, like, a dash d or a dash dash debug flag and then also a preview flag. So we've got a debug and a preview flag, and there's a there's a couple great reasons for this.
Brian:The debug flag, I can print out this sort of stuff that's gonna help me when I debug it later, like the things that are going on within the script or the application. What's going on? The logging statements sort of if logging statements, if you will. You can use logging too, but I gotta be honest. I use the standard out a lot.
Brian:And then, within the code, you can if I just I don't want to print all the time, because it'll muck up the user experience, But I can define either a debug print function or whenever I want to print something, I can check the args dot debug, and it'll it'll tell me whether I want to print some stuff or not based on whether I passed in the debug flag. This is great. Also, around those print statements, I don't really care about testing those so I usually throw a pragma no cover around that. Okay. How about preview?
Brian:That's for doing all of the logic, but when you're not actually wanting to do the side effects. So around the side effects that you don't wanna run when you're testing, like hitting an external API or sending email or billing something or whatever, you can just print out what you would have called that API with. Just send that to a print statement when you when you're passing in the preview flag. The beauty of this is now, I can use that for tests and it's great. You've actually probably seen stuff like this used in some of the everyday features you use.
Brian:I I have a few tools that I use on the command line that I didn't write that have, like, preview flags or something that tell me what would happen if I did do that do this command, but it doesn't actually do it. So you may actually want to expose that to a user experience, but if you don't, if you don't want to expose it, both arc parse and click and typer, they all have features to hide that from the help if you want. So within arc parse, there's a arc parse dot suppress that you assign to the help portion and, click and type or call those hidden. So you'd see their suppress or hidden if you wanna hide those, but they're really great for, for testing. So, I'm printing a bunch of standard out and then my test code, I can and one of the beautiful parts is I can pass in the preview when I'm testing my application, and then I can just use like capsis with pytest and capture that output and make sure that I'm calling the thing correctly.
Brian:This is great, so I don't actually call any side effect, but I am testing everything. Another thing I like to do with testing is with all these command line arguments and stuff, like, they're pulling our parse and other things. They're pulling from sysargv. On the command line, it's gonna be a space delimited string, but in this argv thing and also the thing that the the list of strings that we're passing in argparse, those are a list of strings. They're not a single string that's spaced delimited.
Brian:Every word is separated into a different string that you pass into a list. This is not too bad for just a couple of things, but if I have a whole bunch of things that I'm passing in, it gets really annoying really fast. So, I like to like to just pass in the string. So luckily, Python has a built in function called shlex. It's shlex.split, and that does exactly what I want, splitting a space delimited string into a list of strings.
Brian:So I can use that to pass in my test command line string and, and convert that to a list of strings to pass into arcparks or whatever. This runs great. Okay. Another thing is I'm assuming so far that you could change your application so that it's easier to test, but some people can't. Sometimes I've just got a a script as is that's not in different functions.
Brian:It just runs. Well, in that case, you really have subprocess run. That's what you're gonna have to deal with. You can still run test code. We can still test the output.
Brian:So if there's a way to suppress the actual, side effect that you might not want to use, If you can do that, then you can still use subprocess run and then test the output with, with capsis in pytest. It works works okay. Let's take an example that will work okay like that, and it's our classic hello world example, and this is actually the code that I've got as an example. I've I'm actually gonna put this up as a blog post also, and it'll have the code in it and, there'll be a link in there to a GitHub repo as well. Hello world does not you do not need argparse for hello world.
Brian:So, to make this example, like, kinda easy to think about but, like, still needing arguments, let's add some requirements. So let's add the ability to pass in a name. So if you pass in, like, hello hello. Py space Brian, it'll say hello, Brian instead of hello, world. Next, let's have a goodbye flag so that it'll say goodbye, Brian, instead of hello, Brian.
Brian:Yeah. So that's good enough to to be able to testing our parse. I don't know if there's much more to talk about in the audio form, but I would like you to go and check out the code. It'll it'll be in a blog post, and it's also, like I said, it's also in a repo. The example uses also uses debug, so I've added a debug flag that prints out the args to help you look at how to argue to test that, how to debug it at least.
Brian:It splits up the code into a main and a parse args, and what else? It's got test code. So the test code, will show you how shellx.split works. There's a there's a little test function for that. It's there's a parameterized test main that essentially tests the application from the main function, and it uses capsys to look at the output.
Brian:Because it's looking for hello world or hello Brian or whatever. And then also I I show an example of using, subprocess run to test all of to it to test it from the full application. Anyway, I know that there's probably people out there that have some cool cool tips and tricks to, testing for testing and other command line applications. And if I've missed something that you really want to me to share with the world or if you just think maybe I might be curious about it, hit me up. I'm on, mastodon@brianauken.
Brian:Thanks.