Source code for ecdysis.argparse

"""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)