# Copyright (c) 2005 Divmod, Inc.
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.

"""
Tests for Twisted plugin system.
"""

from __future__ import absolute_import, division

import sys, errno, os, time
import compileall
import functools

from zope.interface import Interface

from twisted.trial import unittest
from twisted.python.compat import _PY3
from twisted.python.log import textFromEventDict, addObserver, removeObserver
from twisted.python.filepath import FilePath

from twisted import plugin

if _PY3:
    from importlib import invalidate_caches as invalidateImportCaches
else:
    def invalidateImportCaches():
        """
        On python 2, import caches don't need to be invalidated.
        """



class ITestPlugin(Interface):
    """
    A plugin for use by the plugin system's unit tests.

    Do not use this.
    """



class ITestPlugin2(Interface):
    """
    See L{ITestPlugin}.
    """



class PluginTests(unittest.TestCase):
    """
    Tests which verify the behavior of the current, active Twisted plugins
    directory.
    """

    def setUp(self):
        """
        Save C{sys.path} and C{sys.modules}, and create a package for tests.
        """
        self.originalPath = sys.path[:]
        self.savedModules = sys.modules.copy()

        self.root = FilePath(self.mktemp())
        self.root.createDirectory()
        self.package = self.root.child('mypackage')
        self.package.createDirectory()
        self.package.child('__init__.py').setContent(b"")

        FilePath(__file__).sibling('plugin_basic.py'
            ).copyTo(self.package.child('testplugin.py'))

        self.originalPlugin = "testplugin"

        sys.path.insert(0, self.root.path)
        import mypackage
        self.module = mypackage


    def tearDown(self):
        """
        Restore C{sys.path} and C{sys.modules} to their original values.
        """
        sys.path[:] = self.originalPath
        sys.modules.clear()
        sys.modules.update(self.savedModules)


    def _unimportPythonModule(self, module, deleteSource=False):
        modulePath = module.__name__.split('.')
        packageName = '.'.join(modulePath[:-1])
        moduleName = modulePath[-1]

        delattr(sys.modules[packageName], moduleName)
        del sys.modules[module.__name__]
        for ext in ['c', 'o'] + (deleteSource and [''] or []):
            try:
                os.remove(module.__file__ + ext)
            except OSError as ose:
                if ose.errno != errno.ENOENT:
                    raise


    def _clearCache(self):
        """
        Remove the plugins B{droping.cache} file.
        """
        self.package.child('dropin.cache').remove()


    def _withCacheness(meth):
        """
        This is a paranoid test wrapper, that calls C{meth} 2 times, clear the
        cache, and calls it 2 other times. It's supposed to ensure that the
        plugin system behaves correctly no matter what the state of the cache
        is.
        """
        @functools.wraps(meth)
        def wrapped(self):
            meth(self)
            meth(self)
            self._clearCache()
            meth(self)
            meth(self)

        return wrapped


    def test_cache(self):
        """
        Check that the cache returned by L{plugin.getCache} hold the plugin
        B{testplugin}, and that this plugin has the properties we expect:
        provide L{TestPlugin}, has the good name and description, and can be
        loaded successfully.
        """
        cache = plugin.getCache(self.module)

        dropin = cache[self.originalPlugin]
        self.assertEqual(dropin.moduleName,
                          'mypackage.%s' % (self.originalPlugin,))
        self.assertIn("I'm a test drop-in.", dropin.description)

        # Note, not the preferred way to get a plugin by its interface.
        p1 = [p for p in dropin.plugins if ITestPlugin in p.provided][0]
        self.assertIs(p1.dropin, dropin)
        self.assertEqual(p1.name, "TestPlugin")

        # Check the content of the description comes from the plugin module
        # docstring
        self.assertEqual(
            p1.description.strip(),
            "A plugin used solely for testing purposes.")
        self.assertEqual(p1.provided, [ITestPlugin, plugin.IPlugin])
        realPlugin = p1.load()
        # The plugin should match the class present in sys.modules
        self.assertIs(
            realPlugin,
            sys.modules['mypackage.%s' % (self.originalPlugin,)].TestPlugin)

        # And it should also match if we import it classicly
        import mypackage.testplugin as tp
        self.assertIs(realPlugin, tp.TestPlugin)

    test_cache = _withCacheness(test_cache)


    def test_cacheRepr(self):
        """
        L{CachedPlugin} has a helpful C{repr} which contains relevant
        information about it.
        """
        cachedDropin = plugin.getCache(self.module)[self.originalPlugin]
        cachedPlugin = list(p for p in cachedDropin.plugins
                            if p.name == 'TestPlugin')[0]
        self.assertEqual(
            repr(cachedPlugin),
            "<CachedPlugin 'TestPlugin'/'mypackage.testplugin' "
            "(provides 'ITestPlugin, IPlugin')>"
        )


    def test_plugins(self):
        """
        L{plugin.getPlugins} should return the list of plugins matching the
        specified interface (here, L{ITestPlugin2}), and these plugins
        should be instances of classes with a C{test} method, to be sure
        L{plugin.getPlugins} load classes correctly.
        """
        plugins = list(plugin.getPlugins(ITestPlugin2, self.module))

        self.assertEqual(len(plugins), 2)

        names = ['AnotherTestPlugin', 'ThirdTestPlugin']
        for p in plugins:
            names.remove(p.__name__)
            p.test()

    test_plugins = _withCacheness(test_plugins)


    def test_detectNewFiles(self):
        """
        Check that L{plugin.getPlugins} is able to detect plugins added at
        runtime.
        """
        FilePath(__file__).sibling('plugin_extra1.py'
            ).copyTo(self.package.child('pluginextra.py'))
        try:
            # Check that the current situation is clean
            self.failIfIn('mypackage.pluginextra', sys.modules)
            self.assertFalse(hasattr(sys.modules['mypackage'], 'pluginextra'),
                        "mypackage still has pluginextra module")

            plgs = list(plugin.getPlugins(ITestPlugin, self.module))

            # We should find 2 plugins: the one in testplugin, and the one in
            # pluginextra
            self.assertEqual(len(plgs), 2)

            names = ['TestPlugin', 'FourthTestPlugin']
            for p in plgs:
                names.remove(p.__name__)
                p.test1()
        finally:
            self._unimportPythonModule(
                sys.modules['mypackage.pluginextra'],
                True)

    test_detectNewFiles = _withCacheness(test_detectNewFiles)


    def test_detectFilesChanged(self):
        """
        Check that if the content of a plugin change, L{plugin.getPlugins} is
        able to detect the new plugins added.
        """
        FilePath(__file__).sibling('plugin_extra1.py'
            ).copyTo(self.package.child('pluginextra.py'))
        try:
            plgs = list(plugin.getPlugins(ITestPlugin, self.module))
            # Sanity check
            self.assertEqual(len(plgs), 2)

            FilePath(__file__).sibling('plugin_extra2.py'
                ).copyTo(self.package.child('pluginextra.py'))

            # Fake out Python.
            self._unimportPythonModule(sys.modules['mypackage.pluginextra'])

            # Make sure additions are noticed
            plgs = list(plugin.getPlugins(ITestPlugin, self.module))

            self.assertEqual(len(plgs), 3)

            names = ['TestPlugin', 'FourthTestPlugin', 'FifthTestPlugin']
            for p in plgs:
                names.remove(p.__name__)
                p.test1()
        finally:
            self._unimportPythonModule(
                sys.modules['mypackage.pluginextra'],
                True)

    test_detectFilesChanged = _withCacheness(test_detectFilesChanged)


    def test_detectFilesRemoved(self):
        """
        Check that when a dropin file is removed, L{plugin.getPlugins} doesn't
        return it anymore.
        """
        FilePath(__file__).sibling('plugin_extra1.py'
            ).copyTo(self.package.child('pluginextra.py'))
        try:
            # Generate a cache with pluginextra in it.
            list(plugin.getPlugins(ITestPlugin, self.module))

        finally:
            self._unimportPythonModule(
                sys.modules['mypackage.pluginextra'],
                True)
        plgs = list(plugin.getPlugins(ITestPlugin, self.module))
        self.assertEqual(1, len(plgs))

    test_detectFilesRemoved = _withCacheness(test_detectFilesRemoved)


    def test_nonexistentPathEntry(self):
        """
        Test that getCache skips over any entries in a plugin package's
        C{__path__} which do not exist.
        """
        path = self.mktemp()
        self.assertFalse(os.path.exists(path))
        # Add the test directory to the plugins path
        self.module.__path__.append(path)
        try:
            plgs = list(plugin.getPlugins(ITestPlugin, self.module))
            self.assertEqual(len(plgs), 1)
        finally:
            self.module.__path__.remove(path)

    test_nonexistentPathEntry = _withCacheness(test_nonexistentPathEntry)


    def test_nonDirectoryChildEntry(self):
        """
        Test that getCache skips over any entries in a plugin package's
        C{__path__} which refer to children of paths which are not directories.
        """
        path = FilePath(self.mktemp())
        self.assertFalse(path.exists())
        path.touch()
        child = path.child("test_package").path
        self.module.__path__.append(child)
        try:
            plgs = list(plugin.getPlugins(ITestPlugin, self.module))
            self.assertEqual(len(plgs), 1)
        finally:
            self.module.__path__.remove(child)

    test_nonDirectoryChildEntry = _withCacheness(test_nonDirectoryChildEntry)


    def test_deployedMode(self):
        """
        The C{dropin.cache} file may not be writable: the cache should still be
        attainable, but an error should be logged to show that the cache
        couldn't be updated.
        """
        # Generate the cache
        plugin.getCache(self.module)

        cachepath = self.package.child('dropin.cache')

        # Add a new plugin
        FilePath(__file__).sibling('plugin_extra1.py'
            ).copyTo(self.package.child('pluginextra.py'))
        invalidateImportCaches()

        os.chmod(self.package.path, 0o500)
        # Change the right of dropin.cache too for windows
        os.chmod(cachepath.path, 0o400)
        self.addCleanup(os.chmod, self.package.path, 0o700)
        self.addCleanup(os.chmod, cachepath.path, 0o700)

        # Start observing log events to see the warning
        events = []
        addObserver(events.append)
        self.addCleanup(removeObserver, events.append)

        cache = plugin.getCache(self.module)
        # The new plugin should be reported
        self.assertIn('pluginextra', cache)
        self.assertIn(self.originalPlugin, cache)

        # Make sure something was logged about the cache.
        expected = "Unable to write to plugin cache %s: error number %d" % (
            cachepath.path, errno.EPERM)
        for event in events:
            if expected in textFromEventDict(event):
                break
        else:
            self.fail(
                "Did not observe unwriteable cache warning in log "
                "events: %r" % (events,))



# This is something like the Twisted plugins file.
pluginInitFile = b"""
from twisted.plugin import pluginPackagePaths
__path__.extend(pluginPackagePaths(__name__))
__all__ = []
"""

def pluginFileContents(name):
    return (
        "from zope.interface import provider\n"
        "from twisted.plugin import IPlugin\n"
        "from twisted.test.test_plugin import ITestPlugin\n"
        "\n"
        "@provider(IPlugin, ITestPlugin)\n"
        "class {0}(object):\n"
        "    pass\n"
    ).format(name).encode('ascii')


def _createPluginDummy(entrypath, pluginContent, real, pluginModule):
    """
    Create a plugindummy package.
    """
    entrypath.createDirectory()
    pkg = entrypath.child('plugindummy')
    pkg.createDirectory()
    if real:
        pkg.child('__init__.py').setContent(b'')
    plugs = pkg.child('plugins')
    plugs.createDirectory()
    if real:
        plugs.child('__init__.py').setContent(pluginInitFile)
    plugs.child(pluginModule + '.py').setContent(pluginContent)
    return plugs



class DeveloperSetupTests(unittest.TestCase):
    """
    These tests verify things about the plugin system without actually
    interacting with the deployed 'twisted.plugins' package, instead creating a
    temporary package.
    """

    def setUp(self):
        """
        Create a complex environment with multiple entries on sys.path, akin to
        a developer's environment who has a development (trunk) checkout of
        Twisted, a system installed version of Twisted (for their operating
        system's tools) and a project which provides Twisted plugins.
        """
        self.savedPath = sys.path[:]
        self.savedModules = sys.modules.copy()
        self.fakeRoot = FilePath(self.mktemp())
        self.fakeRoot.createDirectory()
        self.systemPath = self.fakeRoot.child('system_path')
        self.devPath = self.fakeRoot.child('development_path')
        self.appPath = self.fakeRoot.child('application_path')
        self.systemPackage = _createPluginDummy(
            self.systemPath, pluginFileContents('system'),
            True, 'plugindummy_builtin')
        self.devPackage = _createPluginDummy(
            self.devPath, pluginFileContents('dev'),
            True, 'plugindummy_builtin')
        self.appPackage = _createPluginDummy(
            self.appPath, pluginFileContents('app'),
            False, 'plugindummy_app')

        # Now we're going to do the system installation.
        sys.path.extend([x.path for x in [self.systemPath,
                                          self.appPath]])
        # Run all the way through the plugins list to cause the
        # L{plugin.getPlugins} generator to write cache files for the system
        # installation.
        self.getAllPlugins()
        self.sysplug = self.systemPath.child('plugindummy').child('plugins')
        self.syscache = self.sysplug.child('dropin.cache')
        # Make sure there's a nice big difference in modification times so that
        # we won't re-build the system cache.
        now = time.time()
        os.utime(
            self.sysplug.child('plugindummy_builtin.py').path,
            (now - 5000,) * 2)
        os.utime(self.syscache.path, (now - 2000,) * 2)
        # For extra realism, let's make sure that the system path is no longer
        # writable.
        self.lockSystem()
        self.resetEnvironment()


    def lockSystem(self):
        """
        Lock the system directories, as if they were unwritable by this user.
        """
        os.chmod(self.sysplug.path, 0o555)
        os.chmod(self.syscache.path, 0o555)


    def unlockSystem(self):
        """
        Unlock the system directories, as if they were writable by this user.
        """
        os.chmod(self.sysplug.path, 0o777)
        os.chmod(self.syscache.path, 0o777)


    def getAllPlugins(self):
        """
        Get all the plugins loadable from our dummy package, and return their
        short names.
        """
        # Import the module we just added to our path.  (Local scope because
        # this package doesn't exist outside of this test.)
        import plugindummy.plugins
        x = list(plugin.getPlugins(ITestPlugin, plugindummy.plugins))
        return [plug.__name__ for plug in x]


    def resetEnvironment(self):
        """
        Change the environment to what it should be just as the test is
        starting.
        """
        self.unsetEnvironment()
        sys.path.extend([x.path for x in [self.devPath,
                                          self.systemPath,
                                          self.appPath]])

    def unsetEnvironment(self):
        """
        Change the Python environment back to what it was before the test was
        started.
        """
        invalidateImportCaches()
        sys.modules.clear()
        sys.modules.update(self.savedModules)
        sys.path[:] = self.savedPath


    def tearDown(self):
        """
        Reset the Python environment to what it was before this test ran, and
        restore permissions on files which were marked read-only so that the
        directory may be cleanly cleaned up.
        """
        self.unsetEnvironment()
        # Normally we wouldn't "clean up" the filesystem like this (leaving
        # things for post-test inspection), but if we left the permissions the
        # way they were, we'd be leaving files around that the buildbots
        # couldn't delete, and that would be bad.
        self.unlockSystem()


    def test_developmentPluginAvailability(self):
        """
        Plugins added in the development path should be loadable, even when
        the (now non-importable) system path contains its own idea of the
        list of plugins for a package.  Inversely, plugins added in the
        system path should not be available.
        """
        # Run 3 times: uncached, cached, and then cached again to make sure we
        # didn't overwrite / corrupt the cache on the cached try.
        for x in range(3):
            names = self.getAllPlugins()
            names.sort()
            self.assertEqual(names, ['app', 'dev'])


    def test_freshPyReplacesStalePyc(self):
        """
        Verify that if a stale .pyc file on the PYTHONPATH is replaced by a
        fresh .py file, the plugins in the new .py are picked up rather than
        the stale .pyc, even if the .pyc is still around.
        """
        mypath = self.appPackage.child("stale.py")
        mypath.setContent(pluginFileContents('one'))
        # Make it super stale
        x = time.time() - 1000
        os.utime(mypath.path, (x, x))
        pyc = mypath.sibling('stale.pyc')
        # compile it
        if _PY3:
            # On python 3, don't use the __pycache__ directory; the intention
            # of scanning for .pyc files is for configurations where you want
            # to intentionally include them, which means we _don't_ scan for
            # them inside cache directories.
            extra = dict(legacy=True)
        else:
            # On python 2 this option doesn't exist.
            extra = dict()
        compileall.compile_dir(self.appPackage.path, quiet=1, **extra)
        os.utime(pyc.path, (x, x))
        # Eliminate the other option.
        mypath.remove()
        # Make sure it's the .pyc path getting cached.
        self.resetEnvironment()
        # Sanity check.
        self.assertIn('one', self.getAllPlugins())
        self.failIfIn('two', self.getAllPlugins())
        self.resetEnvironment()
        mypath.setContent(pluginFileContents('two'))
        self.failIfIn('one', self.getAllPlugins())
        self.assertIn('two', self.getAllPlugins())


    def test_newPluginsOnReadOnlyPath(self):
        """
        Verify that a failure to write the dropin.cache file on a read-only
        path will not affect the list of plugins returned.

        Note: this test should pass on both Linux and Windows, but may not
        provide useful coverage on Windows due to the different meaning of
        "read-only directory".
        """
        self.unlockSystem()
        self.sysplug.child('newstuff.py').setContent(pluginFileContents('one'))
        self.lockSystem()

        # Take the developer path out, so that the system plugins are actually
        # examined.
        sys.path.remove(self.devPath.path)

        # Start observing log events to see the warning
        events = []
        addObserver(events.append)
        self.addCleanup(removeObserver, events.append)

        self.assertIn('one', self.getAllPlugins())

        # Make sure something was logged about the cache.
        expected = "Unable to write to plugin cache %s: error number %d" % (
            self.syscache.path, errno.EPERM)
        for event in events:
            if expected in textFromEventDict(event):
                break
        else:
            self.fail(
                "Did not observe unwriteable cache warning in log "
                "events: %r" % (events,))



class AdjacentPackageTests(unittest.TestCase):
    """
    Tests for the behavior of the plugin system when there are multiple
    installed copies of the package containing the plugins being loaded.
    """

    def setUp(self):
        """
        Save the elements of C{sys.path} and the items of C{sys.modules}.
        """
        self.originalPath = sys.path[:]
        self.savedModules = sys.modules.copy()


    def tearDown(self):
        """
        Restore C{sys.path} and C{sys.modules} to their original values.
        """
        sys.path[:] = self.originalPath
        sys.modules.clear()
        sys.modules.update(self.savedModules)


    def createDummyPackage(self, root, name, pluginName):
        """
        Create a directory containing a Python package named I{dummy} with a
        I{plugins} subpackage.

        @type root: L{FilePath}
        @param root: The directory in which to create the hierarchy.

        @type name: C{str}
        @param name: The name of the directory to create which will contain
            the package.

        @type pluginName: C{str}
        @param pluginName: The name of a module to create in the
            I{dummy.plugins} package.

        @rtype: L{FilePath}
        @return: The directory which was created to contain the I{dummy}
            package.
        """
        directory = root.child(name)
        package = directory.child('dummy')
        package.makedirs()
        package.child('__init__.py').setContent(b'')
        plugins = package.child('plugins')
        plugins.makedirs()
        plugins.child('__init__.py').setContent(pluginInitFile)
        pluginModule = plugins.child(pluginName + '.py')
        pluginModule.setContent(pluginFileContents(name))
        return directory


    def test_hiddenPackageSamePluginModuleNameObscured(self):
        """
        Only plugins from the first package in sys.path should be returned by
        getPlugins in the case where there are two Python packages by the same
        name installed, each with a plugin module by a single name.
        """
        root = FilePath(self.mktemp())
        root.makedirs()

        firstDirectory = self.createDummyPackage(root, 'first', 'someplugin')
        secondDirectory = self.createDummyPackage(root, 'second', 'someplugin')

        sys.path.append(firstDirectory.path)
        sys.path.append(secondDirectory.path)

        import dummy.plugins

        plugins = list(plugin.getPlugins(ITestPlugin, dummy.plugins))
        self.assertEqual(['first'], [p.__name__ for p in plugins])


    def test_hiddenPackageDifferentPluginModuleNameObscured(self):
        """
        Plugins from the first package in sys.path should be returned by
        getPlugins in the case where there are two Python packages by the same
        name installed, each with a plugin module by a different name.
        """
        root = FilePath(self.mktemp())
        root.makedirs()

        firstDirectory = self.createDummyPackage(root, 'first', 'thisplugin')
        secondDirectory = self.createDummyPackage(root, 'second', 'thatplugin')

        sys.path.append(firstDirectory.path)
        sys.path.append(secondDirectory.path)

        import dummy.plugins

        plugins = list(plugin.getPlugins(ITestPlugin, dummy.plugins))
        self.assertEqual(['first'], [p.__name__ for p in plugins])



class PackagePathTests(unittest.TestCase):
    """
    Tests for L{plugin.pluginPackagePaths} which constructs search paths for
    plugin packages.
    """

    def setUp(self):
        """
        Save the elements of C{sys.path}.
        """
        self.originalPath = sys.path[:]


    def tearDown(self):
        """
        Restore C{sys.path} to its original value.
        """
        sys.path[:] = self.originalPath


    def test_pluginDirectories(self):
        """
        L{plugin.pluginPackagePaths} should return a list containing each
        directory in C{sys.path} with a suffix based on the supplied package
        name.
        """
        foo = FilePath('foo')
        bar = FilePath('bar')
        sys.path = [foo.path, bar.path]
        self.assertEqual(
            plugin.pluginPackagePaths('dummy.plugins'),
            [foo.child('dummy').child('plugins').path,
             bar.child('dummy').child('plugins').path])


    def test_pluginPackagesExcluded(self):
        """
        L{plugin.pluginPackagePaths} should exclude directories which are
        Python packages.  The only allowed plugin package (the only one
        associated with a I{dummy} package which Python will allow to be
        imported) will already be known to the caller of
        L{plugin.pluginPackagePaths} and will most commonly already be in
        the C{__path__} they are about to mutate.
        """
        root = FilePath(self.mktemp())
        foo = root.child('foo').child('dummy').child('plugins')
        foo.makedirs()
        foo.child('__init__.py').setContent(b'')
        sys.path = [root.child('foo').path, root.child('bar').path]
        self.assertEqual(
            plugin.pluginPackagePaths('dummy.plugins'),
            [root.child('bar').child('dummy').child('plugins').path])
