# Copyright (c) 2008 Liraz Siri <liraz@turnkeylinux.org>
#
# This file is part of turnkey-pylib.
#
# turnkey-pylib is open source software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License as
# published by the Free Software Foundation; either version 3 of the
# License, or (at your option) any later version.

"""Pythonic cli configuration module

Features:

- Elegant Pythonic interface
- Accept configuration from hierarchy of sources:

    1) command line (highest precedence)
    2) environment variable
    3) configuration file (test.conf)
    4) built-in default (lowest precedence)

- Automatic formatting of embedded usage information

Example usage::

    from cliconf import *
    class MyOpts(Opts):

        myopt = Opt(short="o")
        mybool = BoolOpt(short="b")

        simple_opt = ""
        simple_bool = False

    class MyCliConf(CliConf):
        "Syntax: $AV0 [-options] <arg>"

        Opts = MyOpts

        # if you don't configure env_path
        # the environment doesn't configure options

        env_path = "MYPROG_" 

        # if you don't configure file_path
        # no configuration file is supported

        file_path = "/etc/myprog.conf"

    # needed by pyproject.CliWrapper to support -h / --help
    usage = MyCliConf.usage 

    try:
        opts, args = MyCliConf.getopt()
    except MyCliConf.Error, e:
        MyCliConf.usage(e)

    if not args:
        MyCliConf.usage("not enough arguments")

    for opt in opts:
        print "%s=%s" % (opt.name, opt.val)
        print `dict(opt)`
"""

import os
import sys
import getopt
import copy
import types
import re
import string

class Opt(object):
    """This class represents an option.
    
    Iterator allows us to convert instance into a mapping/dictionary 

    Example usage::

        print opt.name
        print `dict(opt)`

    """

    def __init__(self, desc=None, short="", protect=False, default=None, parser=None):
        """Arguments:
           <desc>       description of option (I.e., for usage)
           <short>      one letter flag
           <protect>    True if option is protected in suid mode
           <default>    Default value
           <parser>     Function to use to parse option
        """

        # override class-level parser
        if parser:
            self.parser = parser

        self.desc = desc
        self.short = short
        self.protect = protect

        self.val = default
        self.name = None

    def __iter__(self):
        for attrname, attr in vars(self).items():
            yield attrname, attr

    def longopt(self):
        if self.name:
            return self.name.replace("_", "-")

        return ""
    longopt = property(longopt)

    def protected(self):
        # protected options can't be set in suid mode
        if self.protect and os.getuid() != os.geteuid():
            return True

        return False
    protected = property(protected)

    @staticmethod
    def parser(val):
        return val

    def set_val(self, val):
        if val is not None:
            val = self.parser(val)

        self._val = val

    def get_val(self):
        if hasattr(self, '_val'):
            return self._val

        return None

    val = property(get_val, set_val)

class BoolOpt(Opt):
    """This class represents a boolean option"""
    @staticmethod
    def parser(val):
        if val in (None, True, False):
            return val

        if val.lower() in ('', '0', 'no', 'false'):
            return False

        if val.lower() in ('1', 'yes', 'true'):
            return True

        raise Error("illegal value for bool (%s)" % val)

def is_bool(opt):
    return isinstance(opt, BoolOpt)

class Opts:
    """This class represents a collection of options.

    The user configures a set of options by inheriting from this class
    and setting class attributes which represent individual options.

    Options can be specified in full form, as instances of Opt or
    subclass, or in simple form as a built-in Python value which is
    converted into an Opt value when Opts is initialized by CliConf::

        class MyOpts(Opts):

            myopt = Opt(short="o")
            mybool = BoolOpt(short="b")

            simple_opt = ""
            simple_bool = False

    This configured class can specify which options will be parsed by CliConf::

        class MyCliConf(CliConf):
            Opt = MyOpt

    CliConf.getopt() returns an instance of this class that can be
    accessed to query the states of various options.

    Instances of this class implement a bit of magic to allow the
    class to have a more natural Pythonic interface.

        1) Object interface::

            opt = opts.my_opt
            print opt.name

        2) Sequence-like interface::

            for opt in opts:
                print opt.name

        3) Dictionary-like interface::

            if 'my_opt' in opts:
                print opts['my_opt'].name

    """
    def __init__(self):
        # make copies of options
        for attrname, attr in vars(self.__class__).items():
            if attrname[0] == "_":
                continue

            if isinstance(attr, Opt):
                attr = copy.copy(attr)
            elif isinstance(attr, types.BooleanType):
                attr = BoolOpt(default=attr)
            else:
                attr = Opt(default=attr)

            attr.name = attrname
            setattr(self, attrname, attr)

    def __iter__(self):
        for attr in vars(self).values():
            if isinstance(attr, Opt):
                yield attr

    def __getitem__(self, attrname):
        attr = getattr(self, attrname)
        if isinstance(attr, Opt):
            return attr

        raise KeyError(`attrname`)

    def __contains__(self, opt):
        if isinstance(opt, Opt):
            return opt in list(self)

        if isinstance(opt, types.StringType):
            attr = getattr(self, opt, None)
            if isinstance(attr, Opt):
                return True
            return False

        raise TypeError("type(%s) not a string or an Opt instance" %
                        `opt`)

class Error(Exception):
    pass

class CliConf:
    """Cli configuration class.
    
    This class is configured via inheritance::
    
        class MyCliConf(CliConf):
            "Syntax: $AV0 [-options] <arg>"

            Opts = MyOpts

    All methods in this class are either static methods or class
    methods, so creating an instance of this class before use is not
    required::

        try:
            opts, args = MyCliConf.getopt()
        except MyCliConf.Error, e:
            MyCliConf.usage(e)

    """
    Error = Error

    env_path = None
    file_path = None

    @classmethod
    def _cli_getopt(cls, args, opts):
        # make arguments for getopt.gnu_getopt
        longopts = ['help']
        shortopts = "h"

        for opt in opts:
            longopt = opt.longopt
            shortopt = opt.short

            if not is_bool(opt):
                longopt += "="

                if shortopt:
                    shortopt += ":"

            longopts.append(longopt)
            shortopts += shortopt

        try:
            opts, args = getopt.gnu_getopt(args, shortopts, longopts)
        except getopt.GetoptError, e:
            raise Error(e)

        for opt, val in opts:
            if opt in ('-h', '--help'):
                cls.usage()

        return opts, args

    @staticmethod
    def _parse_conf_file(path):
        try:
            fh = file(path)

            for line in fh.readlines():
                line = line.strip()
                if not line or line.startswith("#"):
                    continue

                try:
                    name, val = re.split(r'\s+', line, 1)
                except ValueError:
                    raise Error("bad line in configuration file: " +
                                line)
                yield name, val
        except IOError:
            pass
    
    @classmethod
    def getopt(cls, args=None):
        opts = cls.Opts()

        if cls.file_path:
            for name, val in cls._parse_conf_file(cls.file_path):
                name = name.replace("-", "_")

                if name not in opts:
                    raise Error("unknown configuration file option `%s'" %
                                name)

                opts[name].val = val

        # set options that are set in the environment
        if cls.env_path is not None:
            for opt in opts:
                optenv = cls.env_path + opt.name
                optenv = optenv.upper()

                if optenv not in os.environ:
                    continue

                if opt.protected:
                    continue
                
                opt.val = os.environ[optenv]

        if not args:
            args = sys.argv[1:]
                
        cli_opts, args = cls._cli_getopt(args, opts)
        for cli_opt, cli_val in cli_opts:
            for opt in opts:
                if cli_opt in ("--" + opt.longopt,
                               "-" + opt.short):

                    if opt.protected:
                        raise Error("protected option (%s) can't be set while running suid" % opt.name)

                    if is_bool(opt):
                        opt.val = True
                    else:
                        opt.val = cli_val

        return opts, args

    @classmethod
    def _usage_fmt_order(cls):
        order = ['command line (highest precedence)']
        if cls.env_path:
            order.append('environment variable')

        if cls.file_path:
            order.append('configuration file (%s)' % cls.file_path)

        order.append('built-in default (lowest precedence)')

        buf = "\n"
        buf += "Resolution order for options:\n"

        for i in range(1, len(order) + 1):
            buf += "%d) %s\n" % (i, order[i - 1])

        return buf + "\n"

    @classmethod
    def _usage_fmt_options(cls):
        opts = cls.Opts()
        rows = []
        for opt in opts:
            left = ""
            if opt.short:
                left += "-%s " % opt.short

            left += "--" + opt.longopt
            if not is_bool(opt):
                left += "="

            right = []
            if opt.desc:
                right.append(opt.desc)

            if cls.env_path:
                optenv = cls.env_path + opt.name
                right.append("environment: " + optenv.upper())

            if opt.val is not None:
                right.append("default: " + str(opt.val))

            rows.append((opt, left, right))

        left_maxlen = max([ len(left) for opt, left, right in rows ]) + 2

        def format_row(left, right):
            padding = " " * (left_maxlen - len(left))
            line = "  " + left + padding
            if right:
                line += right[0]
                del right[0]

            buf = line + "\n"
            for col in right:
                buf += "  " + " " * left_maxlen + col + "\n"

            return buf

        protected_rows = []
        unprotected_rows = []
        for opt, left, right in rows:
            if opt.protected:
                protected_rows.append((left, right))
            else:
                unprotected_rows.append((left, right))

        buf = ""
        if unprotected_rows:
            buf += "Options:\n"
            for left, right in unprotected_rows:
                buf += format_row(left, right) + "\n"

        if protected_rows:
            buf += "\nProtected options (root only):\n\n"
            for left, right in protected_rows: 
                buf += format_row(left, right) + "\n"

        return buf

    @classmethod
    def usage(cls, err=None):
        if err:
            print >> sys.stderr, "error: " + str(err)

        if cls.__doc__:
            tpl = string.Template(cls.__doc__)
            buf = tpl.substitute(AV0=os.path.basename(sys.argv[0]))
            print >> sys.stderr, buf.strip()

        print >> sys.stderr, cls._usage_fmt_order(),
        print >> sys.stderr, cls._usage_fmt_options(),

        if cls.file_path:
            buf = "Configuration file format (%s):\n\n" % cls.file_path
            buf += "  <option-name> <value>\n\n"

            print >> sys.stderr, buf,

        sys.exit(1)

def test():
    class TestOpts(Opts):
        bool = BoolOpt("a boolean flag", short="b", default=False)
        val = Opt("a value", short="v")
        a_b = Opt()

        simple = "test"
        simplebool = False

    class TestCliConf(CliConf):
        """Syntax: $AV0 [-options] <arg>
        """

        Opts = TestOpts

        env_path = "TEST_"
        file_path = "test.conf"

    import pprint
    pp = pprint.PrettyPrinter()

    try:
        opts, args = TestCliConf.getopt()
    except TestCliConf.Error, e:
        TestCliConf.usage(e)

    if len(args) != 1:
        TestCliConf.usage("not enough arguments")

    print "--- OPTIONS:"
    pp.pprint([ dict(opt) for opt in opts])
    for opt in opts:
        print "%s=%s" % (opt.name, opt.val)

    arg = args[0]
    print "arg = " + `arg`

if __name__ == "__main__":
    test()

