"""See top level package docstring for documentation"""
import argparse
import configparser
import copy
import logging
import os
import pathlib
import sys
import attr
import dotmap
myself = pathlib.Path(__file__).stem
# configure library-specific logger
logger = logging.getLogger(myself)
logging.getLogger(myself).addHandler(logging.NullHandler())
########################################################################
# public module-level variables: spec, unparsed, opt, section
# section is currently not implemented
spec = dotmap.DotMap()
"""Empty dotmap object for ease of initialization"""
opt = dotmap.DotMap(_unparsed=None)
"""
opt : dotmap.DotMap
Global data structure storing top-level config options
Example ini config file::
top_level_option = topthing
[some_section]
secondary_option = something
Results in opt being defined as::
DotMap(top_level_option='topthing')
To access::
print(optini.opt.top_level_option)
"""
section = dotmap.DotMap()
"""
Note: not supported yet
section : dotmap.DotMap
Global data structure storing secondary config options (ini section)
Example ini config file::
top_level_option = something
[some_section]
secondary_option = something
Results in secion being defined as::
DotMap(some_section=DotMap(secondary_option='something'))
To access::
print(optini.section.some_section.secondary_option)
"""
# private module-level variables
_lock = None
"""Only main user interface code should initialize config"""
_LOGGINGSPEC = {
# verbose corresponds to INFO log level
'verbose': {
'help': 'report performed actions',
},
'debug': {
'help': 'report planned actions and diagnostics',
},
'quiet': {
'help': 'avoid all non-error console output',
},
'Log': {
'help': 'log output to logfile (see File4log option)',
},
'File4log': {
'type': str,
'help': 'log file',
'default': 'optini.log',
},
}
_IOSPEC = dotmap.DotMap()
_IOSPEC.input.type = argparse.FileType('r')
_IOSPEC.input.default = sys.stdin
_IOSPEC.input.help = 'input file (default: stdin)'
_IOSPEC.output.type = argparse.FileType('w')
_IOSPEC.output.help = 'output file (default: stdout)'
_IOSPEC.output.default = sys.stdout
########################################################################
# helper functions
[docs]def log_spec(spec):
"""Procedure to log contents of spec, one message per item"""
for optname, optconfig in spec.items():
logger.debug(f"option: {optname}")
[logger.debug(f" {k} = {v}") for k, v in optconfig.items()]
########################################################################
[docs]@attr.s(auto_attribs=True)
class Config:
"""
Class to get options from command line and config file
Examples
--------
Define one boolean option, someopt, which defaults to false; users
can specify -s at the command line, or put someopt = true in the
config file.
.. code-block:: python
import optini
optinispec.someopt.help = 'Set a flag'
# implies -s and --someopt command line options
optini.Config(spec=spec, file=True)
if optini.opt.someopt:
print("someopt flag is set")
Config file defaults to ~/.<appname>.ini
Attributes
----------
appname : str
Application identifier (required)
description : str
If not None, optini will use this in argparse usage message
desc : str
Alias for description
epilog : str
Text to display after the argument help
file : bool (default: False)
Enable config file
filename : str (default: <appname>.ini)
If provided, optini will use this file as config file
logging : bool (default: False)
Incorporate logging config, with (mostly) conventional options
(-v, -d, -q, -L, -F LOGFILE)
io : bool (default: False)
Incorporate file input/output with conventional options
(-i inputfile, -o outputfile; defaults: stdin/stdout)
skeleton : bool (default: True)
Create default config file if using config files
spec : dotmap.DotMap or dict
Mapping of option names to option configuration
"""
appname: str
description: str = None
# use desc as the canonical value
desc: str = None
epilog: str = None
file: bool = False
filename: str = None
io: bool = False
logging: bool = False
skeleton: bool = True
spec: bool = None
def __attrs_post_init__(self):
"""Constructor"""
global _lock
if _lock is not None:
logger.warning('configuration already initialized')
logger.warning('only top-level module should initialize config')
logger.warning(f"lock held by {_lock}")
logger.warning('cowardly refusing to proceed')
return
_lock = self.appname
self.desc = self.description if self.description else self.desc
self._set_spec()
# prepare option specification
self._update_loggingspec()
self._set_optspec()
# set configuration from defaults, then config file, then arguments
self._set_config_defaults()
self._parse_config_file()
self._parse_args()
self._merge()
self._configure_logging()
def _set_spec(self):
"""Default to module-level spec variable for input"""
# this streamlines user config (no need to import dotmap module)
# for example:
# import optini
# optini.spec.path.type = str
# optini.spec.path.help = 'File search path'
# confobj = optini.Config(file=True)
if self.spec is None:
logger.debug('no spec provided, using module-level spec')
self.spec = spec
log_spec(self.spec)
def _configure_logging(self):
if not self.logging:
return
# numeric log levels, according to logging module:
# CRITICAL 50, ERROR 40, WARNING 30, INFO 20, DEBUG 10, NOTSET 0
# by default, only show warning and higher messages
loglevel = logging.WARNING
if opt.verbose:
loglevel = min(loglevel, logging.INFO)
if opt.debug:
loglevel = min(loglevel, logging.DEBUG)
handlers = []
if not opt.quiet:
handlers.append(logging.StreamHandler())
if opt.Log:
handlers.append(logging.FileHandler(opt.File4log))
format = f"{self.appname}: %(name)s: %(levelname)s: "
if opt.debug:
# yyy
# format += '%(pathname)s line %(lineno)s: %(message)s'
format += '%(message)s'
else:
format += '%(message)s'
logging.basicConfig(
level=loglevel,
handlers=handlers,
format=format,
)
if opt.Log:
logger.info(f"logging to {opt.File4log}")
[docs] def skeleton_configfile(self):
'Generate sample config file showing default values'
contents = []
contents.append(f"# {self.appname} configuration file\n")
for option in self._optspec:
# ignore options marked as not for config file
if 'configfile' in self._optspec[option]:
if not self._optspec[option].configfile:
continue
lhs = option
if 'default' in self._optspec[option]:
if type(self._optspec[option].default) is bool:
rhs = str(self._optspec[option].default).lower()
else:
rhs = self._optspec[option].default
else:
if self._optspec[option].type is bool:
rhs = 'false'
else:
rhs = "''"
if 'help' in self._optspec[option]:
contents.append(f"# {self._optspec[option].help}")
contents.append(f"#{lhs} = {rhs}\n")
return('\n'.join(contents))
[docs] def merge_spec(self, spec):
"""
Procedure to add option specifications
- On option name clash, new options override old options
- This procedure also adds various defaults
Parameters
----------
spec : dotmap.DotMap or dict
Option specification
"""
if not type(spec) in {dict, dotmap.DotMap}:
logger.warning('expecting dict or dotmap.DotMap')
logger.warning(f"ignoring option specification: {spec}")
return
# convert spec to a DotMap object if necessary
spec = dotmap.DotMap(spec)
# process this spec then merge it in at the end of procedure
for optname, optconfig in dotmap.DotMap(spec).items():
# create a new object to iterate over
# this allows updating the original
logger.debug(f"merge_spec: processing option spec {optname}")
# set default type
if 'type' not in optconfig:
# default type is bool (for option flags)
spec[optname].type = bool
# set default action and default value
# make sure to reference the potentially updated spec
# (rather than the optconfig set in the current iteration)
if spec[optname].type is bool:
if 'action' not in optconfig:
spec[optname].action = 'store_true'
# 'count' would be another reasonable possibility
if 'default' not in optconfig:
spec[optname].default = False
else:
if 'default' not in optconfig:
spec[optname].default = None
logger.debug('processed option specification, prior to merge:')
log_spec(spec)
self._optspec.update(spec)
def _update_loggingspec(self):
'Procedure to update logging defaults with runtime info'
# updating _LOGGINGSPEC depends on appname
# appname is only known at runtime
logfile = f"{self.appname}.log"
_LOGGINGSPEC['File4log'] = {
'type': str,
'help': f"Log file (default: {logfile})",
'default': f"{logfile}",
}
def _set_optspec(self):
'determine and augment option specification'
# option specification, DotMap form
# we will iterate over self.spec to create this
self._optspec = dotmap.DotMap()
self.merge_spec(self.spec)
if self.logging:
self.merge_spec(_LOGGINGSPEC)
if self.io:
self.merge_spec(_IOSPEC)
def _set_config_defaults(self):
'Procedure to set default option values based on spec'
logger.debug('setting option value defaults')
for optname, optconfig in self._optspec.items():
# after _set_optspec(), all items should have a default
opt[optname] = optconfig.default
logger.debug('final default option values:')
[logger.debug(f" {k} = {v}") for k, v in opt.items()]
def _parse_config_file(self):
"""Procedure to parse config file, if config files are enabled"""
# initialize config file parser even if config file is not enabled
# this is so iteration works without issue in the merge code
self._configparser = configparser.ConfigParser(allow_no_value=True)
if not self.file:
logger.debug('config files not enabled')
return
if self.filename is None:
# default config file = $HOME/.<appname>.ini
home = os.environ['HOME']
self.filename = f"{home}/.{self.appname}.ini"
logger.debug(f"config file = {self.filename}")
if self.skeleton:
if not os.path.exists(self.filename):
# create a skeleton config file
logger.debug(f"no such file: {self.filename}")
logger.debug('creating skeleton config file')
open(self.filename, 'w').write(self.skeleton_configfile())
with open(self.filename) as f:
# support options without an ini section header
# to do this, prepend an implicit default [optini] section
config_file_content = f"[optini]\n{f.read()}"
self._configparser.read_string(config_file_content)
# also save variables defined in all config file subsections
# subsection variables are not added to default global opt variable
# instead, subsection variables can be accessed through section
# subsections are useful for libraries etc.
# all values are collected (not limited to optspec)
global section
for sectname in self._configparser.sections():
for optname in self._configparser.options(sectname):
logger.debug(f"self.filename: [{sectname}] {optname}")
# yyy might need to do something to get typed options
section[sectname][optname] = section.get(optname)
def _parse_args(self):
"""Procedure to populate self._args from command line arguments"""
parser = argparse.ArgumentParser(
description=self.desc,
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=self.epilog,
)
# derive add_argument kwargs from option specification
for optname, optconfig in self._optspec.items():
kwargs = copy.deepcopy(optconfig)
# ignore invalid argparse keys
# (these keys are added optini functionality)
kwargs.pop('configfile')
kwargs.pop('type')
# short and long need to be here, because:
# - they are not argpase keys like 'action' or 'help'
# - instead, they are passed as separate initial arguments
# short option form defaults to first character of option name
if 'short' in kwargs:
short = kwargs.pop('short')
else:
short = f"-{optname[0:1]}"
# long option form defaults to --option name
if 'long' in kwargs:
long = kwargs.pop('long')
else:
long = f"--{optname}"
# at this point kwargs is optconfig minus custom keys
parser.add_argument(long, short, **kwargs.toDict())
# argparse returns (Namespace, list)
parsed_args, unparsed_args = parser.parse_known_args()
# save any remaining, unparsed command line arguments
opt._unparsed = unparsed_args
self._args = dotmap.DotMap(vars(parsed_args))
def _merge(self):
"""Merge command line, config file, and default options"""
# for each option in the specification, check config file then args
for optname, optconfig in self._optspec.items():
logger.debug('merging in options from config file')
# override defaults with values from config file
if self.file:
# 'optini' is the default implicit section name
section = self._configparser['optini']
if optname in section:
# ignore options marked as not for config file
if 'configfile' in optconfig:
if not optconfig.configfile:
continue
if optconfig.type is str:
opt[optname] = section.get(optname)
elif optconfig.type is int:
opt[optname] = section.getint(optname)
elif optconfig.type is float:
opt[optname] = section.getfloat(optname)
elif optconfig.type is bool:
opt[optname] = section.getboolean(optname)
else:
logger.warning('unable to process config file opt')
logger.warning(f"specific issue: {optname}")
logger.debug('merging in options from command line')
# override defaults, config file values with command line args
if optname in self._args and self._args[optname] is not None:
if self._optspec[optname].type is int:
# attempt to convert value to int
try:
opt[optname] = int(self._args[optname])
except ValueError:
logger.warning('command line option type issue')
logger.warning(f"option type mismatch: {optname}")
logger.warning('ignoring type hint: int')
logger.warning(f"treating {optname} as str")
logger.debug(f"option spec: {self._optspec[optname]}")
opt[optname] = self._args[optname]
if self._optspec[optname].type is float:
# attempt to convert value to float
try:
opt[optname] = float(self._args[optname])
except ValueError:
logger.warning('command line option type issue')
logger.warning(f"option type mismatch: {optname}")
logger.warning('ignoring type hint: float')
logger.warning(f"treating {optname} as str")
logger.debug(f"option spec: {self._optspec[optname]}")
opt[optname] = self._args[optname]
else:
# argparse can handle string and boolean
opt[optname] = self._args[optname]
def __str__(self):
ret = [f"options from config file ({self.filename}):"]
for sectname in self.configparser.sections():
ret.append(f"config file section: {sectname}")
for option in self.configparser.options(sectname):
optval = self.configparser.get(sectname, option)
ret.append(f" {option} = {optval}")
ret.append("\noptions from command line:")
ret.append(str(self._args))
ret.append("\nconfigured options:")
ret.append(str(self.opt))
return("\n".join(ret))