#!/usr/bin/env python

import os
import sys
from optparse import OptionParser





import re
try:
    from cStringIO import StringIO
except ImportError:
    from StringIO import StringIO


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:
                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:
            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))
                    else:
                        section[optname] = optval
                else:
                    raise PhpIniSyntaxError("[%s:%d] Invalid configuration line. Are there excessive leading spaces?" %
                                            (filename, lineno))
        # 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 self.items(header):
            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)

    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:
                fp = open(filename)
                try:
                    self._read(fp, filename)
                finally:
                    fp.close()
                read_ok.append(filename)
            except IOError, 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 write(self, filename):
        """ Write a php.ini format representation of the configuration state to a file. """
        fp = open(filename, 'w')
        try:
            self._write(fp)
        finally:
            fp.close()

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






def merge_input_configs(server_wide_filename=None, override_filename=None):
    """ Merge all supplied php.ini configuration files and return 
        SafeConfigParser object.
        Files are merged in the following order: server-wide, stdin, override.
    """
    config = PhpIniConfigParser()

    if server_wide_filename:
        try:
            config.read(server_wide_filename)
        except Exception, ex:
            pass

    try:
        config.readfp(sys.stdin)
    except:
        raise RuntimeError( "Cannot parse php.ini: %s" % str(sys.exc_info()[:2]) )


    if override_filename:
        try:
            config.read(override_filename)
        except Exception, ex:
            pass

    return config


class CgiPhpIniConfig:
    def merge(self, override_file=None):
        self.config = merge_input_configs("/etc/php.ini", override_file)

    def open(self, config_path):
        parent_dir = os.path.dirname(config_path)
        if not os.path.exists(parent_dir):
            os.mkdir(parent_dir)
            os.chown(parent_dir, 0, 0)

        return open(config_path, 'wb')

    def write(self, fileobject):
        fileobject.write("""#ATTENTION!
#
#DO NOT MODIFY THIS FILE BECAUSE IT WAS GENERATED AUTOMATICALLY,
#SO ALL YOUR CHANGES WILL BE LOST THE NEXT TIME THE FILE IS GENERATED.
""".replace('#', '; '))
        fileobject.write('\n')
        self.config.writefp(fileobject)





class PhpIniMngApp:
    def __init__(self):
        self.options = None
        self.config_path = None

    def main(self, argv):
        """ Parse command line arguments, check their sanity and provide help. """
        usage = "usage: %prog [options] [php_ini_file]"
        parser = OptionParser(usage=usage)
        parser.add_option('-o', '--override', 
                          help="Load custom directives (takes precedence over all others)")

        (self.options, args) = parser.parse_args()

        if len(args) < 1:
            parser.error("incorrect number of arguments")

        self.config_path = args[0]
        config = CgiPhpIniConfig()

        os.umask(022)
        config.merge(self.options.override)

        conffile = config.open(self.config_path)
        try:
            config.write(conffile)
        finally:
            conffile.close()


def main():
    """ phpinimng main entry point for command-line execution. """
    try:
        PhpIniMngApp().main(sys.argv)
    except SystemExit:
        raise
    except Exception, ex:
        sys.stderr.write('%s\n' % ex)
        sys.exit(1)

main()

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