I've been working on my first Python library which provides different utilities for a set of bioinformatics tools. I am looking for feedback on this module I made for parsing configurations needed for each tool run.
To give a little more context, users are prompted with a text box to edit the default parameters that a tool runs with given a list of different acceptable values that each option may take on.
config.py
# -*- coding: utf-8 -*-
"""
toolhelper.config
~~~~~~~~~~~~~~~~~
This module provides the needed functionality
for parsing and verifying user-inputted config files.
"""
import sys
import logging
PY2 = sys.version_info[0] == 2
if PY2:
import ConfigParser
else:
import configparser as ConfigParser
class Config(object):
"""This class implements config file parsing and validation.
Methods:
...
Attributes:
...
"""
def __init__(self, defaults,
value_map,
filename,
sections,
numericals=None,
allow_no_val=False):
self.defaults = defaults
self.value_map = value_map
self.filename = filename
self.sections = sections
self.numericals = numericals
self.settings = self._parse()
self.allow_no_val = allow_no_val
def _parse(self):
"""Builds dict from user-inputted config settings.
Returns:
Dictionary of config settings to be used for a tool run.
"""
config = ConfigParser.ConfigParser(allow_no_value=self.allow_no_val)
cfg = config.read(self.filename)
if not cfg:
msg = 'Config file not readable: Using default parameters.'
logging.error(msg)
return self.defaults
settings = {}
# Iterate over defaults keys to get each option to lookup
for option in self.defaults:
try:
input_val = _get_config_value(config, self.sections, option)
numerical = option in self.numericals.keys()
# Some of the tools allow for cases where the user can choose from
# many possible unlisted values for a specific option. This is assumed
# when option is in defauls by not value_map
in_value_map = option in self.value_map.keys()
in_defaults = option in self.defaults.keys()
# Might want to make this behavior more explicit.
if in_defaults and in_value_map and not numerical:
value = input_val
msg = 'Will check value for option: {} in specific tool.'.format(option)
logging.debug(msg)
else:
value = self._value_map_lookup(option, input_val, num=numerical)
except (InvalidValueError, InvalidOptionError):
msg = ('Invalid or missing entry for {}. Using default: '
' {}.'.format(option, self.defaults[option]))
logging.error(msg)
settings[option] = self.defaults[option]
else:
logging.debug('parameter %s = %s is valid.', option, value)
settings[option] = value
return settings
def _value_map_lookup(self, option, value, num=False):
"""Get value corresponding to the value argument in value_map.
Args:
value: A key to lookup in the value_map dict.
numerical: dictionary of numerical entries and their ranges.
Returns:
A value obtained from value_map that is suited for the tool's
logic. value_map is a map of user-inputted values to the values
that are needed by the tool.
value_map[option][value], or float(value) if num=True and the
value is not in value_map.
Raises:
InvalidValueError: Value is not found in value_map dictionary. The given
Value is invalid
"""
if value in self.value_map[option].keys():
return self.value_map[option][value]
elif num:
try:
float_val = float(value)
_check_range(float_val, self.numericals[option][0], self.numericals[option][1])
except ValueError:
raise InvalidValueError
else:
return float_val
else:
raise InvalidValueError
def _get_config_value(config, sections, option):
""" Fetches value from ConfigParser
A wrapper around ConfigParser.get(). This function checks
that the config has the given section/option before calling
config.get(). If the config does not have the option an
InvalidOptionError is raised.
Args:
config: A RawConfigParser instance.
sections: list of config sections to check for option.
option: The parameter name to look up in the config.
Returns:
Parameter value for corresponding section and option.
Raises:
InvalidOptionError: The config is missing the given option argument.
"""
for section in sections:
if config.has_option(section, option):
return ''.join(config.get(section, option).split()).lower()
raise InvalidOptionError
def _check_range(value, lower, upper):
"""Check if lower < value < upper or lower < value if upper is 'inf'
Args:
value: A number whose range is to be checked against the given range
lower: The lower limit on the range
upper: The upper limit on the range
Raises:
InvalidValueError: value not in range
"""
if upper == 'inf':
if value >= lower:
return
elif lower <= value <= upper:
return
raise ValueError
class InvalidValueError(Exception):
"""Exception class for invalid values
Exception for when the user enters a value that
is not a valid option. Raise this exception when the
user-inputted value in not in valid_options[key].
"""
class InvalidOptionError(Exception):
"""Exception class for invalid options
Exception for when the user gives an unexpected or invlaid
option. Raise this exception to handle user-inputted options
that do not belong in the config or needed options that are
missing.
"""
A config object is instantiated with:
- a dictionary that represents a tools default config parameters
# Default configuration values for this tool. Equivalent to Default_Parameters.txt.
# When adding keys and values to this dictionary, make sure they are lower case and
# match the values to the values in the VALUE_MAP dict.
DEFAULTS = {
'counting method' : 'templates',
'productive only' : True,
'resolved only' : True,
'vj resolution' : 'gene',
'correction' : 'BY',
'alpha' : 0.05,
}
- A value map dictionary which holds the acceptable values for each config option with each value mapped to what the tool needs to run internally.
# A nested dict where the outer dictionary keys are all options, and each nested
# dictionary maps acceptable user-inputted values to the corresponding values
# needed for the tool internally.
# When adding entries to be sure all strings are lowercase and devoid of whitespace.
# Note this dict does not account for numerical entries
VALUE_MAP = {
'couting method' : {
'templates' : 'templates',
'rearrangement' : 'rearrangement'
},
'productive only' : {
'true' : 'productive',
'false' : 'nonproductive'
},
'resolved only' : {
'true' : True,
'false' : False
},
'vj resolution' : {
'gene' :'gene',
'family' : 'family',
'allele' : 'allele'
},
'correction' : {
'bh' : 'BH',
'bonferroni' : 'bonferroni',
'by' : 'BY',
'fdr' : 'fdr',
'none' : None,
}
}
The filename of the file where the user configuration lives for a specific tool run.
The sections in the configuration.
SECTIONS = ['union', 'significance']
- Dictionary of configuration options that can take on a numerical value in a specific range.
# configuaration settings that may be numerical
# keys: numerical options; values: tuples that embed accepted range.
NUMERICALS = {
'alpha' : (0, 1)
}
A tool will then make a config instance and grab the parsed and validation settings from the config.settings attribute.
configuration = config.Config(settings.OUTPUT_DIR,
settings.DEFAULTS,
settings.VALUE_MAP,
settings.CONFIG_FILE,
settings.SECTIONS,
settings.NUMERICALS)
parameters = configuration.settings
Each tool initially had it's own configuration checking method with nested try-except blocks checking each config option and it was a total mess. I spent a lot of time trying to think how to best represent and validate the config data to hopefully minimize code complexity in each tool.