# -*- mode: python; coding: utf-8 -*-
# Copyright 2012-2015, 2018 Peter Williams <peter@newton.cx> and collaborators.
# Licensed under the MIT License.
"""The :mod:`pwkit.kwargv` module provides a framework for parsing
keyword-style arguments to command-line programs. It’s designed so that you
can easily make a routine with complex, structured configuration parameters
that can also be driven from the command line.
Keywords are defined by declaring a subclass of the
:class:`ParseKeywords` class with fields corresponding to the
support keywords::
from pwkit.kwargv import ParseKeywords, Custom
class MyConfig(ParseKeywords):
foo = 1
bar = str
multi = [int]
extra = Custom(float, required=True)
@Custom(str)
def declination(value):
from pwkit.astutil import parsedeglat
return parsedeglat(value)
Instantiating the subclass fills in all defaults. Calling the
:meth:`ParseKeywords.parse` method parses a list of strings (defaulting to
``sys.argv[1:]``) and updates the instance’s properties. This framework is
designed so that you can provide complex configuration to an algorithm either
programmatically, or on the command line. A typical use would be::
from pwkit.kwargv import ParseKeywords, Custom
class MyConfig(ParseKeywords):
niter = 1
input = str
scales = [int]
# ...
def my_complex_algorithm(cfg):
from pwkit.io import Path
data = Path(cfg.input).read_fits()
for i in range(cfg.niter):
# ....
def call_algorithm_in_code():
cfg = MyConfig()
cfg.input = 'testfile.fits'
# ...
my_complex_algorithm(cfg)
if __name__ == '__main__':
cfg = MyConfig().parse()
my_complex_algorithm(cfg)
You could then execute the module as a program and specify arguments in the
form ``./program niter=5 input=otherfile.fits``.
Keyword Specification Format
----------------------------
Arguments are specified in the following ways:
- ``foo = 1`` defines a keyword with a default value, type inferred as
``int``. Likewise for ``str``, ``bool``, ``float``.
- ``bar = str`` defines an string keyword with default value of None.
Likewise for ``int``, ``bool``, ``float``.
- ``multi = [int]`` parses as a list of integers of any length, defaulting to
the empty list ``[]`` (I call these "flexible" lists.). List items are
separated by commas on the command line.
- ``other = [3.0, int]`` parses as a 2-element list, defaulting to ``[3.0,
None]``. If one value is given, the first array item is parsed, and the
second is left as its default. (I call these "fixed" lists.)
- ``extra = Custom(float, required=True)`` parses like ``float`` and then
customizes keyword properties. Supported properties are the attributes of
the :class:`KeywordInfo` class.
- Use :class:`Custom` as a decorator (``@Custom``) on a function ``foo``
defines a keyword ``foo`` that’s parsed according to the :class:`Custom`
specification, then has its value fixed up by calling the ``foo()`` function
after the basic parsing. That is, the final value is ``foo
(intermediate_value)``. A common pattern is to use a fixup function for a
fixed list where the first few values are mandatory (see
:attr:`KeywordInfo.minvals` below) but later values can be guessed or
defaulted.
See the :class:`KeywordInfo` documentation for specification of additional
keyword properties that may be specified. The ``Custom`` name is simply an
alias for :class:`KeywordInfo`.
"""
from __future__ import absolute_import, division, print_function, unicode_literals
__all__ = str("Custom KwargvError ParseError KeywordInfo ParseKeywords basic").split()
from . import Holder, PKError
[docs]
class KwargvError(PKError):
"""Raised when invalid arguments have been provided."""
[docs]
class ParseError(KwargvError):
"""Raised when the structure of the arguments appears legitimate, but a
particular value cannot be parsed into its expected type.
"""
[docs]
def basic(args=None):
"""Parse the string list *args* as a set of keyword arguments in a very
simple-minded way, splitting on equals signs. Returns a
:class:`pwkit.Holder` instance with attributes set to strings. The form
``+foo`` is mapped to setting ``foo = True`` on the :class:`pwkit.Holder`
instance. If *args* is ``None``, ``sys.argv[1:]`` is used. Raises
:exc:`KwargvError` on invalid arguments (i.e., ones without an equals sign
or a leading plus sign).
"""
if args is None:
import sys
args = sys.argv[1:]
parsed = Holder()
for arg in args:
if arg[0] == "+":
for kw in arg[1:].split(","):
parsed.set_one(kw, True)
# avoid analogous -a,b,c syntax because it gets confused with -a --help, etc.
else:
t = arg.split("=", 1)
if len(t) < 2:
raise KwargvError('don\'t know what to do with argument "%s"', arg)
if not len(t[1]):
raise KwargvError('empty value for keyword argument "%s"', t[0])
parsed.set_one(t[0], t[1])
return parsed
# The fancy, full-featured system.
[docs]
class KeywordInfo(object):
"""Properties that a keyword argument may have."""
parser = None
"""A callable used to convert the argument text to a Python value.
This attribute is assigned automatically upon setup."""
default = None
"""The default value for the keyword if it’s left unspecified."""
required = False
"""Whether an error should be raised if the keyword is not seen while
parsing."""
sep = ","
"""The textual separator between items for list-valued keywords."""
maxvals = None
"""The maximum number of values allowed. This only applies for flexible lists;
fixed lists have predetermined sizes.
"""
minvals = 0 # note: maxvals and minvals are used in different ways
"""The minimum number of values allowed in a flexible list, *if the keyword is
specified at all*. If you want ``minvals = 1``, use ``required = True``.
"""
scale = None
"""If not ``None``, multiply numeric values by this number after parsing."""
repeatable = False
"""If true, the keyword value(s) will always be contained in a list. If they
keyword is specified multiple times (i.e. ``./program kw=1 kw=2``), the
list will have multiple items (``cfg.kw = [1, 2]``). If the keyword is
list-valued, using this will result in a list of lists.
"""
printexc = False
"""Print the exception as normal if there’s an exception when parsing the
keyword value. Otherwise there’s just a message along the lines of “cannot
parse value <val> for keyword <kw>”.
"""
fixupfunc = None
"""If not ``None``, the final value of the keyword is set to the return value
of ``fixupfunc(intermediate_value)``.
"""
_attrname = None
# This isn't used on Keyword*Info* instances, but adding a dummy here makes
# the docs much saner:
uiname = None
"""The name of the keyword as parsed from the command-line. For instance,
``some_value = Custom(int, uiname="some-value")`` will result in a
keyword that the user sets by calling ``./program some-value=3``. This
provides a mechanism to support keyword names that are not legal Python
identifiers.
"""
class KeywordOptions(Holder):
uiname = None
subval = None
def __init__(self, subval, **kwargs):
self.set(**kwargs)
self.subval = subval
def __call__(self, fixupfunc):
# Slightly black magic. Grayish magic. This lets us be used as
# a decorator on "fixup" functions to modify or range-check
# the parsed argument value.
self.fixupfunc = fixupfunc
return self
Custom = KeywordOptions # sugar for users
def _parse_bool(s):
s = s.lower()
if s in "y yes t true on 1".split():
return True
if s in "n no f false off 0".split():
return False
raise ParseError('don\'t know how to interpret "%s" as a boolean' % s)
def _val_to_parser(v):
if isinstance(v, bool):
return _parse_bool
if isinstance(v, (int, float, str)):
return v.__class__
raise ValueError("can't figure out how to parse %r" % v)
def _val_or_func_to_parser(v):
if v is bool:
return _parse_bool
if callable(v):
return v
return _val_to_parser(v)
def _val_or_func_to_default(v):
if callable(v):
return None
if isinstance(v, (int, float, bool, str)):
return v
raise ValueError
("can't figure out a default for %r" % v)
def _handle_flex_list(ki, ks):
assert len(ks) == 1
elemparser = ks[0]
# I don't think 'foo = [0]' will be useful ...
assert callable(elemparser)
def flexlistparse(val):
return [elemparser(i) for i in val.split(ki.sep)]
return flexlistparse, []
def _handle_fixed_list(ki, ks):
parsers = [_val_or_func_to_parser(sks) for sks in ks]
defaults = [_val_or_func_to_default(sks) for sks in ks]
ntot = len(parsers)
def fixlistparse(val):
items = val.split(ki.sep)
ngot = len(items)
if ngot < ki.minvals:
if ki.minvals == ntot:
raise ParseError(
"expected exactly %d values, but only got %d", ntot, ngot
)
raise ParseError(
"expected between %d and %d values, but only got %d",
ki.minvals,
ntot,
ngot,
)
if ngot > ntot:
raise ParseError(
"expected between %d and %d values, but got %d", ki.minvals, ntot, ngot
)
result = list(defaults) # make a copy
for i in range(ngot):
result[i] = parsers[i](items[i])
return result
return fixlistparse, list(defaults) # make a copy
[docs]
class ParseKeywords(Holder):
"""The template class for defining your keyword arguments. A subclass of
:class:`pwkit.Holder`. Declare attributes in a subclass following the
scheme described above, then call the :meth:`ParseKeywords.parse` method.
"""
def __init__(self):
kwspecs = self.__class__.__dict__
kwinfos = {}
# Process our keywords, as specified by the class attributes, into a
# form more friendly for parsing, and check for things we don't
# understand. 'kw' is the keyword name exposed to the user; 'attrname'
# is the name of the attribute to set on the resulting object.
for kw, ks in kwspecs.items():
if kw[0] == "_":
continue
ki = KeywordInfo()
ko = None
attrname = kw
if isinstance(ks, KeywordOptions):
ko = ks
ks = ko.subval
if ko.uiname is not None:
kw = ko.uiname
if callable(ks):
# expected to be a type (int, float, ...).
# This branch would get taken for methods, too,
# which sorta makes sense?
parser = _val_or_func_to_parser(ks)
default = _val_or_func_to_default(ks)
elif isinstance(ks, list) and len(ks) == 1:
parser, default = _handle_flex_list(ki, ks)
elif isinstance(ks, list) and len(ks) > 1:
parser, default = _handle_fixed_list(ki, ks)
else:
parser = _val_to_parser(ks)
default = _val_or_func_to_default(ks)
ki._attrname = attrname
ki.parser = parser
ki.default = default
if ko is not None: # override with user-specified options
ki.__dict__.update(ko.__dict__)
if ki.required:
# makes sense, and prevents trying to call fixupfunc on
# weird default values of fixed lists.
ki.default = None
elif ki.repeatable:
ki.default = []
elif ki.fixupfunc is not None:
# Make sure to process the default through the fixup, if it
# exists. This helps code use "interesting" defaults with types
# that you might prefer to use when launching a task
# programmatically; e.g. a default output stream that is
# `sys.stdout`, not "-". Note, however, that the fixup will
# always get called for the default value, so it shouldn't do
# anything too expensive.
ki.default = ki.fixupfunc(ki.default)
kwinfos[kw] = ki
# Apply defaults, save parse info, done
for kw, ki in kwinfos.items():
self.set_one(ki._attrname, ki.default)
self._kwinfos = kwinfos
[docs]
def parse(self, args=None):
"""Parse textual keywords as described by this class’s attributes, and update
this instance’s attributes with the parsed values. *args* is a list of
strings; if ``None``, it defaults to ``sys.argv[1:]``. Returns *self*
for convenience. Raises :exc:`KwargvError` if invalid keywords are
encountered.
See also :meth:`ParseKeywords.parse_or_die`.
"""
if args is None:
import sys
args = sys.argv[1:]
seen = set()
for arg in args:
t = arg.split("=", 1)
if len(t) < 2:
raise KwargvError('don\'t know what to do with argument "%s"', arg)
kw, val = t
ki = self._kwinfos.get(kw)
if ki is None:
raise KwargvError('unrecognized keyword argument "%s"', kw)
if not len(val):
raise KwargvError('empty value for keyword argument "%s"', kw)
try:
pval = ki.parser(val)
except ParseError as e:
raise KwargvError(
'cannot parse value "%s" for keyword ' 'argument "%s": %s',
val,
kw,
e,
)
except Exception as e:
if ki.printexc:
raise KwargvError(
'cannot parse value "%s" for keyword ' 'argument "%s": %s',
val,
kw,
e,
)
raise KwargvError(
'cannot parse value "%s" for keyword ' 'argument "%s"', val, kw
)
if ki.maxvals is not None and len(pval) > ki.maxvals:
raise KwargvError(
'keyword argument "%s" may have at most %d'
' values, but got %s ("%s")',
kw,
ki.maxvals,
len(pval),
val,
)
if ki.scale is not None:
pval = pval * ki.scale
if ki.fixupfunc is not None:
pval = ki.fixupfunc(pval)
if ki.repeatable:
# We can't just unilaterally append to the preexisting
# list, since if we did that starting with the default value
# we'd mutate the default list.
cur = self.get(ki._attrname)
if not len(cur):
pval = [pval]
else:
cur.append(pval)
pval = cur
seen.add(kw)
self.set_one(ki._attrname, pval)
for kw, ki in self._kwinfos.items():
if kw not in seen:
if ki.required:
raise KwargvError(
'required keyword argument "%s" was not provided', kw
)
return self # convenience
[docs]
def parse_or_die(self, args=None):
"""Like :meth:`ParseKeywords.parse`, but calls :func:`pkwit.cli.die` if a
:exc:`KwargvError` is raised, printing the exception text. Returns
*self* for convenience.
"""
from .cli import die
try:
return self.parse(args)
except KwargvError as e:
die(e)
if __name__ == "__main__":
print(basic())