Library Design Gotchas: Configuration loading
A friend was complaining about this library they were trying to use that was failing to load a configuration from a file. The resulting dive into the code inspired this post about inappropriate choices made when designing how a library is configured.
It isn’t my intention to pick on pyart. I appreciate the hard work the developers did to create it and open source it. It is just the example at hand.
In the instance above, simply executing this line within the production environment at my friend’s job was failing:
import pyart
and the backtrace pointed to the configuration file loader. The
library tries to load its configuration from a file. Unfortunately, in the
production environment, the Python code is bundled into a custom archive. Their
environment adds additional module loaders to the Python import machinery so
that simple import foo
statements can import from the archive. pyart is
directly using SourceFileLoader
, which is not aware of this. It tries to treat
the archive as a directory and fails. Reading more of that code, pyart is
violating several conventions that make configuring the library difficult.
Keep configuration within the API
By its nature, a library cannot control the execution environment users will use it in. This means the sole method of configuring the library must not depend on the execution environment. In this case it is loading a file and retrieving constants from it. In another case it might be reading values from environment variables. These methods constrain the places your library can be used.
At the lowest level a library should offer a configuration option that is
entirely within the bounds of the language it is written in, as that is the
only constraint to which the user of your library is also bound. In Python
this can mean letting a user pass a Configuration
object to the library
initialization routine. In C, this may involve a struct. This allows the user
to retrieve configuration from wherever they want, create the configuration
object, then start the library. They might be retrieving one setting from the
user’s location, another from the current temperature, and a third from a radio
transmission! They might be running code on embedded hardware that has no
filesystem! This will still work because clearly they have enough
infrastructure to get the language runtime executing.
If you’d like to expose a default configuration, it is trivial to export a
DefaultConfiguration
object or constant that creates a Configuration
with
the right values.
Configuration should not be global state
When the user passes some configuration to your library, avoid modifying global library state with it. There are rare situations when an application needs to store configuration in global state, but I’ve yet to encounter a situation where a library has to do this.
Global state is bad for a bunch of other reasons. My top two – forced singletons and difficulty testing.
If an application wants to use the same library in multiple places in the code base, global state makes it impossible to use different configurations at those 2 points.
Testing also becomes annoying. Any tests the user of your library writes can no longer be run in parallel. If they want to speed up test execution, they’ve to now jump through a lot of hoops. If the user is not aware of internal global state, they will encounter hard to reproduce race conditions that will leave them scratching their head.
Provide helpers
Once your basic configuration is moved into a data structure, it is definitely nice to provide utility functions that can read a configuration from a byte stream or a file. This is particularly useful if your configuration is complicated, or you need some validation beyond just “this is a string, this is a bool”. When the user is in a circumstance where they can use these helpers, they will be glad you provided them. Just don’t let it be the only way to configure the library. Don’t give in to the temptation to mix I/O and parsing!
Keep imports and initialization separate
I feel the situation is particularly bad in Python-land because arbitrary code execution is allowed during import. Avoid running code during an import. The import exists to declare classes and functions! Initialization must be a separate process. The library user may not want to use your library for several days after their app first starts running!
If pyart had not tried to initialize itself as soon as it was imported, things
would have been usable even with the previous two violations. The application
could’ve imported pyart along with all its other imports. Then it could read
the default_configs.py
file using some code that knew how to find the file in
a production environment, write it out to a normal directory, modify the
environment variable, then initialize pyart. Now the application code has to do
all this before it can even import pyart. This makes the application code ugly.
It has the import statement somewhere two levels deep in some function.
If you need global state, have a boolean (use threading.Event
for
thread-safety) that determines if the init()
or similar function is called,
and fails other parts of the library API if it hasn’t.
You may have a legitimate use case where you’d like users to be able to use parts of your library as an application to do some basic tasks. For example your library may be able to process a CSV or something.
The library should ship with separate scripts that use the library as a library, and ask the user to run those scripts as binaries. In languages like Python, you can even hide the script like behavior behind
if __name__ == '__main__':
do_some_work()
Ship it!
Library design is always challenging as authors have to balance usability and configurability. Exposing a nice way to configure the library leads to a pleasant initial experience for the user as they learn to use your library, and also lets them adapt a library to the needs of their application without jumping through hoops. It makes them happy!