Source code for fsleyes_props.cli

#!/usr/bin/env python
#
# cli.py - Generate command line arguments for a HasProperties object.
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#

"""Generate command line arguments for a :class:`.HasProperties` instance.

This module provides the following functions:

 .. autosummary::
    :nosignatures:

    addParserArguments
    applyArguments
    generateArguments

The ``addParserArguments`` function is used to add arguments to an
``ArgumentParser`` object for the properties of a ``HasProperties`` class. The
simplest way to do so is to allow the ``addParserArguments`` function to
automatically generate short and long arguments from the property names::

    >>> import argparse
    >>> import fsleyes_props as props

    >>> class MyObj(props.HasProperties):
            intProp  = props.Int()
            boolProp = props.Boolean()

    >>> parser = argparse.ArgumentParser('MyObj')
    >>> props.addParserArguments(MyObj, parser)

    >>> parser.print_help()
    usage: MyObj [-h] [-b] [-i INT]

    optional arguments:
        -h, --help            show this help message and exit
        -b, --boolProp
        -i INT, --intProp INT

Now, if we have a ``MyObj`` instance, and some arguments::

    >>> myobj = MyObj()

    >>> args = parser.parse_args(['-b', '--intProp', '52'])

    >>> print myobj
    MyObj
      boolProp = False
       intProp = 0

    >>> props.applyArguments(myobj, args)
    >>> print myobj
    MyObj
      boolProp = True
       intProp = 52

If you want to customise the short and long argument tags, and the help text,
for each property, you can pass them in to the ``addParserArguments``
function::

    >>> shortArgs = {'intProp' : 'r',              'boolProp' : 't'}
    >>> longArgs  = {'intProp' : 'TheInt',         'boolProp' : 'someBool'}
    >>> propHelp  = {'intProp' : 'Sets int value', 'boolProp' : 'Toggles bool'}

    >>> parser = argparse.ArgumentParser('MyObj')
    >>> props.addParserArguments(MyObj,
                                 parser,
                                 shortArgs=shortArgs,
                                 longArgs=longArgs,
                                 propHelp=propHelp)
    >>> parser.print_help()
    usage: MyObj [-h] [-t] [-r INT]

    optional arguments:
      -h, --help            show this help message and exit
      -t, --someBool        Toggles bool
      -r INT, --TheInt INT  Sets int value

Or, you can add the short and long arguments, and the help text, as specially
named class attributes of your ``HasProperties`` class or instance::

    >>> class MyObj(props.HasProperties):
            intProp  = props.Int()
            boolProp = props.Boolean()
            _shortArgs = {
                'intProp'  : 'r',
                'boolProp' : 't'
            }
            _longArgs = {
                'intProp'  : 'TheInt',
                'boolProp' : 'someBool'
            }
            _propHelp = {
                'intProp' : 'Sets int value',
                'boolProp' : 'Toggles bool'
            }

    >>> parser = argparse.ArgumentParser('MyObj')
    >>> props.addParserArguments(MyObj, parser)

    >>> parser.print_help()
    usage: MyObj [-h] [-t] [-r INT]

    optional arguments:
      -h, --help            show this help message and exit
      -t, --someBool        Toggles bool
      -r INT, --TheInt INT  Sets int value

    >>> args = parser.parse_args('--someBool -r 23413'.split())
    >>> myobj = MyObj()
    >>> props.applyArguments(myobj, args)
    >>> print myobj
    MyObj
      boolProp = True
       intProp = 23413

The ``generateArguments`` function, as the name suggests, generates command
line arguments from a ``HasProperties`` instance::

    >>> props.generateArguments(myobj)
    ['--someBool', '--TheInt', '23413']

The ``generateArguments`` and ``applyArguments`` functions optionally accept a
set of *transform* functions which, for ``generateArguments``, take the value
of a property, and return some transformation of that property, suitable to be
used as a command line argument value. The transform functions passed to the
``applyArguments`` function perform the reverse transformation.  Transforms
are useful for properties which cannot easily be converted to/from strings,
and also for properties where the value you wish users to pass in on the
command line does not correspond exactly to the value you wish the property to
be given.

For example::

    >>> class MyObject(props.HasProperties):
            showBlah = props.Boolean(default=True)

    >>> shortArgs = {'showBlah' : 'hb'}
    >>> longArgs  = {'showBlah' : 'hideBlah'}
    >>> xforms    = {'showBlah' : lambda b : not b }

    >>> parser = argparse.ArgumentParser('MyObject')
    >>> props.addParserArguments(MyObject,
                                 parser,
                                 shortArgs=shortArgs,
                                 longArgs=longArgs)

    >>> myobj = MyObject()
    >>> myobj.showBlah = False

    >>> props.generateArguments(myobj,
                                shortArgs=shortArgs,
                                longArgs=longArgs,
                                xformFuncs=xforms)
        ['--hideBlah']

In this example, we can use the same transform function for the reverse
operation::

    >>> myobj2 = MyObject()
    >>> args   = parser.parse_args(['--hideBlah'])
    >>> props.applyArguments(myobj2,
                             args,
                             xformFuncs=xforms,
                             longArgs=longArgs)
    >>> print myobj2
        MyObject
            showBlah = False

The ``cli`` module supports the following property types:

.. autosummary::
   :nosignatures:

   _String
   _Choice
   _Boolean
   _Int
   _Real
   _Percentage
   _Bounds
   _Point
   _Colour
   _ColourMap

"""


import logging

import sys
import argparse

import matplotlib        as mpl
import matplotlib.pyplot as plt

import fsleyes_props.properties       as props
import fsleyes_props.properties_types as ptypes


log = logging.getLogger(__name__)


[docs] class SkipArgument(Exception): """This exception may be raised by transform functions which are called by :func:`applyArguments` and :func:`generateArguments` to indicate that the arguemnt should be skipped (i.e. not applied, or not generated). """
def _String(parser, propObj, propCls, propName, propHelp, shortArg, longArg, atype=str): """Adds an argument to the given parser for the given :class:`.String` property. :param parser: An ``ArgumentParser`` instance. :param propCls: A ``HasProperties`` class or instance. :param propObj: The ``PropertyBase`` class. :param propName: Name of the property. :param propHelp: Custom help text for the property. :param shortArg: String to use as the short argument. :param longArg: String to use as the long argument. :param atype: Data type to expect (defaults to ``str``). This allows the conversion type to be overridden for some, but not all, property types. """ parser.add_argument(shortArg, longArg, help=propHelp, type=atype) def _Choice(parser, propObj, propCls, propName, propHelp, shortArg, longArg, **kwargs): """Adds an argument to the given parser for the given :class:`.Choice` property. See the :func:`_String` documentation for details on the parameters. Only works with ``Choice`` properties with string options (unless the ``choices`` argument is provided). See the ``allowStr`` parameter for :class:`.Choice` instances. All of the described arguments must be speified as keyword arguments. Any additional arguments will be passed to the ``ArgumentParser.add_argument`` method. :arg choices: If not ``None``, assumed to be list of possible choices for the property. If not provided, the possible choices are taken from the :meth:`.Choice.getChoices` method. If explicitly passed in as ``None``, the argument will be configured to accept any value. :arg default: If not ``None``, gives the default value. Otherwise, the ``default`` attribute of the :class:`.Choice` object is used. :arg useAlts: If ``True`` (the default), alternate values for the choices are added as options (see the :class:`.Choice` class). n.b. This flag will have no effect if ``choices`` is explicitly set to ``None``, as desrcibed above. :arg metavar: If ``None`` (the default), the available choices for this property will be shown in the help text. Otherwise, this string will be shown instead. """ choices = kwargs.pop('choices', 'notspecified') default = kwargs.pop('default', None) useAlts = kwargs.pop('useAlts', True) metavar = kwargs.pop('metavar', None) if choices == 'notspecified': choices = propObj.getChoices() if useAlts and choices is not None: alternates = propObj.getAlternates() for altList in alternates: choices += [a for a in altList] if default is None: default = propObj.getAttribute(None, 'default') # Make sure that only unique # choices are set as options. if choices is not None: # We could just convert to a set, but # this would not preserve ordering, so # we'll uniquify the list the hard way. choices = [str(c) for c in choices] unique = [] for c in choices: if c not in unique: unique.append(c) choices = unique parser.add_argument(shortArg, longArg, help=propHelp, choices=choices, metavar=metavar, **kwargs) def _Boolean(parser, propObj, propCls, propName, propHelp, shortArg, longArg): """Adds an argument to the given parser for the given :class:`.Boolean` property. See the :func:`_String` documentation for details on the parameters. """ # Using store_const instead of store_true, # because if the user doesn't set this # argument, we don't want to explicitly # set the property value to False (if it # has a default value of True, we don't # want that default value overridden). parser.add_argument(shortArg, longArg, help=propHelp, action='store_const', const=True) def _Int(parser, propObj, propCls, propName, propHelp, shortArg, longArg): """Adds an argument to the given parser for the given :class:`.Int` property. See the :func:`_String` documentation for details on the parameters. """ parser.add_argument(shortArg, longArg, help=propHelp, metavar='INT', type=int) def _Real(parser, propObj, propCls, propName, propHelp, shortArg, longArg): """Adds an argument to the given parser for the given :class:`.Real` property. See the :func:`_String` documentation for details on the parameters. """ parser.add_argument(shortArg, longArg, help=propHelp, metavar='REAL', type=float) def _Percentage( parser, propObj, propCls, propName, propHelp, shortArg, longArg): """Adds an argument to the given parser for the given :class:`.Percentage` property. See the :func:`_String` documentation for details on the parameters. """ parser.add_argument(shortArg, longArg, help=propHelp, metavar='PERC', type=float) def _Bounds(parser, propObj, propCls, propName, propHelp, shortArg, longArg, atype=None): """Adds an argument to the given parser for the given :class:`.Bounds` property. See the :func:`_String` documentation for details on the parameters. """ ndims = getattr(propCls, propName)._ndims real = getattr(propCls, propName)._real metavar = tuple(['LO', 'HI'] * ndims) if atype is None: if real: atype = float else: atype = int parser.add_argument(shortArg, longArg, help=propHelp, metavar=metavar, type=atype, nargs=2 * ndims) def _Point(parser, propObj, propCls, propName, propHelp, shortArg, longArg): """Adds an argument to the given parser for the given :class:`.Point` property. See the :func:`_String` documentation for details on the parameters. """ ndims = getattr(propCls, propName)._ndims real = getattr(propCls, propName)._real if real: pType = float else: pType = int parser.add_argument(shortArg, longArg, help=propHelp, metavar='N', type=pType, nargs=ndims) def _Colour(parser, propObj, propCls, propName, propHelp, shortArg, longArg, alpha=True): """Adds an argument to the given parser for the given :class:`.Colour` property. See the :func:`_String` documentation for details on the parameters. """ if alpha: metavar = ('R', 'G', 'B', 'A') nargs = 4 else: metavar = ('R', 'G', 'B') nargs = 3 parser.add_argument(shortArg, longArg, help=propHelp, metavar=metavar, type=float, nargs=nargs) def _ColourMap(parser, propObj, propCls, propName, propHelp, shortArg, longArg, parseStr=False, choices=None, metavar=None): """Adds an argument to the given parser for the given :class:`.ColourMap` property. :arg parseStr: If ``False`` (the default), the parser will be configured to parse any registered ``matplotlib`` colour map name. Otherwise, the parser will accept any string, and assume that the ``ColourMap`` property is set up to accept it. :arg choices: This parameter can be used to restrict the values that will be accepted. :arg metavar: If ``choices`` is not ``None``, they will be shown in the help text, unless this string is provided. See the :func:`_String` documentation for details on the other parameters. """ # Attempt to retrieve a matplotlib.cm.ColourMap # instance given its name def parse(cmapName): try: cmapKeys = plt.colormaps() cmapNames = [mpl.colormaps[cm].name for cm in cmapKeys] lCmapNames = [s.lower() for s in cmapNames] lCmapKeys = [s.lower() for s in cmapKeys] cmapName = cmapName.lower() try: idx = lCmapKeys .index(cmapName) except ValueError: idx = lCmapNames.index(cmapName) cmapName = cmapKeys[idx] return mpl.colormaps[cmapName] except Exception: raise argparse.ArgumentTypeError( 'Unknown colour map: {}'.format(cmapName)) if choices is not None: choices = [c.lower() for c in choices] if parseStr: aType = str.lower else: aType = parse parser.add_argument(shortArg, longArg, help=propHelp, type=aType, choices=choices, metavar=metavar) def _getShortArgs(propCls, propNames, exclude=''): """Generates unique single-letter argument names for each of the names in the given list of property names. Any letters in the exclude string are not used as short arguments. :param propCls: A ``HasProperties`` class. :param propNames: List of property names for which short arguments are to be generated. :param exclude: String containing letters which should not be used. """ letters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' shortArgs = {} for propName in propNames: # if '_shortArgs' is present on the hasProps # object, and there is an entry for the # current property, use that entry. if hasattr(propCls, '_shortArgs'): if propName in propCls._shortArgs: # throw an error if that entry # has already been used, or # should be excluded if propCls._shortArgs[propName] in list(shortArgs.values()) or\ propCls._shortArgs[propName] in exclude: raise RuntimeError( 'Duplicate or excluded short argument for property ' '{}.{}: {}'.format( propCls.__name__, propName, propCls._shortArgs[propName])) shortArgs[propName] = propCls._shortArgs[propName] continue # use the first unique letter in the # property name or, if that doesn't # work, in the alphabet for c in propName + letters: if (c not in list(shortArgs.values())) and (c not in exclude): shortArgs[propName] = c break if any([name not in list(shortArgs) for name in propNames]): raise RuntimeError('Could not generate default short arguments ' 'for HasProperties object {} - please provide ' 'custom short arguments via a _shortArgs ' 'attribute'.format(propCls.__name__)) return shortArgs
[docs] def applyArguments(hasProps, arguments, propNames=None, xformFuncs=None, longArgs=None, **kwargs): """Apply arguments to a ``HasProperties`` instance. Given a ``HasProperties`` instance and an ``argparse.Namespace`` instance, sets the property values of the ``HasProperties`` instance from the values stored in the ``Namespace`` object. :param hasProps: The ``HasProperties`` instance. :param arguments: The ``Namespace`` instance. :param propNames: List of property names to apply. If ``None``, an attempt is made to set all properties. If not ``None``, the property values are set in the order specified by this list. :param xformFuncs: A dictionary of ``{property name -> function}`` mappings, which can be used to transform the value given on the command line before it is assigned to the property. :param longArgs: Dict containing ``{property name : longArg}`` mappings. All other keyword arguments are passed through to the ``xformFuncs`` functions. """ if propNames is None: propNames, propObjs = hasProps.getAllProperties() else: propObjs = [hasProps.getProp(name) for name in propNames] if longArgs is None: if hasattr(hasProps, '_longArgs'): longArgs = hasProps._longArgs else: longArgs = dict(zip(propNames, propNames)) if xformFuncs is None: xformFuncs = {} for propName, propObj in zip(propNames, propObjs): xform = xformFuncs.get(propName, lambda v, **kwa : v) argName = longArgs.get(propName, propName) argVal = getattr(arguments, argName, None) # An argVal of None means that no value # was passed in for this property if argVal is None: continue try: argVal = xform(argVal, **kwargs) except SkipArgument: continue log.debug('Setting {}.{} = {}'.format( type(hasProps).__name__, propName, argVal)) setattr(hasProps, propName, argVal)
[docs] def addParserArguments( propCls, parser, cliProps=None, shortArgs=None, longArgs=None, propHelp=None, extra=None, exclude=''): """Adds arguments to the given ``argparse.ArgumentParser`` for the properties of the given ``HasProperties`` class or instance. :param propCls: A ``HasProperties`` class. An instance may alternately be passed in. :param parser: An ``ArgumentParser`` to add arguments to. :param list cliProps: List containing the names of properties to add arguments for. If ``None``, and an attribute called ``_cliProps``' is present on the ``propCls`` class, the value of that attribute is used. Otherwise an argument is added for all properties. :param dict shortArgs: Dict containing ``{propName: shortArg}`` mappings, to be used as the short (typically single letter) argument flag for each property. If ``None``, and an attribute called ``_shortArgs`` is present on the ``propCls`` class, the value of that attribute is used. Otherwise, short arguments are automatically generated for each property. :param dict longArgs: Dict containing ``{propName: longArg}`` mappings, to be used as the long argument flag for each property. If ``None``, and an attribute called ``_longArgs`` is present on the ``propCls`` class, the value of that attribute is used. Otherwise, the name of each property is used as its long argument. :param dict propHelp: Dict containing ``{propName: helpString]`` mappings, to be used as the help text for each property. If ``None``, and an attribute called ``_propHelp`` is present on the ``propCls`` class, the value of that attribute is used. Otherwise, no help string is used. :param dict extra: Any property-specific settings to be passed through to the parser configuration function (see e.g. the :func:`_Choice` function). If ``None``, and an attribute called ``_propExtra`` is present on the ``propCls`` class, the value of that attribute is used instead. :param str exclude: String containing letters which should not be used as short arguments. """ if isinstance(propCls, props.HasProperties): propCls = propCls.__class__ if cliProps is None: if hasattr(propCls, '_cliProps'): cliProps = propCls._cliProps else: cliProps = propCls.getAllProperties()[0] if propHelp is None: if hasattr(propCls, '_propHelp'): propHelp = propCls._propHelp else: propHelp = {} if longArgs is None: if hasattr(propCls, '_longArgs'): longArgs = propCls._longArgs else: longArgs = dict(zip(cliProps, cliProps)) if shortArgs is None: if hasattr(propCls, '_shortArgs'): shortArgs = propCls._shortArgs else: shortArgs = _getShortArgs(propCls, cliProps, exclude) if extra is None: if hasattr(propCls, '_propExtra'): extra = propCls._propExtra else: extra = {prop : {} for prop in cliProps} for propName in cliProps: propObj = propCls.getProp(propName) propType = propObj.__class__.__name__ parserFunc = getattr( sys.modules[__name__], '_{}'.format(propType), None) if parserFunc is None: log.warning('No CLI parser function for property {} ' '(type {})'.format(propName, propType)) continue shortArg = '-{}'.format(shortArgs[propName]) longArg = '--{}'.format(longArgs[ propName]) propExtra = extra.get(propName, {}) parserFunc(parser, propObj, propCls, propName, propHelp.get(propName, None), shortArg, longArg, **propExtra)
[docs] def generateArguments(hasProps, useShortArgs=False, xformFuncs=None, cliProps=None, shortArgs=None, longArgs=None, exclude='', **kwargs): """Given a ``HasProperties`` instance, generates a list of arguments which could be used to configure another instance in the same way. :param hasProps: The ``HasProperties`` instance. :param useShortArgs: If ``True`` the short argument version is used instead of the long argument version. :param xformFuncs: A dictionary of ``{property name -> function}`` mappings, which can be used to perform some arbitrary transformation of property values. All other keyword arguments are passed through to the ``xformFuncs`` functions. See the :func:`addParserArguments` function for descriptions of the other parameters. """ args = [] if cliProps is None: if hasattr(hasProps, '_cliProps'): cliProps = hasProps._cliProps else: cliProps = hasProps.getAllProperties()[0] if longArgs is None: if hasattr(hasProps, '_longArgs'): longArgs = hasProps._longArgs else: longArgs = dict(zip(cliProps, cliProps)) if shortArgs is None: if hasattr(hasProps, '_shortArgs'): shortArgs = hasProps._shortArgs else: shortArgs = _getShortArgs(hasProps, cliProps, exclude) if xformFuncs is None: xformFuncs = {} for propName in cliProps: propObj = hasProps.getProp(propName) xform = xformFuncs.get(propName, lambda v, **kwa: v) try: propVal = xform(getattr(hasProps, propName), **kwargs) except SkipArgument: continue # TODO Should I skip a property # if its value is None? if propVal is None: continue if useShortArgs: argKey = '-{}'.format(shortArgs[propName]) else: argKey = '--{}'.format(longArgs[ propName]) # TODO I should be using the (new) # serialise module for this. # This would incorporate the # TODO below. # TODO This logic could somehow be stored # as default transform functions for # the respective types if isinstance(propObj, (ptypes.Bounds, ptypes.Point, ptypes.Colour)): values = ['{}'.format(v) for v in propVal] elif isinstance(propObj, ptypes.ColourMap): values = [propVal.name] elif isinstance(propObj, ptypes.String): values = ['"{}"'.format(propVal)] elif isinstance(propObj, ptypes.Boolean): values = None if not propVal: argKey = None else: if propVal is None: values = None else: values = [propVal] if argKey is not None: args.append(argKey) if values is not None: args.extend(values) return [str(a) for a in args]