Dynamic Modules

In the previous post I mentioned that I’d need to dynamically load the state modules at runtime. Luckily, Python provides a module – importlib – to do just this.

In order to load our states into the system, we pass a list of dictionary objects into our Game initialiser, which in turn passes this onto the StateManager initialiser. This object looks like this:

states = [
    {"name": "<Name of the class>",
     "file": "<filename containing the class (minus the .py extension)",
     "default": <Boolean True if this is the default state, false otherwise>
    },
...
]

Within our StateManager class, the __init__ method defines instance variables for the state stack and the current state as follows:

self.state_stack = []
self.current_state = None

It then calls a private internal method to load the states into these variables and prepares to bootstrap itself – note that the self.states variable is simply the loaded definition of the states so that they can be referenced as needed later:

self.states = self._load_states(states)

It’s within the _load_states method that the loading and preparation takes place, so let’s first see the code, and then analyse the breakdown:

    def _load_states(self, states):
        """Private method used to load the state matrix into the StateManager
        object from their individual modules"""
        state_defs = {}

        for this_state in states:
            try:
                # Do the import
                module = importlib.import_module(this_state['file'])
                class_ = getattr(module, this_state['name'])
                state_defs[this_state['name']] = class_()
            except ModuleNotFoundError:
                # Raise an exception on error
                print(f"The module "
                      f"{this_state['file']}.py could not be loaded.  Aborting.")
                sys.exit(ModuleNotFoundError)
            except AttributeError:
                # Raise an exception on error
                print(f"The class {this_state['name']} cound not be found in "
                      f"module {this_state['file'].py}.  Aborting.")
                sys.exit(AttributeError)

            if this_state['default'] is True:
                self.push(state_defs[this_state['name']])

        # Now that we've imported all the states, invalidate the cache so
        # that Python can recognise the new code
        importlib.invalidate_caches()

        return state_defs

The local variable state_defs is first created as an empty dictionary. It’s this dictionary that we’ll eventually return to the StateManager object so that we have references to all the loaded modules for later. We then move into the main loop which does the heavy lifting in lines 6-24, and wrap this in a try/except construct so that we can bail if there’s an issue with reading the module classes into the program.

Within the try, we use the import_module() method to read the specified python file, and store a reference to this file in the local variable module. We then need to scan this module using the getattr() method to extract the named class definition. Finally, once we have the class definition (stored in the class_ variable), we instantiate it by implicitly calling its __init__ function and storing the resulting instance object in the state_defs dictionary with the class name as its key.

Now, all of that is potentially error prone, so we trap two potential error types in the except clause:

  • ModuleNotFoundError gets thrown if the Python runtime cannot find the .py file containing the class that we’ve referenced.
  • AttributeError gets thrown if the names class doesn’t actually reside in the file we’ve just loaded.

In either case, we simply report an error back to stdout and exit the program, throwing the error back out as an exit code.

At this point, if we’ve not errored, then the module has been loaded, initialised and pushed into our cache of available states. In order to bootstrap the system, we also need to check if the state we’ve just loaded is the first one we need to run. This is done in line 23 above which checks whether the ‘default’ flag is set to True for this particular state. If it is, then we invoke the push method of the StateManager to insert the state onto the active state stack.

Finally, once all the modules have been read in, we call the invalidate_caches() method of importlib to ensure that Python refreshes its internal state with the new code. If we don’t do this, we run the risk of runtime errors further down the line as Python may be unaware of the presence of the new code.

Once this is done, we return our state dictionary back to the __init__ module for safe storage. The reason for passing this back rather than setting self.states directly in the method is because Python will complain if we try to define an instance variable outside of the __init__ method, so doing things this way is cleaner.

The push() method which we invoke, adds the referenced state to the state stack and sets the instance variable current_state to point to it. It also calls the state’s startup code in order that state initialisation can occur.

And with this done, we have our state management model complete. We can now begin the task of creating the individual states for controlling the game as a whole.

The code is available on GitHub here:

Good order is the foundation of all things.

Edmund Burke

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.