Source code for schrodinger.application.inputconfig

"""
A modified version of the configobj module.

This module can be used to read and write simplified input file (SIF)
formats, such as those used by VSW, QPLD, Prime, Glide, MacroModel, etc.

The SIF format is related to the Windows INI (.ini) format that is read and
writen by configobj, but with following exceptions:

1. Spaces are used instead of equals signs to separate keywords from
   values.
2. Keywords should be written in upper case. (The reading of keywords is
   case-sensitive).

Example input file::

    KEYWORD1 value
    KEYWORD2 "value with $special$ characters"
    KEYWORD3 item1, item2, item3
    KEYWORD4 True
    KEYWORD5 10.2
    KEYWORD6 1.1, 2.2, 3.3, 4.4

    [ SECTION1 ]
       SUBKEYWORD1 True
       SUBKEYWORD2 12345
       SUBKEYWORD3 "some string"

    #END

For more information on ConfigObj, see:
http://www.voidspace.org.uk/python/configobj.html

Copyright Schrodinger, LLC. All rights reserved.

"""

# Contributors: K. Shawn Watts, Matvey Adzhigirey

################################################################################
# Packages
################################################################################

import copy
import io
import re

import validate
from configobj import ConfigObj
from configobj import flatten_errors
from configobj import wspace_plus

wspace = ' \r\n\v\t'


def custom_is_list(value, min=None, max=None):
    """
    This list validator turns single items without commas into 1-element
    lists. The default list validator requires a trailing comma for these.

    That is, with this function as the list validator and a list spec, an
    input line of "indices = 1" will create a value of [1] for the key
    'indices'.

    """
    (min_len, max_len) = validate._is_num_param(('min', 'max'), (min, max))

    # If checking one item and it's a string:
    if type(value) != type([]) and type(value) != type(()):
        # not a list (string, float, int)
        value = [value]

    try:
        num_members = len(value)
    except TypeError:
        raise validate.VdtTypeError(value)
    if min_len is not None and num_members < min_len:
        raise validate.VdtValueTooShortError(value)
    if max_len is not None and num_members > max_len:
        raise validate.VdtValueTooLongError(value)
    return value


def custom_is_string_list(value, min=None, max=None):
    """
    Custom is_string_list() method which overrides the one in validate.py.
    This method does not raise an exception if a string is passed, and
    instead tries to break it into a list of strings.
    """
    #print 'custom_is_string_list() input:', value
    return [validate.is_string(mem) for mem in custom_is_list(value, min, max)]


validate.is_list = custom_is_list
validate.is_string_list = custom_is_string_list


class InputConfig(ConfigObj):
    """
    Parse keyword-value input files and make the settings available in a
    dictionary-like fashion.

    Typical usage::

        list_of_specs = ["NUM_RINGS = integer(min=1, max=100, default=1)"]
        config = InputConfig(filename, list_of_specs)
        if config['NUM_RINGS'] > 4:
            do_something()

    """

    def __init__(self, infile=None, specs=None):
        """
        :type infile: string
        :param infile: The name of the input file.

        :type specs: list of strings
        :param specs: A list of strings, each in the format
                `<keywordname> = <validator>(<validatoroptions>)`. An example
                string is `NUM_RINGS = integer(min=1, max=100, default=1)`.
                For available validators, see:
                http://www.voidspace.org.uk/python/validate.html.

        """
        self._specs = specs
        self._key_order = []  # List of supported keywords in preferred order

        if isinstance(infile, str):  # File path
            # If file specified, re-Write keywords/values separated with "="
            # to a StringIO handle, and pass it to ConfigObj:
            tmpfh = io.StringIO()

            item_re = re.compile(r'^\s*(\S+)\s+(.+)$')
            # Replace white space after first word with "=":

            with open(infile) as fh:
                for iline in fh:
                    iline = iline.strip(
                        '\n')  # Remove the trailing return character
                    # FIXME: Improve the matching mechanism
                    if not iline.strip().startswith('['):  # not section iline
                        match = item_re.match(iline)
                        if match:
                            if len(match.groups()) != 2:
                                print('LENTH OF MATCH != 2!!! len:',
                                      len(match.groups()))
                            iline = '='.join(match.groups())
                    #print 'OUTLINE', iline
                    #print ''
                    tmpfh.write(iline + '\n')
            tmpfh.seek(0)  # go to beginning of file
            infile = tmpfh
        elif isinstance(infile, dict):
            # Keyword dict was specified
            infile = copy.deepcopy(infile)
            # ConfigObj seems to retain a references to values from the
            # keyword dict, so when it's modified the input keywords
            # dictionary's values (if they are lists/dicts/etc will also get
            # modified. For this reason deep copy it first.

        if specs:
            specs_no_comments = []
            for iline in specs:
                # Use only everything before the first "#" (ignore comments):
                iline = iline.split('#')[0]
                specs_no_comments.append(iline)
                s = iline.strip().split()
                if s:
                    self._key_order.append(s[0])

            try:
                ConfigObj.__init__(
                    self,
                    infile,
                    configspec=specs_no_comments,
                    raise_errors=True,
                    indent_type="    ")
            except Exception as err:
                raise RuntimeError(str(err))

        elif infile:
            try:
                ConfigObj.__init__(
                    self, infile, raise_errors=True, indent_type="    ")
            except Exception as err:
                raise RuntimeError(str(err))
        else:
            try:
                ConfigObj.__init__(self, raise_errors=True, indent_type="    ")
            except Exception as err:
                raise RuntimeError(str(err))
        #print '**************'
        #print 'OUTPUT:', self
        #print '**************'

    def getSpecsString(self):
        """
        Return a string of specifications.
        One keywords per line.
        Raises ValueError if this class has no specifications.
        """

        if self._specs:
            outstr = ""
            for spec in self._specs:
                outstr += (spec + '\n')
            return outstr
        else:
            raise ValueError("This class has no specification")

    def printout(self):
        """
        Print all keywords of this instance to stdout.

        This method is meant for debugging purposes.

        """
        output = io.StringIO()
        self.write(output)
        for iline in output.getvalue().split('\n'):
            print(iline)

    def writeInputFile(self,
                       filename,
                       ignore_none=False,
                       yesno=False,
                       smartsort=False):
        """
        Write the configuration to a file in the InputConfig format.

        :type filename: a file path or an open file handle
        :param filename: The file to write the configuration to.

        :type ignore_none: bool
        :param ignore_none: If True, keywords with a value of None will not
                be written to the input file.

        :type yesno: bool
        :param yesno: If True, boolean keywords will be written as "yes" and
                "no", if False, as "True" and "False".

        :type smartsort: bool
        :param smartsort: If True, keywords that are identical except for the
                numbers at the end will be sorted such that "2" will go before "10".
        """

        lines = ConfigObj.write(self)

        # Write keyword-value pairs to the input file:
        if hasattr(filename, 'write'):  # File handle passed
            fh = filename
        else:
            fh = open(filename, 'w')

        sections_present = False
        kw_line_list = []  # list of tuples: (keyword, iline)
        for iline in lines:
            s = iline.split(None, 2)
            if not s:  # empty line
                continue
            keyword = s[0]  # first word in line
            value = s[-1]  # last word in line
            if ignore_none and value == 'None':
                continue

            # Ev:76198
            if yesno:
                if value == 'True':
                    iline = iline.replace('True', 'yes')
                elif value == 'False':
                    iline = iline.replace('False', 'no')

            if keyword.startswith('['):
                sections_present = True
            iline = iline.replace('=', ' ', 1) + "\n"
            # NOTE: The Python file object will automatically convert "\n" to "\r\n" on Windows

            kw_line_list.append((keyword, iline))

        # Do not attempt to sort keywords if sections are present:

        if sections_present:
            for keyword, iline in kw_line_list:
                # Add a blank line before new (root) sections:
                if iline.startswith("[") and not iline.startswith("[["):
                    fh.write("\n")
                    # NOTE: The Python file object will automatically convert "\n" to "\r\n" on Windows
                fh.write(iline)

        else:  # attempt to sort
            kw_dict = {}  # All keywords/lines as dict
            max_key_size = 1  # Number of characters in the longest keyword
            for key, iline in kw_line_list:
                kw_dict[key] = iline
                if len(key) > max_key_size:
                    max_key_size = len(key)

            # Sort the keywords by the order that they appear in the specs:
            out_kw_line_list = []
            for key in self._key_order:
                if key in kw_dict:
                    out_kw_line_list.append((key, kw_dict[key]))

            # Smart-sorting was added as part of PYTHON-1815:
            int_conv = lambda text: int(text) if text.isdigit() else text
            alphanum_key = lambda key_value: [int_conv(c) for c in re.split('([0-9]+)', key_value[0])]

            if smartsort:
                sort_func = alphanum_key
            else:
                sort_func = None

            # Write keywords that are not in the spec, sorted by the keyword name:
            for key, iline in sorted(kw_line_list, key=sort_func):
                if key not in self._key_order:
                    out_kw_line_list.append((key, iline))

            # Write keyword-line pairs to the input file:
            for key, iline in out_kw_line_list:
                fh.write(iline)

        if fh != filename:
            fh.close()

    def validateValues(self, preserve_errors=True, copy=True):
        """
        Validate the values read in from the InputConfig file.

        Provide values for keywords with validators that have default
        values.

        If a validator for a keyword is specified without a default and the
        keyword is missing from the input file, a RuntimeError will be
        raised.

        :type preserve_errors: bool
        :param preserve_errors: If set to False, this method returns True if
                all tests passed, and False if there is a failure. If set to True,
                then instead of getting False for failed checkes, the actual
                detailed errors are printed for any validation errors encountered.

            Even if preserve_errors is True, missing keys or sections will
            still be represented by a False in the results dictionary.

        :type copy: bool
        :param copy: If False, default values (as specified in the 'specs'
                strings in the constructor) will not be copied to object's
                "defaults" list, which will cause them to not be written out
                when writeInputFile() method is called.
                If True, then all keywords with a default will be written
                out to the file via the writeInputFile() method.
                NOTE: Default is True, while in ConfigObj default is False.
        """

        #for keyname in kw_dict:
        #    if not keyname in self._key_order:
        #        raise ValueError("Unsupported keyword: %s" % keyname)

        #print '\nUNVALIDATED KEYWORDS:', kw_dict
        #keywords = self.keys()
        vdt = validate.Validator()
        res = self.validate(
            vdt, preserve_errors=preserve_errors,
            copy=copy)  # Copy so that defaults are set

        if preserve_errors:
            errors = []
            error_msg = ""
            for entry in flatten_errors(self, res):
                section_list, key, error = entry
                section_list.insert(0, '[root]')
                expected_type = None
                if key is not None:
                    # Ev:99875 Save the expected type of the keyword:
                    # for now we'll only report expected types for keys in the root section
                    if len(section_list) == 1:
                        expected_type = self.configspec[key]
                    section_list.append(key)
                else:
                    section_list.append('[missing]')
                section_string = ', '.join(section_list)
                errors.append((section_string, error, expected_type))
            errors.sort()
            for key, error, expected_type in errors:
                if expected_type:
                    # Ev:99875 In addition to the error, print the expected type of the keyword:
                    error_msg += '%s : %s Expected type: %s\n' % (key, (
                        error or 'MISSING'), expected_type)
                else:
                    error_msg += '%s : %s\n' % (key, (error or 'MISSING'))

            if errors:
                raise RuntimeError(error_msg)

        #print '\nVALIDATED KEYWORDS:', self
        #print ''
        else:  # not preserving errors. Returns True or False
            return res

    def _quote(self, value, multiline=True):
        """
        Overwrite ConfigObj's quoting method to ensure that values with spaces
        get quoted. (ConfigObj quotes only values that start and/or end with
        spaces).
        """

        # Only modify strings that contain white spaces:
        modify = False
        if isinstance(value, str):
            for spacechar in wspace:
                if spacechar in value:
                    modify = True
                    break

        if modify:
            # Do not modify values that are already properly quoted by ConfigObj:
            # (values that start of end with a whilte space or a quote, and
            #  values that contain commas)
            if value[0] in wspace_plus or value[-1] in wspace_plus or ',' in value:
                modify = False

        if modify:
            # Temporarily insert a space at position 0 to force ConfigObj to quote the value:
            value = " " + value

        value = ConfigObj._quote(self, value, multiline)

        if modify:
            # Remove the added space:
            value = value[0] + value[2:]

        return value


def determine_jobtype(inpath):
    """
    Parse the specified file and determines its job type.

    This is needed in order to avoid parsing of an input file if its
    job type is invalid.

    Return the job type as a string, or the empty string if no JOBTYPE
    keyword is found.

    """
    for iline in open(inpath):
        s = iline.strip().split()
        if len(s) >= 2 and s[0] in ['JOBTYPE', 'JOB_TYPE']:
            return s[1]
    return ''


# EOF