# -*- coding: utf-8 -*-
# vim: tabstop=4 shiftwidth=4 softtabstop=4
#
# Copyright (C) 2014-2021 GEM Foundation
#
# OpenQuake is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# OpenQuake is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with OpenQuake. If not, see <http://www.gnu.org/licenses/>.
"""
``openquake.baselib.sap`` is a Simple Argument Parser based on argparse
which is extremely powerful. Its features are
1. zero boilerplate (no decorators)
2. supports arbitrarily nested subcommands with an easy sintax
3. automatically generates a simple parser from a Python module and
a hierarchic parser from a Python package.
Here is a minimal example of usage:

.. code-block:: python
>>> def convert_archive(input_, output=None, inplace=False, *, out='/tmp'):
... "Example"
... print(input_, output, inplace, out)
>>> convert_archive.input_ = 'input file or archive'
>>> convert_archive.inplace = 'convert inplace'
>>> convert_archive.output = 'output archive'
>>> convert_archive.out = 'output directory'
>>> parser(convert_archive, prog='app').print_help()
usage: app [-h] [-i] [-o /tmp] input [output]
<BLANKLINE>
Example
<BLANKLINE>
positional arguments:
input input file or archive
output output archive
<BLANKLINE>
optional arguments:
-h, --help show this help message and exit
-i, --inplace convert inplace
-o /tmp, --out /tmp output directory
>>> run(convert_archive, argv=['a.zip', 'b.zip'])
a.zip b.zip False /tmp
>>> run(convert_archive, argv=['a.zip', '-i', '-o', '/tmp/x'])
a.zip None True /tmp/x
"""
import os
import inspect
import argparse
import importlib
NODEFAULT = object()
def _choices(choices):
# returns {choice1, ..., choiceN} or the empty string
if choices:
return '{%s}' % ', '.join(map(str, choices))
return ''
def _populate(parser, func):
# populate the parser
# args, varargs, varkw, defaults, kwonlyargs, kwonlydefaults, anns
argspec = inspect.getfullargspec(func)
if argspec.varargs:
raise TypeError('varargs in the signature of %s are not supported'
% func)
defaults = argspec.defaults or ()
nodefaults = len(argspec.args) - len(defaults)
alldefaults = (NODEFAULT,) * nodefaults + defaults
argdef = dict(zip(argspec.args, alldefaults))
argdef.update(argspec.kwonlydefaults or {})
parser.description = func.__doc__
parser.set_defaults(_func=func)
parser.aliases = {}
argdescr = [] # list of pairs (argname, argkind)
for arg in argspec.args:
if argdef[arg] is False:
argdescr.append((arg, 'flg'))
else:
argdescr.append((arg, 'pos'))
for arg in argspec.kwonlyargs:
argdescr.append((arg, 'opt'))
abbrevs = {'-h'} # already taken abbreviations
for name, kind in argdescr:
if name.endswith('_'):
# make it possible use bultins/keywords as argument names
stripped = name.rstrip('_')
parser.aliases[stripped] = name
else:
stripped = name
descr = getattr(func, name, '')
if isinstance(descr, str):
kw = dict(help=descr)
else: # assume a dictionary
kw = descr.copy()
if (kind != 'flg' and kw.get('type') is None and
name in func.__annotations__):
kw.setdefault('type', func.__annotations__[name])
abbrev = kw.get('abbrev')
choices = kw.get('choices')
default = argdef[name]
if kind == 'pos':
if default is not NODEFAULT:
kw['default'] = default
kw.setdefault('nargs', '?')
if default is not None:
kw['help'] += ' [default: %s]' % repr(default)
elif kind == 'flg':
kw.setdefault('abbrev', abbrev or '-' + name[0])
kw['action'] = 'store_true'
elif kind == 'opt':
kw.setdefault('abbrev', abbrev or '-' + name[0])
if default not in (None, NODEFAULT):
kw['default'] = default
kw.setdefault('metavar', _choices(choices) or str(default))
abbrev = kw.pop('abbrev', None)
longname = '--' + stripped.replace('_', '-')
if abbrev and abbrev in abbrevs:
# avoid conflicts with previously defined abbreviations
args = longname,
elif abbrev:
if len(abbrev) > 2: # no single-letter abbrev
args = longname, abbrev
else: # single-letter abbrev
args = abbrev, longname
abbrevs.add(abbrev)
else:
# no abbrev
args = stripped,
parser.add_argument(*args, **kw)
def _rec_populate(parser, funcdict):
subparsers = parser.add_subparsers(
help='available subcommands; use %s <subcmd> --help' % parser.prog)
for name, func in funcdict.items():
subp = subparsers.add_parser(name, prog=parser.prog + ' ' + name)
if isinstance(func, dict): # nested subcommand
_rec_populate(subp, func)
else: # terminal subcommand
_populate(subp, func)
[docs]def pkg2dic(pkg):
"""
:param pkg: a python module or package
:returns: a dictionary name -> func_or_dic_of_funcs
"""
if not hasattr(pkg, '__path__'): # is a module, not a package
return {pkg.__name__: pkg.main}
dic = {}
for path in pkg.__path__:
for name in os.listdir(path):
fname = os.path.join(path, name)
dotname = pkg.__name__ + '.' + name
if os.path.isdir(fname) and '__init__.py' in os.listdir(fname):
subdic = pkg2dic(importlib.import_module(dotname))
if subdic:
dic[name] = subdic
elif name.endswith('.py') and name not in (
'__init__.py', '__main__.py'):
mod = importlib.import_module(dotname[:-3])
if hasattr(mod, 'main'):
dic[name[:-3]] = mod.main
return dic
[docs]def parser(funcdict, **kw):
"""
:param funcdict: a function or a nested dictionary of functions
:param kw: keyword arguments passed to the underlying ArgumentParser
:returns: the ArgumentParser instance
"""
if isinstance(funcdict, dict):
version = funcdict.pop('__version__', None)
else:
version = getattr(funcdict, '__version__', None)
parser = argparse.ArgumentParser(**kw)
if version:
parser.add_argument(
'-v', '--version', action='version', version=version)
if inspect.ismodule(funcdict): # passed a module or package
funcdict = pkg2dic(funcdict)
if callable(funcdict):
_populate(parser, funcdict)
else:
_rec_populate(parser, funcdict)
return parser
def _run(parser, argv=None):
namespace = parser.parse_args(argv)
try:
func = namespace.__dict__.pop('_func')
except KeyError:
parser.print_usage()
return
if hasattr(parser, 'aliases'):
# go back from stripped to unstripped names
dic = {parser.aliases.get(name, name): value
for name, value in vars(namespace).items()}
else:
dic = vars(namespace)
return func(**dic)
[docs]def run(funcdict, argv=None, **parserkw):
"""
:param funcdict: a function or a nested dictionary of functions
:param argv: a list of command-line arguments (if None, use sys.argv[1:])
:param parserkw: arguments accepted by argparse.ArgumentParser
"""
return _run(parser(funcdict, **parserkw), argv)
[docs]def runline(line, **parserkw):
"""
Run a command-line. Useful in the tests.
"""
pkg, *args = line.split()
return run(importlib.import_module(pkg), args, **parserkw)