Source code for config_utils
"""Configuration file utilities for the MAIS simulation.
This module provides classes and helpers for reading, writing, and generating
INI-style configuration files used to parameterise simulation runs.
Key components:
- :func:`string_to_value`: Type-coercing string parser used when reading
INI values.
- :class:`ConfigFile`: Thin wrapper around :class:`configparser.ConfigParser`
for loading, saving, and querying individual INI files.
- :class:`ConfigFileGenerator`: Expands a template INI file containing
semicolon-separated parameter lists into a stream of fully-specified
:class:`ConfigFile` instances, one per parameter combination.
"""
import configparser
import os
import io
from sklearn.model_selection import ParameterGrid
[docs]
def string_to_value(s):
"""Convert a raw INI string value to the most appropriate Python type.
Tries type conversions in the following order:
1. ``int`` – if the entire string represents an integer.
2. ``float`` – if the entire string represents a floating-point number.
3. ``list`` – if the string contains a comma; it is split on commas and
each token is stripped of surrounding whitespace.
4. ``str`` – the original string is returned unchanged.
Args:
s (str): Raw string value read from a configuration file.
Returns:
int or float or list of str or str: The converted value.
"""
try:
return int(s)
except ValueError:
try:
return float(s)
except ValueError:
pass
if "," in s:
list_of_values = s.split(",")
return [val.strip() for val in list_of_values]
else:
return s
[docs]
class ConfigFile():
"""Wrapper around :class:`configparser.ConfigParser` for INI-style config files.
Provides convenience methods for loading from and saving to ``.ini``
files, serialising to a string, and reading sections as type-converted
dictionaries. Key-case is preserved (``optionxform = str``).
Args:
param_dict (dict, optional): If provided, a mapping of
``{section_name: {key: value}}`` pairs used to pre-populate
the underlying :class:`configparser.ConfigParser`. Defaults to
``None`` (empty configuration).
"""
def __init__(self, param_dict=None):
self.config = configparser.ConfigParser()
self.config.optionxform = str
if param_dict:
for name, value in param_dict.items():
self.config[name] = value
[docs]
def save(self, filename):
"""Write the configuration to a file or file-like object.
Args:
filename (str or file-like): If a string, the configuration is
written to that file path (UTF-8 encoded). Otherwise the
object is treated as a writable file-like and
``config.write()`` is called on it directly.
"""
if type(filename) == str:
with open(filename, 'w', encoding="utf-8") as configfile:
self.config.write(configfile)
else:
self.config.write(filename)
[docs]
def to_string(self):
"""Serialise the configuration to an INI-formatted string.
Returns:
str: The full INI representation of the configuration, equivalent
to what would be written by :meth:`save`.
"""
output = io.StringIO()
self.config.write(output)
ret = output.getvalue()
output.close()
return ret
[docs]
def load(self, filename):
"""Read a configuration from an INI file.
Args:
filename (str): Path to the ``.ini`` file to read.
Raises:
ValueError: If ``filename`` does not exist on the filesystem.
"""
if not os.path.exists(filename):
raise ValueError(f"Config file {filename} not exists. Provide name (including path) to a valid config file.")
self.config.read(filename)
[docs]
def section_as_dict(self, section_name):
"""Return the contents of a section as a type-converted dictionary.
Each raw string value in the section is converted by
:func:`string_to_value` to the most appropriate Python type.
Args:
section_name (str): Name of the INI section to retrieve.
Returns:
dict: Mapping of ``{key (str): value}`` for all entries in the
section, with values converted by :func:`string_to_value`. Returns
an empty dict if the section does not exist.
"""
sdict = self.config._sections.get(section_name, {})
return {name: string_to_value(value) for name, value in sdict.items()}
[docs]
def fix_output_id(self):
"""Resolve and replace the ``OUTPUT_ID.id`` field with a descriptive string.
Reads the ``id`` entry from the ``[OUTPUT_ID]`` section. If it
contains one or more ``section:key`` references (as a list or a
single string), each reference is resolved to its current value in
the configuration, and a composite identifier string of the form
``_Section_key=value`` is constructed and stored back into
``OUTPUT_ID.id``. Spaces in the resulting string are replaced with
underscores.
If ``OUTPUT_ID.id`` is not present, the method returns without making
any changes.
"""
output_id = self.section_as_dict("OUTPUT_ID").get("id", None)
if output_id is None:
return
text_id = ""
if not isinstance(output_id, list):
output_id = [output_id]
for variable in output_id:
section, name = variable.split(":")
text_id += f"_{section}_{name}={self.section_as_dict(section).get(name, None)}"
text_id = text_id.replace(" ", "_")
self.config["OUTPUT_ID"]["id"] = text_id
[docs]
class ConfigFileGenerator():
"""Generator that expands a template INI file into individual :class:`ConfigFile` instances.
The template INI file may contain semicolon-separated lists of values for
any key. The generator computes the Cartesian product of all such lists
(using :class:`sklearn.model_selection.ParameterGrid`) and yields one
fully-specified :class:`ConfigFile` per parameter combination.
After each config file is generated, :meth:`ConfigFile.fix_output_id` is
called to resolve any ``OUTPUT_ID`` references.
"""
def __init__(self):
self.config = configparser.ConfigParser()
self.config.optionxform = str # this is to keep the case of the keys in the config file
def _explode_lists(self, section):
"""Split each value in a config section on the ``';'`` separator.
Args:
section (dict): A dictionary of ``{key: raw_string_value}`` pairs
from a single INI section.
Returns:
dict: Mapping of ``{key: list of str}`` where each raw value has
been split into a list of alternative values.
"""
return {
name : value.split(";")
for name, value in section.items()
}
[docs]
def load(self, filename):
"""Load a template INI file and yield one :class:`ConfigFile` per parameter combination.
Each key that contains a ``';'``-separated list of values is treated
as a parameter with multiple options. The full Cartesian product of
all such options (across all keys and sections) is enumerated, and
each combination is yielded as a separate :class:`ConfigFile`.
Args:
filename (str): Path to the template ``.ini`` file. The file may
contain semicolon-separated lists of values for any key.
Yields:
ConfigFile: A fully-specified configuration for one parameter
combination, with ``OUTPUT_ID`` resolved via
:meth:`ConfigFile.fix_output_id`.
Raises:
ValueError: If ``filename`` does not exist on the filesystem.
"""
if not os.path.exists(filename):
raise ValueError(f"Config file {filename} not exists. Provide name (including path) to a valid config file.")
self.config.read(filename)
variable_names = {
section: list(self.config._sections[section].keys())
for section in self.config.sections()
}
# convert to dict
param_dict = {
section: self._explode_lists(self.config._sections[section])
for section in self.config.sections()
}
# convert each section to the list of final sections
param_dict = {
name: list(ParameterGrid(section))
for name, section in param_dict.items()
}
# do the outer parameter grid
param_dict = ParameterGrid(param_dict)
for params in param_dict:
x = ConfigFile(param_dict=params)
x.fix_output_id()
yield x
if __name__ == "__main__":
test_generator = ConfigFileGenerator()
for config in test_generator.load("../../config/info_verona.ini"):
print(config.section_as_dict("OUTPUT_ID").get("id", None))
print(config.to_string())
exit()
test_dict = {
"TASK": {"num_nodes": 10000},
"MODEL": {"beta": 0.155,
"gamma": 1/12.39,
"sigma": 1/5.2
}
}
test_config = ConfigFile(test_dict)
test_config.save("test.ini")
new_config = ConfigFile()
new_config.load("test.ini")
print(new_config.section_as_dict("TASK"))
print(new_config.section_as_dict("MODEL"))