Source code for tfep.utils.cli.tool
#!/usr/bin/env python
# =============================================================================
# MODULE DOCSTRING
# =============================================================================
"""
Utility classes to wrap command line tools.
The module provides a class :class:`.CLITool` that provides boilerplate code to
wrap command line tools and make them compatible to :class:`~tfep.utils.cli.Launcher`.
"""
# =============================================================================
# GLOBAL IMPORTS
# =============================================================================
import abc
import inspect
import os
# =============================================================================
# CLITOOL
# =============================================================================
[docs]
class CLITool:
"""Command line tool wrapper.
The class mainly fulfills two roles:
1. Encapsulates input and outputs of a command and provide a command
specification that can be understood by :class:`tfep.utils.cli.Launcher`.
2. Converts and sanitizes Python types to string command line parameters.
3. Provides CLI interfaces with readable parameter names avoiding abbreviations
that makes the code harder to read.
Wrapping a new command line tool requires creating a new class that inherits
from ``CLITool`` and defines its arguments using the options descriptors such
as :class:`.AbsolutePathOption` and :class:`.FlagOption` (see examples below).
The constructor takes as input ordered and keyword arguments. Keyword arguments
must match those defined with the option descriptors when the wrapper is declared.
Ordered arguments must be strings are appended to the command as strings.
The path to the executable (or simply the executable name if it is in the
system path) can be set globally through the class variable ``EXECUTABLE_PATH``,
or it can be specific to the command instance as specified in the constructor.
To associate a command to a particular subprogram, you can use the
``SUBPROGRAM`` class variable. E.g., for the gmx program in the GROMACS suite,
creating ``CLITool`` that prepare a ``gmx mdrun ...`` command requires
setting ``SUBPROGRAM = 'mdrun'``.
Once defined and instantiated, a command can be run either using a
:class:`~tfep.utils.cli.Launcher` class or the standard module ``subprocess``
after building the command with the :func:`.CLITool.to_subprocess` method.
Parameters
----------
executable_path : str, optional
The executable path associated to the instance of the command. If this
is not specified, the ``EXECUTABLE_PATH`` class variable is used instead.
See Also
--------
`tfep.utils.cli.Launcher` : Launch and run commands.
Examples
--------
Suppose we want to create a wrapper for a subset of the command ``grep``
that supports reading the pattern from a file. We can create a wrapper
with the following syntax
>>> class MyGrep(CLITool):
... EXECUTABLE_PATH = 'grep'
... patterns_file_path = KeyValueOption('-f')
... max_count = KeyValueOption('-m')
... print_version = FlagOption('-v')
You can then create an command instance specifying the options. For example,
:class:`.FlagOption`s takes either ``True`` or ``False``.
>>> my_grep_cmd = MyGrep(print_version=True)
You can then pass the command to a :class:`~tfep.utils.cli.Launcher` or use
the :func:`.CLITool.to_subprocess` method can be used to convert the command
to a sanitized ``list`` that can be executed by the Python standard module
``subprocess``.
>>> my_grep_cmd.to_subprocess()
['grep', '-v']
Another example more complex example
>>> my_grep_cmd = MyGrep('input.txt', patterns_file_path='my_patterns.txt', max_count=3)
>>> my_grep_cmd.to_subprocess()
['grep', '-m', '3', '-f', 'my_patterns.txt', 'input.txt']
"""
SUBPROGRAM = None
[docs]
def __init__(self, *args, executable_path=None, **kwargs):
self.args = args
self._executable_path = executable_path
# Check that keyword arguments match.
options_descriptions = self._get_defined_options()
for k, v in kwargs.items():
if k not in options_descriptions:
raise AttributeError('Undefined CLI option ' + k)
# Set the value.
setattr(self, k, v)
@property
def executable_path(self):
"""The path to the command executable to run."""
if self._executable_path is None:
return self.EXECUTABLE_PATH
return self._executable_path
@executable_path.setter
def executable_path(self, value):
self._executable_path = value
[docs]
def to_subprocess(self):
"""Convert the command to a list that can be run with the ``subprocess`` module.
Returns
-------
subprocess_cmd : List[str]
The command in subprocess format. For example ``['grep', '-v']``.
"""
subprocess_cmd = [self.executable_path]
# Add subprogram
if self.SUBPROGRAM is not None:
subprocess_cmd.append(self.SUBPROGRAM)
# Append all options.
for option_descriptor in self._get_defined_options().values():
subprocess_cmd.extend(option_descriptor.to_subprocess(self))
# Append all ordered args.
subprocess_cmd.extend([str(x) for x in self.args])
return subprocess_cmd
@classmethod
def _get_defined_options(cls):
"""Return a dict attribute_name -> description object for all CLIOptions defined."""
options_descriptors = {}
for attribute_name, descriptor_object in inspect.getmembers(cls, inspect.isdatadescriptor):
if isinstance(descriptor_object, CLIOption):
options_descriptors[attribute_name] = descriptor_object
return options_descriptors
# =============================================================================
# CLI OPTIONS
# =============================================================================
[docs]
class CLIOption(abc.ABC):
"""Generic descriptor for command line option.
This must be inherited by all options for :class:``.CLITool`` to automatically
discover the option. To implement this, it is sufficient to provide an
implementation of the ``to_subprocess()`` method, which takes the object
instance as input and outputs a list with the strings to append to the
command in ``subprocess`` format.
Parameters
----------
option_name : str
The name of the option in the command line interface (e.g., ``'-o'``).
"""
def __set_name__(self, owner_type, name):
self.public_name = name
self.private_name = '_' + name
def __get__(self, owner_instance, owner_type):
if owner_instance is None:
# This was call from the owner class. Return the descriptor object.
return self
return getattr(owner_instance, self.private_name, None)
def __set__(self, owner_instance, value):
setattr(owner_instance, self.private_name, value)
[docs]
@abc.abstractmethod
def to_subprocess(self, owner_instance):
"""Return the strings to append to the command in ``subprocess`` format.
For example, it might return something like ``['-o', 'path_to_my_file.txt']``.
"""
pass
[docs]
class KeyValueOption(CLIOption):
"""A generic command line key-value option.
This descriptor simply converts the value to string.
Parameters
----------
option_name : str
The name of the option in the command line interface (e.g., ``'-o'``).
"""
[docs]
def to_subprocess(self, owner_instance):
"""Implements ``CLIOption.to_subprocess()``."""
value = getattr(owner_instance, self.private_name, None)
if value is None:
return []
return [self.option_name, str(value)]
[docs]
class AbsolutePathOption(KeyValueOption):
"""A file or directory path that is converted to an absolute path when instantiated.
Relative file paths change change with the current working directory. This
option type converts relative paths to absolute paths when the option is
assigned so that it refers to the same file even if the working directory
is changed.
Parameters
----------
option_name : str
The name of the option in the command line interface (e.g., ``'-o'``).
"""
def __set__(self, owner_instance, value):
abs_path = os.path.abspath(value)
setattr(owner_instance, self.private_name, abs_path)
[docs]
class FlagOption(CLIOption):
"""A generic command line flag option.
This descriptor accepts only ``True``/``False`` or ``None`` and it specifies
CLI flag parameters (i.e., that do not take a value). If ``None``, it is not
passed to the command. If ``False``, its behavior depends on the
``prepend_no_to_false`` parameter (see below).
Parameters
----------
option_name : str
The name of the option in the command line interface (e.g., ``'-o'``).
prepend_to_false : str, optional
If given and the descriptor is ``False``, this string (typically ``'no'``)
is inserted into the flag passed to the command right after the dash
character(s).
"""
[docs]
def __init__(self, option_name, prepend_to_false=None):
super().__init__(option_name)
self.prepend_to_false = prepend_to_false
def __set__(self, owner_instance, value):
if not isinstance(value, bool) and value is not None:
raise ValueError(self.public_name + ' must be either a boolean or None')
setattr(owner_instance, self.private_name, value)
[docs]
def to_subprocess(self, owner_instance):
"""Implements ``CLIOption.to_subprocess()``."""
value = getattr(owner_instance, self.private_name, None)
if (value is None or (
(not value and self.prepend_to_false is None))):
return []
if value is True:
return [self.option_name]
# value is False and self.prepend_to_false is not None.
if self.option_name.startswith('--'):
n_dashes = 2
else:
n_dashes = 1
option_name = self.option_name[:n_dashes] + self.prepend_to_false + self.option_name[n_dashes:]
return [option_name]