#!/usr/bin/python2
#
# Authors:
#   Jakub Hrozek <jhrozek@redhat.com>
#   Jan Cholasta <jcholast@redhat.com>
#
# Copyright (C) 2011  Red Hat
# see file 'COPYING' for use and warranty information
#
# This program is free 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.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import os
import sys
from optparse import OptionParser
from fnmatch import fnmatch, fnmatchcase

try:
    from pylint import checkers
    from pylint.lint import PyLinter
    from pylint.checkers.typecheck import TypeChecker
    from pylint.checkers.utils import safe_infer
    from astroid import Class, Instance, Module, InferenceError, Function
    from pylint.reporters.text import TextReporter
except ImportError:
    print >> sys.stderr, "To use {0}, please install pylint.".format(sys.argv[0])
    sys.exit(32)

# File names to ignore when searching for python source files
IGNORE_FILES = ('.*', '*~', '*.in', '*.pyc', '*.pyo')
IGNORE_PATHS = (
    'build', 'rpmbuild', 'dist', 'install/po/test_i18n.py', 'lite-server.py')

class IPATypeChecker(TypeChecker):
    NAMESPACE_ATTRS = ['Command', 'Object', 'Method', 'Backend', 'Updater',
        'Advice']
    LOGGING_ATTRS = ['log', 'debug', 'info', 'warning', 'error', 'exception',
        'critical']

    # 'class or module': ['generated', 'properties']
    ignore = {
        # Python standard library & 3rd party classes
        'krbV.Principal': ['name'],
        'socket._socketobject': ['sendall'],
        # should be 'subprocess.Popen'
        '.Popen': ['stdin', 'stdout', 'stderr', 'pid', 'returncode', 'poll',
            'wait', 'communicate'],
        'urlparse.ResultMixin': ['scheme', 'netloc', 'path', 'query',
            'fragment', 'username', 'password', 'hostname', 'port'],
        'urlparse.ParseResult': ['params'],
        'pytest': ['fixture', 'raises', 'skip', 'yield_fixture', 'mark', 'fail'],
        'unittest.case': ['assertEqual', 'assertRaises'],
        'nose.tools': ['assert_equal', 'assert_raises'],
        'datetime.tzinfo': ['houroffset', 'minoffset', 'utcoffset', 'dst'],
        'nss.nss.subject_public_key_info': ['public_key'],

        # IPA classes
        'ipalib.base.NameSpace': ['add', 'mod', 'del', 'show', 'find'],
        'ipalib.cli.Collector': ['__options'],
        'ipalib.config.Env': ['*'],
        'ipalib.krb_utils.KRB5_CCache': LOGGING_ATTRS,
        'ipalib.parameters.Param': ['cli_name', 'cli_short_name', 'label',
            'default', 'doc', 'required', 'multivalue', 'primary_key',
            'normalizer', 'default_from', 'autofill', 'query', 'attribute',
            'include', 'exclude', 'flags', 'hint', 'alwaysask', 'sortorder',
            'csv', 'option_group'],
        'ipalib.parameters.Bool': ['truths', 'falsehoods'],
        'ipalib.parameters.Data': ['minlength', 'maxlength', 'length',
            'pattern', 'pattern_errmsg'],
        'ipalib.parameters.Str': ['noextrawhitespace'],
        'ipalib.parameters.Password': ['confirm'],
        'ipalib.parameters.File': ['stdin_if_missing'],
        'ipalib.plugins.dns.DNSRecord': ['validatedns', 'normalizedns'],
        'ipalib.parameters.Enum': ['values'],
        'ipalib.parameters.Number': ['minvalue', 'maxvalue'],
        'ipalib.parameters.Decimal': ['precision', 'exponential',
            'numberclass'],
        'ipalib.parameters.DNSNameParam': ['only_absolute', 'only_relative'],
        'ipalib.plugable.API': NAMESPACE_ATTRS + LOGGING_ATTRS,
        'ipalib.plugable.Plugin': ['api', 'env'] + NAMESPACE_ATTRS +
            LOGGING_ATTRS,
        'ipalib.session.AuthManager': LOGGING_ATTRS,
        'ipalib.session.SessionAuthManager': LOGGING_ATTRS,
        'ipalib.session.SessionManager': LOGGING_ATTRS,
        'ipaserver.install.ldapupdate.LDAPUpdate': LOGGING_ATTRS,
        'ipaserver.rpcserver.KerberosSession': ['api'] + LOGGING_ATTRS,
        'ipatests.test_integration.base.IntegrationTest': [
            'domain', 'master', 'replicas', 'clients', 'ad_domains']
    }

    def _related_classes(self, klass):
        yield klass
        for base in klass.ancestors():
            yield base

    def _class_full_name(self, klass):
        return klass.root().name + '.' + klass.name

    def _find_ignored_attrs(self, owner):
        attrs = []
        for klass in self._related_classes(owner):
            name = self._class_full_name(klass)
            if name in self.ignore:
                attrs += self.ignore[name]
        return attrs

    def visit_getattr(self, node):
        try:
            inferred = list(node.expr.infer())
        except InferenceError:
            inferred = []

        for owner in inferred:
            if isinstance(owner, Module):
                if node.attrname in self.ignore.get(owner.name, ()):
                    return

            elif isinstance(owner, Class) or type(owner) is Instance:
                ignored = self._find_ignored_attrs(owner)
                for pattern in ignored:
                    if fnmatchcase(node.attrname, pattern):
                        return

        super(IPATypeChecker, self).visit_getattr(node)

    def visit_callfunc(self, node):
        called = safe_infer(node.func)
        if isinstance(called, Function):
            if called.name in self.ignore.get(called.root().name, []):
                return

        super(IPATypeChecker, self).visit_callfunc(node)

class IPALinter(PyLinter):
    ignore = (TypeChecker,)

    def __init__(self):
        super(IPALinter, self).__init__()

        self.missing = set()

    def register_checker(self, checker):
        if type(checker) in self.ignore:
            return
        super(IPALinter, self).register_checker(checker)

    def add_message(self, msg_id, line=None, node=None, args=None, confidence=None):
        if line is None and node is not None:
            line = node.fromlineno

        # Record missing packages
        if msg_id == 'F0401' and self.is_message_enabled(msg_id, line):
            self.missing.add(args)

        super(IPALinter, self).add_message(msg_id, line, node, args)

def find_files(path, basepath):
    entries = os.listdir(path)

    # If this directory is a python package, look no further
    if '__init__.py' in entries:
        return [path]

    result = []
    for filename in entries:
        filepath = os.path.join(path, filename)

        for pattern in IGNORE_FILES:
            if fnmatch(filename, pattern):
                filename = None
                break
        if filename is None:
            continue

        for pattern in IGNORE_PATHS:
            patpath = os.path.join(basepath, pattern).replace(os.sep, '/')
            if filepath == patpath:
                filename = None
                break
        if filename is None:
            continue

        if os.path.islink(filepath):
            continue

        # Recurse into subdirectories
        if os.path.isdir(filepath):
            result += find_files(filepath, basepath)
            continue

        # Add all *.py files
        if filename.endswith('.py'):
            result.append(filepath)
            continue

        # Add any other files beginning with a shebang and having
        # the word "python" on the first line
        file = open(filepath, 'r')
        line = file.readline(128)
        file.close()

        if line[:2] == '#!' and line.find('python') >= 0:
            result.append(filepath)

    return result

def main():
    optparser = OptionParser()
    optparser.add_option('--no-fail', help='report success even if errors were found',
        dest='fail', default=True, action='store_false')
    optparser.add_option('--enable-noerror', help='enable warnings and other non-error messages',
        dest='errors_only', default=True, action='store_false')

    options, args = optparser.parse_args()
    cwd = os.getcwd()

    if len(args) == 0:
        files = find_files(cwd, cwd)
    else:
        files = args

    for filename in files:
        dirname = os.path.dirname(filename)
        if dirname not in sys.path:
            sys.path.insert(0, dirname)

    linter = IPALinter()
    checkers.initialize(linter)
    linter.register_checker(IPATypeChecker(linter))

    if options.errors_only:
        linter.disable_noerror_messages()
        linter.enable('F')
    linter.set_reporter(TextReporter())
    linter.set_option('msg-template',
                        '{path}:{line}: [{msg_id}({symbol}), {obj}] {msg})')
    linter.set_option('reports', False)
    linter.set_option('persistent', False)
    linter.set_option('disable', 'python3')

    linter.check(files)

    if linter.msg_status != 0:
        print >> sys.stderr, """
===============================================================================
Errors were found during the static code check.
"""

        if len(linter.missing) > 0:
            print >> sys.stderr, "There are some missing imports:"
            for mod in sorted(linter.missing):
                print >> sys.stderr, "    " + mod
            print >> sys.stderr, """
Please make sure all of the required and optional (python-krbV, python-rhsm)
python packages are installed.
"""

        print >> sys.stderr, """\
If you are certain that any of the reported errors are false positives, please
mark them in the source code according to the pylint documentation.
===============================================================================
"""

    if options.fail:
        return linter.msg_status
    else:
        return 0

if __name__ == "__main__":
    sys.exit(main())
