Plumbing with Snakes 2: Coils within Coils

programming python apps basics

So we left off last time with a non-working wrapper script... just can't get the staff these days... so obviously the first thing we want to do is make it work. In Python 3 it works fine the main issue is that when run with Python 2 it freaks out because it doesn't recognize the syntax, but we're also going to want that code because it does work, we just want to prevent it being run incorrectly. The way to solve this is we need a file that is Python 2 friendly that can be run and give a descriptive error, so we effectively need to sandbox the Python 2 component, but because all we need it to do is error, we can contain it all within the one file. So lets make a new file, run.py, add in our shebang, add a naming comment since we now have multiple files to keep track of, import sys, and throw in our if statement to check for Python 2.

#!/usr/bin/env python3

# run.py
import sys

# Check python version, display error and exit if wrong
if sys.version_info.major != 3:
    sys.exit(1)

There you go, I've even added a nice little comment to tell you what it does, of course it doesn't have any functional parts yet, but lets make sure it works as intended before we worry about that. Give it a run with both Python 2 and Python 3 and you'll see both just complete without saying anything, don't worry its not broken, we never told it to say anything, if you check the exit codes you'll see it handled it correctly.

 ~> python3 run.py

 ~> echo $?
 0

 ~> python2 run.py

 ~> echo $?
 1

Excellent, so with that running properly we can look into getting it to output a proper error message, but lets not get ahead of ourselves, we need to reconnect it to our main function. To do that we should bring in our other conditional so it only runs when called directly. You could argue our version checker should be encased in it too, but firstly as you'll come to see its effectively a special error so keeping it distinct is useful, also we want it to fail on Python 2 no matter how it's called, whereas the main program is only there for when run correctly. Hopefully you should be familiar with calling local files in Python but its fairly easy, you simply call the file you need with a dot then the function you want after importing it, you could be more specific with your importing allowing you to rename the function, however I tend to reserve that for special cases, insisting on the dot notation makes it easy to see where the function is. So your run.py should now look a bit like this:

#!/usr/bin/env python3

# run.py
import sys

# Check python version, display error and exit if wrong
if sys.version_info.major != 3:
    sys.exit(1)

import app

# Run main program
if __name__ == "__main__":
    app.main(sys.argv)

Notice how I've also used the imported sys library to send the necessary data to the function so I don't need to import it again, can save some unnecessary imports if you plan ahead like that. You may be wondering why the app import is so low down, and its a clever trick, see the issue we had previously is if Python 2 finds syntax it doesn't like it'll error, this entire file is Python 2 friendly so it parses it all fine, app.py however contains incompatible code. The trick is the import isn't evaluated until it reaches that line, as the Python 2 exit instruction is above it, it never reaches it, only Python 3 does which has no such problems with the syntax in app.py, neat huh? We'll come back and improve that "error" in a bit as all it does right now is force exit with an undocumented exit code, right now we need to clean up app.py.

#!/usr/bin/env python3

# app.py
import sys

def main(argv: list[str]) -> int:
    """
    Main function
    :param argv: A list of arguments containing the program name and the user choice.
    :return: An exit code signifying the status upon completion.
    """
    return 0

if __name__ == "__main__":
    sys.exit(2)

So you can see I've removed the version condition as that's now handled in the other file, though I've still left in the import as its currently used by the now repurposed main checking condition. As the other file is now the one that gets run this condition wouldn't do it's job anymore, however, we also now need to make sure this script doesn't get run directly as it's only a part of the whole. So in the event it does we now force exit with another undocumented exit code of 2, again this is a bit crude, but we'll clear it up again in a little bit, the aim right now is to make the program functional.

 ~> python3 app.py

 ~> echo $?
 2

You should now see the previous commands work as intended still and now this app file can't be run directly. If you test it you'll find if run with Python 2 it still gives the old syntax error, but we can't make all our files safe against it, the main thing is it still doesn't work and it shouldn't take someone long to figure out run.py is the intended launch point, and that is protected against older Python versions which should eliminate most user error once it provides a descriptive note. Alright so we can be fairly sure the user can't easily run the program incorrectly, it's time to start adding some descriptive output so the user knows what they're doing wrong, and to avoid constantly reinventing the wheel, lets build a modular error handling library we can call whenever something goes wrong so we can improve this crude exit code nonsense.

To build a library effectively you want a new file, now you don't need one to build this stuff, but its worth noting anything in run.py will need to be Python 2 compatible, which gets messy, and I prefer to keep my error handling as a separate entity that works in a modular fashion as it tends to make debugging a bit easier. So lets build err.py it'll require the usual bits and bobs, shebang, note, and a condition to prevent it being run directly, and lets make a generic main function, the use of this will become more apparent later. the error handler will have to come after the intial run script but wants to be a wrapper around the core functions so it makes sense it should be second in the chain. Don't forget to add the pointer to app.py and change the one in run.py to err.main(sys.argv) and change about the imports as required.

#!/usr/bin/env python3

# err.py
import sys
import app

def main(argv: list[str]) -> None:
    return_code = app.main(argv)
    return

if __name__ == "__main__":
    sys.exit(2)

Lovely, so if you've done all that right the program should pass right through err.py and respond exactly as it did before with no real indication of any step in between. Note how I've created a variable to store the return code of the program this is where that return code will get turned into an appropriate exit code with accompanying error message for the user to read. Before we get started on that though we're going to need a helper function to standardize and modularize how to deal with all the different errors we'll throw at it. If you're not familiar with error handling this next bit might look a bit weird, just go with it, its fairly simple and just designed to tack onto the existing error hierarchy.

def throw(error: BaseException, code: str, message: str) -> None:
    """
    Generic error throwing function for use within the program
    :param error: The type of error to be thrown.
    :param code: The error code to be provided upon exit.
    :param message: The message to be presented to the user.
    """
    try:
        raise error from None
    except error as e:
        print(f"{type(e).__name__} ({code}) {message}")
        sys.exit(int(code, 16))

So what we have here is a function called throw and as the documentation says it takes an error type to throw, as all errors inherit from the BaseException (you can read more on how this works here do familiarize yourself with it as error handling is a core part of good code writing), the exit code to be used and the message to be provided. The idea behind this function is using try, catch, except and assert during the main program you catch the internal errors and deal with them appropriately, when an error cannot be avoided that information is then fed back to the error handler by one means or another and if it cannot be successfully handled throw is called aborting with the error code and a descriptive message describing what sort of unrecoverable error has occurred so the user knows what happened. Using this method the error handling is all contained within one document limiting the need to directly raise errors or call sys.exit which can be dangerous if done improperly, and the bonus of keeping it all in one file is it prevents duplication, improves debugging, facilitates the ease of documenting the program's behaviour, and it makes it much easier for it to fail smart rather than stupid because everything is well maintained and ordered.

You can see after raising the error the next thing it does is except it which should in theory always work, print a user friendly error message using f-strings and then exit using a hexadecimal exit code, this is just personal preference. The way you would use this is by catching any errors that occur in the main code, and running throw with that error and a tailor-made message built of the knowledge of where the error occured, we'll see this in action before long. This method prevents the long string of traceback calls you would normally get and instead provides you with information direct from the heart of the problem as it is much more specific than the general errors Python normally produces.

This is all well and nice though but we already have two known types of errors we need to catch so we better get to work. We'll tackle the second one first because it's a little easier, we need to adjust the if that prevents running err.py and app.py directly to this:

# Throw an error if run directly.
try:
    assert __name__ != "__main__"
except AssertionError:
    throw(RuntimeError, "0x02", "Please run from run.py.")

Alright so what did we do? Well the sys.exit got removed and replaced with a call to throw so in app.py we can remove the import sys as it should no longer be necessary given that is dealt with by the error handler. As a result of this it needs to know where throw is so chuck in a from err import throw as the first line of the exception, as the function is already in err.py it doesn't need this line. Notice how not only has the condition been wrapped in a try-except but the condition has been reversed is now an assert, the reason for this is assert will throw an error if the assertion isn't true, so flipping the condition has the intended effect as we want the file to fail if it does get run directly. When run directly the assertion turns out to be wrong which raises an AssertionError which in turn throws a RuntimeError and thus the program exits with an appropriate exit code and the user is informed of what they did wrong.

Now we need to sort the Python 2 catch, and this is where it gets a little awkward, as its important that the code never touches syntax it doesn't like or it will fail. Simply put, it won't be able to use our error handler, it will have to mimic it, because not only is the function syntax all wrong, Python 2 doesn't understand f-strings. So if you play around with it a bit you should find this will work:

# Check python version, display error and exit if wrong
try:
    if sys.version_info.major != 3:
        raise RuntimeError("run with wrong Python version")
except RuntimeError as e:
    print("{} (0x01) Program only supports Python 3.".format(type(e).__name__))
    sys.exit(0x01)

Much of this should be starting to look familiar now, the if statement is left intact and encased in a try-except, the sys.exit is replaced with a RuntimeError which then is caught so a message can be printed then exited. The only really "new" bit is the print method, it uses the more traditional string formatting, taking advantage of the fact that the error here is a known quantity, this is completely acceptable to Python 2 and should provide a basically identical style of error message to be viewed. I'll leave the testing that this does exactly what its supposed to do to you for now, and I'll be back next week to put the finishing touches on this boilerplate code.

Previous Post Next Post