# Copyright 1999-2017. Plesk International GmbH. All rights reserved.
""" Collection of utilities for handling php.ini files. 

    Following are excerpts from comments in stock php.ini file and php.net
    documentation that describe php.ini syntax and usage.

    PHP's initialization file, generally called php.ini, is responsible for
    configuring many of the aspects of PHP's behavior.

    The syntax of the file is extremely simple.  Whitespace and Lines
    beginning with a semicolon are silently ignored (as you probably guessed).
    Section headers (e.g. [Foo]) are also silently ignored, even though
    they might mean something in the future.

    Directives following the section heading [PATH=/www/mysite] only
    apply to PHP files in the /www/mysite directory.  Directives
    following the section heading [HOST=www.example.com] only apply to
    PHP files served from www.example.com.  Directives set in these
    special sections cannot be overridden by user-defined INI files or
    at runtime. Currently, [PATH=] and [HOST=] sections only work under
    CGI/FastCGI.
    http://php.net/ini.sections

    Directives are specified using the following syntax:
    directive = value
    Directive names are *case sensitive* - foo=bar is different from FOO=bar.
    Directives are variables used to configure PHP or PHP extensions.
    There is no name validation.  If PHP can't find an expected
    directive because it is not set or is mistyped, a default value will be used.

    The value can be a string, a number, a PHP constant (e.g. E_ALL or M_PI), one
    of the INI constants (On, Off, True, False, Yes, No and None) or an expression
    (e.g. E_ALL & ~E_NOTICE), a quoted string ("bar"), or a reference to a
    previously set variable or directive (e.g. ${foo})

    Boolean flags can be turned on using the values 1, On, True or Yes.
    They can be turned off using the values 0, Off, False or No.

    An empty string can be denoted by simply not writing anything after the equal
    sign, or by using the None keyword:

     foo =         ; sets foo to an empty string
     foo = None    ; sets foo to an empty string
     foo = "None"  ; sets foo to the string 'None'

    If you use constants in your value, and these constants belong to a
    dynamically loaded extension (either a PHP extension or a Zend extension),
    you may only use these constants *after* the line that loads the extension.

    ENV variables are also accessible in .ini files. As such it is possible to 
    reference the home directory using ${LOGIN} and ${USER}. Environment variables 
    may vary between Server APIs as those environments may be different. 

     include_path = ".:${USER}/pear/php"
"""

import os
import re

from contextlib import closing
try:
    from cStringIO import StringIO
except ImportError:
    from StringIO import StringIO

try:
    import plesk_log
    log = plesk_log.getLogger(__name__)
except ImportError:
    # Avoid failing doctest in source tree
    import logging
    log = logging.getLogger(__name__)


class PhpIniSyntaxError(Exception):
    """ Raised when a syntax error is encountered in php.ini file. """
    pass

class PhpIniFeatureNotSupportedError(PhpIniSyntaxError):
    """ Raised when encountered a certain feature in php.ini file that is not supported. """
    pass

class NoSectionError(Exception):
    """ Raised when a section with requested name was not found. """
    def __init__(self, section):
        Exception.__init__(self, "No section: %s" % section)
        self.section = section

class NoDirectiveError(Exception):
    """ Raised when a directive with requested name was not found. """
    def __init__(self, directive, section):
        Exception.__init__(self, "No directive in section %s: %s" % (section, directive))
        self.section = section
        self.directive = self.option = directive


class PhpIniConfigParser(object):
    """ This is a parser for php.ini files. It works a lot like parsers from 
        ConfigParser module, but was customized for PHP twisted logic. 

        Important limitations and aspects to note:
         * array directives (like 'extension') are stored separately with their
           order preserved;
         * order of other directives is not guaranteed to be preserved;
         * hence variable interpolation (or substitution, or referencing, or whatever 
           you may call it) is not supported and will trigger a syntax error;
         * all sections apart from special PATH= and HOST= ones are merged into a 
           single one (by default its name is 'PHP'), this is governed by 
           is_section_retained() method;
         * file parsing is fail-fast as opposed to ConfigParser implementation;
         * when parsing comments and blank lines are ignored.
    """
    def __init__(self, default_section="PHP"):
        self._default_header = default_section
        self._sections = {}
        self._extensions = []

        self._create_section(self._default_header)

    def sections(self):
        """ Returns a list of section names, including default one.

            >>> config.sections()
            ['PHP', 'HOST=www.example.com']
        """
        return self._sections.keys()

    def has_section(self, section):
        """ Check whether the named section is present in configuration.

            >>> config.has_section('PHP')
            True
            >>> config.has_section('Pdo')
            False
        """
        return section in self._sections

    def items(self, section):
        """ Returns a list of (name, value) pairs for each option in the given section.
            For a default section extension directives also appear in this list.

            >>> config.items('HOST=www.example.com')
            [('display_startup_errors', 'True')]
        """
        try:
            d = self._sections[section]
        except KeyError:
            if section != self._default_header:
                raise NoSectionError(section)
            d = {}
        if '__name__' in d:
            del d['__name__']
        if section == self._default_header:
            return self._extensions + d.items()
        else:
            return d.items()

    def remove_section(self, section):
        """ Removes a configuration section. Returns previous existence status.

            >>> config = PhpIniConfigParser()
            >>> config.readstr(data1)
            >>> config.remove_section('HOST=www.example.com')
            True
            >>> config.remove_section('PHP') # default section is always present,
            ...                              # but this will delete its content
            True
            >>> config.sections()
            ['PHP']
            >>> config.items('PHP')
            []
        """
        if section in self._sections:
            del self._sections[section]
            if section == self._default_header:
                log.debug("PhpIniConfigParser: default section '%s' was cleaned", section)
                self._extensions = []
                self._create_section(self._default_header)
            return True
        else:
            return False

    def extensions(self):
        """ Returns list of (directive, path) pairs for extension directives in
            php.ini file preserving order.

            Following directives are recognised as extension ones:
             * extension
             * zend_extension
             * zend_extension_debug      (prior to PHP 5.3.0)
             * zend_extension_debug_ts   (prior to PHP 5.3.0)
             * zend_extension_ts         (prior to PHP 5.3.0)

            >>> config.extensions()
            [('zend_extension_debug_ts', '/path/to/ioncube_loader_5.3.so'), ('extension', 'pdo.so')]
        """
        return self._extensions[:]

    def get(self, section, option):
        """ Get an option value for named section or default one if not section. """
        if not section:
            section = self._default_header
        if section not in self._sections:
            raise NoSectionError(section)
        elif option in self._sections[section]:
            return self._sections[section][option]
        elif section == self._default_header and self._is_extension_directive(option):
            return [ext[1] for ext in self._extensions if ext[0] == option]
        else:
            raise NoDirectiveError(section, option)

    def getbool(self, section, option):
        """ A convenience method that coerces the option value in the specified 
            section to a boolean.
        """
        return self._boolean_value_as_bool(self.get(section, option))

    def is_section_retained(self, section):
        """ A predicate method that decides whether to retain a given section
            or merge its contents into a default one.

            Below is a tricky monkey-patching mumbo-jumbo example. It's usually better to
            just subclass, since monkey-patching is hard to debug. But I'm feeling fancy ;)
            >>> config = PhpIniConfigParser()
            >>> config.is_section_retained = type(PhpIniConfigParser.is_section_retained)(
            ...         lambda self, section:
            ...             PhpIniConfigParser.is_section_retained(self, section) or 
            ...             section in ('Pdo', 'Pdo_mysql'),
            ...         config, PhpIniConfigParser)
            >>> config.readstr(data1)
            >>> config.readstr(data2)
            >>> config.sections()
            ['Pdo', 'Pdo_mysql', 'PATH=/www/mysite', 'PHP', 'HOST=www.example.com']
        """
        return section.startswith('PATH=') or section.startswith('HOST=')

    def _create_section(self, section):
        """ Returns section structure, optionally creating it. """
        if section not in self._sections:
            log.debug("PhpIniConfigParser: created new section '%s'", section)
            self._sections[section] = {'__name__': section}
        return self._sections[section]

    def _has_variable_interpolation(self, value):
        """ Check whether a given string contains variable interpolation.
            E.g. ".:${USER}/pear/php" has one for USER variable.

            >>> config._has_variable_interpolation(".:${USER}/pear/php")
            True
            >>> config._has_variable_interpolation("abra}${cadabra")
            False
        """
        return '${' in value and '}' in value and value.index('${') < value.rindex('}')

    BOOLEAN_TRUE_VALUES  = ('1', 'on', 'true', 'yes')
    BOOLEAN_FALSE_VALUES = ('0', 'off', 'false', 'no')

    def _is_boolean_setting(self, name, value):
        """ Check whether a given setting is a boolean one. """
        return value.lower() in self.BOOLEAN_TRUE_VALUES + self.BOOLEAN_FALSE_VALUES

    def _boolean_value_as_bool(self, value):
        """ Convert string boolean value to a bool. """
        if value.lower() in self.BOOLEAN_TRUE_VALUES:
            return True
        elif value.lower() in self.BOOLEAN_FALSE_VALUES:
            return False
        else:
            raise PhpIniSyntaxError("Value is not a valid boolean one: '%s'" % value)

    _EXTENSION_RE = re.compile(r'^(zend_)?extension(_debug)?(_ts)?$')

    def _is_extension_directive(self, name):
        """ Check whether a given directive name requests loading extension. 

            >>> dirs = ('extension', 'zend_extension', 'zend_extension_debug', 'zend_extension_ts', 'zend_extension_debug_ts')
            >>> for directive in dirs:
            ...     if not config._is_extension_directive(directive):
            ...         break
            ... else:
            ...     print "OK"
            OK
            >>> config._is_extension_directive('display_errors')
            False
        """
        return self._EXTENSION_RE.match(name) is not None

    _SECTION_HEADER_RE = re.compile(r'\[(?P<header>[^]]+)\]')
    _DIRECTIVE_RE = re.compile(
        r'^(?P<option>[^=\s][^=]*)'         # quite permissive, but there should be no leading spaces
        r'\s*=\s*'                          # equals sign with any number of space/tabs on each side
        r'(?P<value>.*)$'                   # everything up to end of line
    )

    def _read(self, fp, filename):
        """ Parse php.ini file and merge new data into internal structures.

            This is a fail-fast parser, i.e. if errors are encountered, parser will fail
            immediately. Any already read data will be retained.
        """
        header = real_header = self._default_header
        section = self._create_section(header)
        lineno = 0

        for line in fp:
            lineno += 1
            if not line.strip() or line.lstrip()[0] in ';#':
                continue    # blank line or comment
            # Is it a section header?
            header_match = self._SECTION_HEADER_RE.match(line)
            if header_match:
                header = real_header = header_match.group('header')
                if not self.is_section_retained(header):
                    header = self._default_header
                section = self._create_section(header)
            else:
                # Is it an option line?
                option_match = self._DIRECTIVE_RE.match(line)
                if option_match:
                    optname, optval = option_match.group('option', 'value')
                    optname, optval = optname.rstrip(), optval.strip()
                    # Supporting evil is evil. Therefore refuse handling variable interpolations
                    # (which otherwise would require topological sorting of option lines and 
                    # some kind of options origin control, e.g. same origin policy).
                    if self._has_variable_interpolation(optval):
                        raise PhpIniFeatureNotSupportedError("[%s:%d] Variable references are not supported in option "
                                                             "values, found '%s'" % (filename, lineno, optval))
                    if self._is_extension_directive(optname):
                        if header != self._default_header:
                            raise PhpIniSyntaxError("[%s:%d] Extension directive '%s' found in wrong section '%s'" % 
                                                    (filename, lineno, optname, real_header))
                        # Extension loading order should be preserved, hence such directives are stored separately.
                        self._extensions.append((optname, optval))
                        continue

                    section[optname] = optval
                else:
                    log.debug("PhpIniConfigParser: failed to parse line '%s'", line.rstrip())
        # Maybe deduplicate self._extensions?

    def _write_section(self, fp, header):
        """ Writes a given section identified with its header to a file-like object. """
        fp.write("[%s]\n" % header)
        for optname, optvalue in sorted(self.items(header), key=lambda item: item[0]):
            fp.write("%s = %s\n" % (optname, optvalue))
        fp.write("\n")

    def _write(self, fp):
        """ Write a php.ini representation of the configuration state. """
        self._write_section(fp, self._default_header)
        for header in set(self._sections.keys()) - set([self._default_header]):
            self._write_section(fp, header)

        fp.flush()
        os.fsync(fp.fileno())

    def read(self, filenames):
        """ Read and parse a filename or list of filenames.

            Returns a list of successfully read files, others are silently ignored.
        """
        if isinstance(filenames, basestring):
            filenames = [filenames]
        read_ok = []
        for filename in filenames:
            try:
                with open(filename) as fp:
                    self._read(fp, filename)
                read_ok.append(filename)
            except IOError, ex:
                log.debug("PhpIniConfigParser: failed to read from file, ignoring: %s", ex)
                continue
        return read_ok

    def readfp(self, fp, filename=None):
        """ Like read() but argument must be a file-like object. """
        if filename is None:
            try:
                filename = fp.name
            except AttributeError:
                filename = '<???>'
        self._read(fp, filename)

    def readstr(self, string, filename='<internal>'):
        """ Like read() but argument must be a string with data to read. 

            >>> config = PhpIniConfigParser()
            >>> config.readstr(data1)
            >>> config.getbool(None, 'display_errors')
            False

            >>> config.readstr(data2)   # merge a second "diff" config
            >>> config.sections()
            ['PATH=/www/mysite', 'PHP', 'HOST=www.example.com']
            >>> config.getbool(None, 'display_errors')
            True
            >>> config.get('PHP', 'pdo_odbc.connection_pooling')
            'relaxed'
        """
        with closing(StringIO(string)) as fp:
            self._read(fp, filename)

    def write(self, filename):
        """ Write a php.ini format representation of the configuration state to a file. """
        with open(filename, 'w') as fp:
            self._write(fp)

    def writefp(self, fp):
        """ Like write() but argument must be a file-like object. """
        self._write(fp)

    def writestr(self):
        """ Like write(), but returns content to be written as a string. 

            >>> config = PhpIniConfigParser()
            >>> config.readstr(data1)
            >>> config.readstr(data2)   # merge a second "diff" config

            >>> print config.writestr() # doctest: +ELLIPSIS, +REPORT_UDIFF
            [PHP]
            zend_extension_debug_ts = /path/to/ioncube_loader_5.3.so
            extension = pdo.so
            extension = pdo_mysql.so
            ...
            display_errors = On
            ...
            <BLANKLINE>
            [PATH=/www/mysite]
            display_errors = Off
            <BLANKLINE>
            [HOST=www.example.com]
            ...
            display_errors = False
            ...
        """
        with closing(StringIO()) as fp:
            self._write(fp)
            return fp.getvalue()

# vim: ts=4 sts=4 sw=4 et :