"""improvements to the standard :module:`argparse` module"""
from __future__ import absolute_import
import argparse
import logging
import os
import os.path
from site import USER_BASE
from typing import Iterable, Optional, Sequence
try:
import yaml
except ImportError:
pass
# If you came here looking for how to share argument with subparsers,
# look at this instead:
# https://github.com/ARPA-SIMC/moncic-ci/blob/main/moncic/argparse.py
[docs]
class NegateAction(argparse.Action):
"""add a toggle flag to argparse
this is similar to 'store_true' or 'store_false', but allows
arguments prefixed with --no to disable the default. the default
is set depending on the first argument - if it starts with the
negative form (defined by default as '--no'), the default is False,
otherwise True.
originally written for the stressant project.
@deprecated use the BooleanOptionalAction from Python 3.9 instead,
although it doesn't have the default override we implemented here.
"""
negative = "--no"
def __init__(self, option_strings, *args, **kwargs):
"""set default depending on the first argument"""
kwargs["default"] = kwargs.get(
"default", option_strings[0].startswith(self.negative)
)
super(NegateAction, self).__init__(option_strings, *args, nargs=0, **kwargs)
def __call__(self, parser, ns, values, option):
"""set the truth value depending on whether
it starts with the negative form"""
setattr(ns, self.dest, not option.startswith(self.negative))
[docs]
class ConfigAction(argparse._StoreAction):
"""add configuration file to current defaults.
a *list* of default config files can be specified and will be
parsed when added by ConfigArgumentParser.
it was reported this might not work well with subparsers, patches
to fix that are welcome.
"""
def __init__(self, *args, **kwargs): # type: ignore[no-untyped-def]
"""the config action is a search path, so a list, so one or more argument"""
kwargs["nargs"] = 1
super().__init__(*args, **kwargs)
def __call__(self, parser, ns, values, option): # type: ignore[no-untyped-def]
"""change defaults for the namespace, still allows overriding
from commandline options"""
for path in values:
try:
# XXX: this is probably the bit that fails with
# subparsers and groups
parser.set_defaults(**self.parse_config(path))
except FileNotFoundError as e:
logging.debug("config file %s not found: %s", path, e)
else:
# stop processing once we find a valid configuration
# file
break
super().__call__(parser, ns, values, option)
[docs]
def parse_config(self, path: str) -> dict: # type: ignore[type-arg]
"""abstract implementation of config file parsing, should be overridden in subclasses"""
raise NotImplementedError()
[docs]
class YamlConfigAction(ConfigAction):
"""YAML config file parser action"""
[docs]
def parse_config(self, path: str) -> dict: # type: ignore[type-arg]
"""This doesn't handle errors around open() and others, callers should
probably catch FileNotFoundError at least.
"""
try:
with open(os.path.expanduser(path), "r") as handle:
logging.debug("parsing path %s as YAML" % path)
return yaml.safe_load(handle) or {}
except yaml.error.YAMLError as e:
raise argparse.ArgumentError(
self, "failed to parse YAML configuration: %s" % str(e)
)
[docs]
class ConfigArgumentParser(argparse.ArgumentParser):
"""argument parser which supports parsing extra config files
Config files specified on the commandline through the
YamlConfigAction arguments modify the default values on the
spot. If a default is specified when adding an argument, it also
gets immediately loaded.
This will typically be used in a subclass, like this:
self.add_argument('--config', action=YamlConfigAction, default=self.default_config())
This shows how the configuration file overrides the default value
for an option:
>>> from tempfile import NamedTemporaryFile
>>> c = NamedTemporaryFile()
>>> c.write(b"foo: delayed\\n")
13
>>> c.flush()
>>> parser = ConfigArgumentParser()
>>> a = parser.add_argument('--foo', default='bar')
>>> a = parser.add_argument('--config', action=YamlConfigAction, default=[c.name])
>>> args = parser.parse_args([])
>>> args.config == [c.name]
True
>>> args.foo
'delayed'
>>> args = parser.parse_args(['--foo', 'quux'])
>>> args.foo
'quux'
This is the same test, but with `--config` called earlier, which
should still work:
>>> from tempfile import NamedTemporaryFile
>>> c = NamedTemporaryFile()
>>> c.write(b"foo: quux\\n")
10
>>> c.flush()
>>> parser = ConfigArgumentParser()
>>> a = parser.add_argument('--config', action=YamlConfigAction, default=[c.name])
>>> a = parser.add_argument('--foo', default='bar')
>>> args = parser.parse_args([])
>>> args.config == [c.name]
True
>>> args.foo
'quux'
>>> args = parser.parse_args(['--foo', 'baz'])
>>> args.foo
'baz'
This tests that you can override the config file defaults altogether:
>>> parser = ConfigArgumentParser()
>>> a = parser.add_argument('--config', action=YamlConfigAction, default=[c.name])
>>> a = parser.add_argument('--foo', default='bar')
>>> args = parser.parse_args(['--config', '/dev/null'])
>>> args.foo
'bar'
>>> args = parser.parse_args(['--config', '/dev/null', '--foo', 'baz'])
>>> args.foo
'baz'
This tests multiple search paths, first one should be loaded:
>>> from tempfile import NamedTemporaryFile
>>> d = NamedTemporaryFile()
>>> d.write(b"foo: argh\\n")
10
>>> d.flush()
>>> parser = ConfigArgumentParser()
>>> a = parser.add_argument('--config', action=YamlConfigAction, default=[d.name, c.name])
>>> a = parser.add_argument('--foo', default='bar')
>>> args = parser.parse_args([])
>>> args.foo
'argh'
>>> c.close()
>>> d.close()
There are actually many other implementations of this we might
want to consider instead of maintaining our own:
https://github.com/omni-us/jsonargparse
https://github.com/bw2/ConfigArgParse
https://github.com/omry/omegaconf
See this comment for a quick review:
https://github.com/borgbackup/borg/issues/6551#issuecomment-1094104453
"""
def __init__(self, *args, **kwargs): # type: ignore[no-untyped-def]
super().__init__(*args, **kwargs)
# a list of actions to fire with their defaults if not fired
# during parsing
self._delayed_config_action = []
def _add_action(self, action: argparse.Action) -> argparse.Action: # type: ignore[override]
# this overrides the add_argument() routine, which is where
# actions get registered in the argparse module.
#
# we do this so we can properly load the default config file
# before the the other arguments get set.
#
# now, of course, we do not fire the action here directly
# because that would make it impossible to *not* load the
# default action. so instead we register this as a
# "_delayed_config_action" which gets fired in `parse_args()`
# instead
action = super()._add_action(action)
if isinstance(action, ConfigAction) and action.default is not None:
self._delayed_config_action.append(action)
return action
[docs]
def parse_args( # type: ignore[override]
self,
args: Optional[Sequence[str]] = None,
namespace: Optional[argparse.Namespace] = None,
) -> argparse.Namespace:
# we do a first failsafe pass on the commandline to find out
# if we have any "config" parameters specified, in which case
# we must *not* load the default config file
ns, _ = self.parse_known_args(args, namespace)
# load the default configuration file, if relevant
#
# this will parse the specified config files and load the
# values as defaults *before* the rest of the commandline gets
# parsed
#
# we do this instead of just loading the config file in the
# namespace precisely to make it possible to override the
# configuration file settings on the commandline
for action in self._delayed_config_action:
if action.dest in ns and action.default != getattr(ns, action.dest):
# do not load config default if specified on the commandline
logging.debug("not loading delayed action because of config override")
# action is already loaded, no need to parse it again
continue
logging.debug("searching config files: %s" % action.default)
action(self, ns, action.default, None)
# this will actually load the relevant config file when found
# on the commandline
#
# note that this will load the config file a second time
return super().parse_args(args, namespace)
[docs]
def default_config(self) -> Iterable[str]:
"""handy shortcut to detect commonly used config paths
This list is processed as a FIFO: if a file is found in there,
it will be parsed and the remaining ones will be ignored.
"""
return [
os.path.join(
os.environ.get("XDG_CONFIG_HOME", "~/.config/"), self.prog + ".yml"
),
os.path.join(USER_BASE or "/usr/local", "etc", self.prog + ".yml"),
os.path.join("/usr/local/etc", self.prog + ".yml"),
os.path.join("/etc", self.prog + ".yml"),
]
[docs]
class LoggingAction(argparse.Action):
"""change log level on the fly
The logging system should be initialized before this, using
`basicConfig`.
Example usage:
logging.basicConfig(level="WARNING", format="%(message)s")
parser.add_argument(
"-v",
"--verbose",
action=LoggingAction,
const="INFO",
help="enable verbose messages",
)
parser.add_argument(
"-d",
"--debug",
action=LoggingAction,
const="DEBUG",
help="enable debugging messages",
)
Or, if you want to default to "verbose" (AKA "INFO"):
logging.basicConfig(format="%(message)s") # INFO is default
parser.add_argument(
"-q",
"--quiet",
action=LoggingAction,
const="WARNING",
help="silence messages except warnings and errors",
)
parser.add_argument(
"-d",
"--debug",
action=LoggingAction,
const="DEBUG",
help="enable debugging messages",
)
"""
def __init__(self, *args, **kwargs): # type: ignore[no-untyped-def]
"""setup the action parameters
This enforces a selection of logging levels. It also checks if
const is provided, in which case we assume it's an argument
like `--verbose` or `--debug` without an argument.
"""
kwargs["choices"] = logging._nameToLevel.keys()
if "const" in kwargs:
kwargs["nargs"] = 0
super().__init__(*args, **kwargs)
def __call__(self, parser, ns, values, option): # type: ignore[no-untyped-def]
"""if const was specified it means argument-less parameters"""
if self.const:
logging.getLogger("").setLevel(self.const)
else:
logging.getLogger("").setLevel(values)
# cargo-culted from _StoreConstAction
setattr(ns, self.dest, self.const or values)