tl; dr
Okay okay, okay! If you google "python configuration", you'll get tons of answers with third party nice packages that are capable of managing configuration objects from any format (YAML, JSON, XML, TOML, .ini like, and lots of others) that are stord either in system wide config areas or custom places, with butter and cookies...
The purpose of this post is not to compare all these good but heavy solutions, but to describe a simple solution that:
- Does not need any additional third party package
- Store configuration data in Python files. (Thus if strong security matters, you may find this harmful.)
- Let the user provide a custom configuration in a Python file which globals take over the default configuration values.
Attention!
If you love the way this recipe provides custom options to your application, from now, you should consider using pyflexconfig in place of this recipe.
The files layout
This is a very small and useless app that demonstrates this idea, no more.
├── app │ ├── __init__.py -> Nothing interresting for our demo, it just makes the package │ ├── __main__.py -> Entry point. Only prints options - customized or not │ ├── defaultoptions.py -> The default options and values │ └── settings.py -> Exports the "conf" objects with config data in attributes
__main__.py
This is just a "do nearly nothing" application that does not deserve more comments than the... included comments. Just note the from settings import config that lets you access to the - maybe customized - configuration options.
""" =========== __main__.py =========== This is just a simple demo that shows how it's simple to use the merged default and custom configuration options. """ # Access your config data from this object's attributes from settings import config def main(): print("Hello, this is the main entry point.") # Use some config options print(config.OPTION_1) print(config.OPTION_2) print(config.OPTION_3) if __name__ == "__main__": main()
defaultoptions.py
Attention!
Do not hesitate to comment in depth each option of a real project and add a copy of this module in your package documentation (this can be easily automated with Sphinx) since the end user needs it to customize the app with his custom options.
This module provides the default options names and values from its globals with uppercase names with no leading underscore. This is just a dummy example.
""" ============== defaultoptions ============== A special module which globals are available through the config namespace unless not "forbidden". See the rules in the "settings" module. .. warning:: Do **not** import here from elsewhere in your app unless you may raise a circular import error. Anyway, imports from the stdlib or 3rd party package are harmless. """ OPTION_1 = "Default value for option 1" OPTION_2 = "Any Python object" # These options will not be available because... stuff = 1 # Starts with a lowercase _OPTION = None # Starts with "_" # Anyway you may use "hidden" intermediate objects to build public options _intermediate = "anything" OPTION_3 = {"key": _intermediate}
settings.py
This is the key module - bones and meat - of this blog article. Leveraging the - not very well known - runpy module <https://docs.python.org/3.6/library/runpy.html#module-runpy> from stdlib to "parse" both default (defaultoptions.py from above) and custom (if any) configuration files. The resulting configuration data are exposed as attributes of the config object of this module.
Note that we use below the APP_CUSTOM_OPTIONS environment variable to tell where's the custom configuration data. Of course you may rename it such it relates to your app name.
""" =========== settings.py =========== The resources provided here provide the merged default and custom options in a Namespace named "config". See near the end of this module. Example:: from app.settings import config ... some_option = config.SOME_OPTION """ import pathlib import os import runpy import types import warnings # This environment var, if set, should be the path (absolute or relative) to a # Python file that overrides some of the default options from # "defaultoptions.py". CUSTOM_OPTIONS_ENVVAR = "APP_CUSTOM_OPTIONS" def keep_upper_names(options_dict: dict) -> None: """Remove disallowed option names""" def name_rejected(name: str) -> bool: """True if not an allowed option name. Legal names are: - All uppercases with potential "_" or [0..9] inside - Don't start with "_" """ return name.startswith("_") or name.upper() != name # Remove "illegal" option names. for name in list(options_dict): if name_rejected(name): del options_dict[name] # This is the default options dict default_options = runpy.run_module("defaultoptions") keep_upper_names(default_options) # This will build the "custom_options" dict custom_options = {} custom_options_file = os.getenv(CUSTOM_OPTIONS_ENVVAR) if custom_options_file: custom_options_file = pathlib.Path(custom_options_file) if custom_options_file.is_file(): custom_options = runpy.run_path(custom_options_file) keep_upper_names(custom_options) else: warnings.warn( f"No {custom_options_file} found. Fix or remove env var {CUSTOM_OPTIONS_ENVVAR}", ResourceWarning, ) # And finally the object that exposes the custom options merged with the default # ones as attributes. config = types.SimpleNamespace(**{**default_options, **custom_options})
Okay, time for the demo
If you copied exactly the files layout and contents, you may proceed to the demo, otherwise you should adapt what follows to your app layout and names.
- cd to the parent directory (the one that contains the app/ directory) and execute the command:
python app
This should display:
Hello, this is the main entry point. Default value for option 1 Any Python object {'key': 'anything'}
You have seen the default values of three options. Now let's start a custom configuration. Create in the same directory a customoptions.py file with only this line:
OPTION_1 = "Custom value for option 1"
We can now "tell" the app to use the custom options redefined in this file:
APP_CUSTOM_OPTIONS=customoptions.py python app
Now this displays:
Hello, this is the main entry point. Custom value for option 1 Any Python object {'key': 'anything'}
As you can notice, this only changed the value of OPTION_1 when the other options keep their default value.
Attention!
The examples work as is with Python 3.6 and up. Using an older Python version down to Python 2.7 may require some changes (no pathlib, fo f-strings, etc.)
Comments !