Using ChainMap for defaults and overrides

We'll often have a configuration file hierarchy. Previously, we listed several locations where configuration files can be installed. The configparser module, for example, is designed to read a number of files in a particular order and integrate the settings by having later files override values from earlier files.

We can implement elegant default-value processing using the collections.ChainMap class. You can refer to Chapter 7, Creating Containers and Collections for some background on this class. We'll need to keep the configuration parameters as dict instances, which is something that works out well using exec() to evaluate Python-language initialization files.

Using this will require us to design our configuration parameters as a flat dictionary of values. This may be a bit of a burden for applications with a large number of complex configuration values, which are integrated from several sources. We'll show you a sensible way to flatten the names.

First, we'll build a list of files based on the standard locations:

from collections import ChainMap 
from pathlib import Path 
config_name = "config.py"
config_locations = (
Path.cwd(),
Path.home(),
Path("/etc/thisapp"),
# Optionally Path("~thisapp").expanduser(), when an app has a "home" directory
Path(__file__),
)
candidates = (dir / config_name
for dir in config_locations)
config_paths = (path for path in candidates if path.exists())

We started with a list of directories showing the order in which to search for values. First, look at the configuration file found in the current working directory; then, look in the user's home directory. An /etc/thisapp directory (or possibly a ~thisapp directory) can contain installation defaults. Finally, the Python library will be examined. Each candidate location for a configuration file was used to create a generator expression, assigned to the candidates variable. The config_paths generator applies a filter so only the files that actually exist are loaded into the ChainMap instance.

Once we have the names of the candidate files, we can build ChainMap by folding each file into the map, as follows:

cm_config: typing.ChainMap[str, Any] = ChainMap()
for path in config_paths:
config_layer: Dict[str, Any] = {}
source_code = path.read_text()
exec(source_code, globals(), config_layer)
cm_config.maps.append(config_layer)

simulate(config.table, config.player, config.outputfile, config.samples)

Each file is included by creating a new, empty map that can be updated with local variables. The exec() function will add the file's local variables to an empty map. The new maps are appended to the maps attribute of the ChainMap object, cm_config.

In ChainMap, every name is resolved by searching through the sequence of maps and looking for the requested key and associated value. Consider loading two configuration files into ChainMap, giving a structure that is similar to the following example:

ChainMap({},
{'betting_rule': Martingale(),
...
},
{'betting_rule': Flat(),
...
})

Here, many of the details have been replaced with ... to simplify the output. The chain has a sequence of three maps:

  1. The first map is empty. When values are assigned to the ChainMap object, they go into this initial map, which will be searched first.
  2. The second map is from the most local file, that is, the first file loaded into the map; they are overrides to the defaults.
  3. The last map has the application defaults; they will be searched last.

The only downside is that the reference to the configuration values will be using dictionary notation, for example, config['betting_rule']. We can extend ChainMap() to implement the attribute access in addition to the dictionary item access.

Here's a subclass of ChainMap, which we can use if we find the getitem() dictionary notation too cumbersome:

class AttrChainMap(ChainMap):

def __getattr__(self, name: str) -> Any:
if name == "maps":
return self.__dict__["maps"]
return super().get(name, None)

def __setattr__(self, name: str, value: Any) -> None:
if name == "maps":
self.__dict__["maps"] = value
return
self[name] = value

We can now say config.table instead of config['table']. This reveals an interesting restriction on our extension to ChainMap, that is, we can't use maps as an attribute. The maps key is a first-class attribute of the parent ChainMap class, and must be left untouched by this extension.

We can define mappings from keys to values using a number of different syntaxes. In the next section, we'll take a look at JSON and YAML format for defining the parameter values.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset