1
# Copyright (c) 2005 Divmod, Inc.
2
# Copyright (c) 2007 Twisted Matrix Laboratories.
3
# See LICENSE for details.
6
Tests for Twisted plugin system.
9
import sys, errno, os, time
12
from zope.interface import Interface
14
from twisted.trial import unittest
15
from twisted.python.filepath import FilePath
16
from twisted.python.util import mergeFunctionMetadata
18
from twisted import plugin
22
class ITestPlugin(Interface):
24
A plugin for use by the plugin system's unit tests.
31
class ITestPlugin2(Interface):
38
class PluginTestCase(unittest.TestCase):
40
Tests which verify the behavior of the current, active Twisted plugins
46
Save C{sys.path} and C{sys.modules}, and create a package for tests.
48
self.originalPath = sys.path[:]
49
self.savedModules = sys.modules.copy()
51
self.root = FilePath(self.mktemp())
52
self.root.createDirectory()
53
self.package = self.root.child('mypackage')
54
self.package.createDirectory()
55
self.package.child('__init__.py').setContent("")
57
FilePath(__file__).sibling('plugin_basic.py'
58
).copyTo(self.package.child('testplugin.py'))
60
self.originalPlugin = "testplugin"
62
sys.path.insert(0, self.root.path)
64
self.module = mypackage
69
Restore C{sys.path} and C{sys.modules} to their original values.
71
sys.path[:] = self.originalPath
73
sys.modules.update(self.savedModules)
76
def _unimportPythonModule(self, module, deleteSource=False):
77
modulePath = module.__name__.split('.')
78
packageName = '.'.join(modulePath[:-1])
79
moduleName = modulePath[-1]
81
delattr(sys.modules[packageName], moduleName)
82
del sys.modules[module.__name__]
83
for ext in ['c', 'o'] + (deleteSource and [''] or []):
85
os.remove(module.__file__ + ext)
87
if ose.errno != errno.ENOENT:
91
def _clearCache(self):
93
Remove the plugins B{droping.cache} file.
95
self.package.child('dropin.cache').remove()
98
def _withCacheness(meth):
100
This is a paranoid test wrapper, that calls C{meth} 2 times, clear the
101
cache, and calls it 2 other times. It's supposed to ensure that the
102
plugin system behaves correctly no matter what the state of the cache
111
return mergeFunctionMetadata(meth, wrapped)
114
def test_cache(self):
116
Check that the cache returned by L{plugin.getCache} hold the plugin
117
B{testplugin}, and that this plugin has the properties we expect:
118
provide L{TestPlugin}, has the good name and description, and can be
121
cache = plugin.getCache(self.module)
123
dropin = cache[self.originalPlugin]
124
self.assertEquals(dropin.moduleName,
125
'mypackage.%s' % (self.originalPlugin,))
126
self.assertIn("I'm a test drop-in.", dropin.description)
128
# Note, not the preferred way to get a plugin by its interface.
129
p1 = [p for p in dropin.plugins if ITestPlugin in p.provided][0]
130
self.assertIdentical(p1.dropin, dropin)
131
self.assertEquals(p1.name, "TestPlugin")
133
# Check the content of the description comes from the plugin module
136
p1.description.strip(),
137
"A plugin used solely for testing purposes.")
138
self.assertEquals(p1.provided, [ITestPlugin, plugin.IPlugin])
139
realPlugin = p1.load()
140
# The plugin should match the class present in sys.modules
141
self.assertIdentical(
143
sys.modules['mypackage.%s' % (self.originalPlugin,)].TestPlugin)
145
# And it should also match if we import it classicly
146
import mypackage.testplugin as tp
147
self.assertIdentical(realPlugin, tp.TestPlugin)
149
test_cache = _withCacheness(test_cache)
152
def test_plugins(self):
154
L{plugin.getPlugins} should return the list of plugins matching the
155
specified interface (here, L{ITestPlugin2}), and these plugins
156
should be instances of classes with a C{test} method, to be sure
157
L{plugin.getPlugins} load classes correctly.
159
plugins = list(plugin.getPlugins(ITestPlugin2, self.module))
161
self.assertEquals(len(plugins), 2)
163
names = ['AnotherTestPlugin', 'ThirdTestPlugin']
165
names.remove(p.__name__)
168
test_plugins = _withCacheness(test_plugins)
171
def test_detectNewFiles(self):
173
Check that L{plugin.getPlugins} is able to detect plugins added at
176
FilePath(__file__).sibling('plugin_extra1.py'
177
).copyTo(self.package.child('pluginextra.py'))
179
# Check that the current situation is clean
180
self.failIfIn('mypackage.pluginextra', sys.modules)
181
self.failIf(hasattr(sys.modules['mypackage'], 'pluginextra'),
182
"mypackage still has pluginextra module")
184
plgs = list(plugin.getPlugins(ITestPlugin, self.module))
186
# We should find 2 plugins: the one in testplugin, and the one in
188
self.assertEquals(len(plgs), 2)
190
names = ['TestPlugin', 'FourthTestPlugin']
192
names.remove(p.__name__)
195
self._unimportPythonModule(
196
sys.modules['mypackage.pluginextra'],
199
test_detectNewFiles = _withCacheness(test_detectNewFiles)
202
def test_detectFilesChanged(self):
204
Check that if the content of a plugin change, L{plugin.getPlugins} is
205
able to detect the new plugins added.
207
FilePath(__file__).sibling('plugin_extra1.py'
208
).copyTo(self.package.child('pluginextra.py'))
210
plgs = list(plugin.getPlugins(ITestPlugin, self.module))
212
self.assertEquals(len(plgs), 2)
214
FilePath(__file__).sibling('plugin_extra2.py'
215
).copyTo(self.package.child('pluginextra.py'))
218
self._unimportPythonModule(sys.modules['mypackage.pluginextra'])
220
# Make sure additions are noticed
221
plgs = list(plugin.getPlugins(ITestPlugin, self.module))
223
self.assertEquals(len(plgs), 3)
225
names = ['TestPlugin', 'FourthTestPlugin', 'FifthTestPlugin']
227
names.remove(p.__name__)
230
self._unimportPythonModule(
231
sys.modules['mypackage.pluginextra'],
234
test_detectFilesChanged = _withCacheness(test_detectFilesChanged)
237
def test_detectFilesRemoved(self):
239
Check that when a dropin file is removed, L{plugin.getPlugins} doesn't
242
FilePath(__file__).sibling('plugin_extra1.py'
243
).copyTo(self.package.child('pluginextra.py'))
245
# Generate a cache with pluginextra in it.
246
list(plugin.getPlugins(ITestPlugin, self.module))
249
self._unimportPythonModule(
250
sys.modules['mypackage.pluginextra'],
252
plgs = list(plugin.getPlugins(ITestPlugin, self.module))
253
self.assertEquals(1, len(plgs))
255
test_detectFilesRemoved = _withCacheness(test_detectFilesRemoved)
258
def test_nonexistentPathEntry(self):
260
Test that getCache skips over any entries in a plugin package's
261
C{__path__} which do not exist.
264
self.failIf(os.path.exists(path))
265
# Add the test directory to the plugins path
266
self.module.__path__.append(path)
268
plgs = list(plugin.getPlugins(ITestPlugin, self.module))
269
self.assertEqual(len(plgs), 1)
271
self.module.__path__.remove(path)
273
test_nonexistentPathEntry = _withCacheness(test_nonexistentPathEntry)
276
def test_nonDirectoryChildEntry(self):
278
Test that getCache skips over any entries in a plugin package's
279
C{__path__} which refer to children of paths which are not directories.
281
path = FilePath(self.mktemp())
282
self.failIf(path.exists())
284
child = path.child("test_package").path
285
self.module.__path__.append(child)
287
plgs = list(plugin.getPlugins(ITestPlugin, self.module))
288
self.assertEqual(len(plgs), 1)
290
self.module.__path__.remove(child)
292
test_nonDirectoryChildEntry = _withCacheness(test_nonDirectoryChildEntry)
295
def test_deployedMode(self):
297
The C{dropin.cache} file may not be writable: the cache should still be
298
attainable, but an error should be logged to show that the cache
302
plugin.getCache(self.module)
305
FilePath(__file__).sibling('plugin_extra1.py'
306
).copyTo(self.package.child('pluginextra.py'))
308
os.chmod(self.package.path, 0500)
309
# Change the right of dropin.cache too for windows
310
os.chmod(self.package.child('dropin.cache').path, 0400)
311
self.addCleanup(os.chmod, self.package.path, 0700)
312
self.addCleanup(os.chmod,
313
self.package.child('dropin.cache').path, 0700)
315
cache = plugin.getCache(self.module)
316
# The new plugin should be reported
317
self.assertIn('pluginextra', cache)
318
self.assertIn(self.originalPlugin, cache)
320
errors = self.flushLoggedErrors()
321
self.assertEquals(len(errors), 1)
322
# Windows report OSError, others IOError
323
errors[0].trap(OSError, IOError)
327
# This is something like the Twisted plugins file.
329
from twisted.plugin import pluginPackagePaths
330
__path__.extend(pluginPackagePaths(__name__))
334
def pluginFileContents(name):
336
"from zope.interface import classProvides\n"
337
"from twisted.plugin import IPlugin\n"
338
"from twisted.test.test_plugin import ITestPlugin\n"
340
"class %s(object):\n"
341
" classProvides(IPlugin, ITestPlugin)\n") % (name,)
344
def _createPluginDummy(entrypath, pluginContent, real, pluginModule):
346
Create a plugindummy package.
348
entrypath.createDirectory()
349
pkg = entrypath.child('plugindummy')
350
pkg.createDirectory()
352
pkg.child('__init__.py').setContent('')
353
plugs = pkg.child('plugins')
354
plugs.createDirectory()
356
plugs.child('__init__.py').setContent(pluginInitFile)
357
plugs.child(pluginModule + '.py').setContent(pluginContent)
362
class DeveloperSetupTests(unittest.TestCase):
364
These tests verify things about the plugin system without actually
365
interacting with the deployed 'twisted.plugins' package, instead creating a
371
Create a complex environment with multiple entries on sys.path, akin to
372
a developer's environment who has a development (trunk) checkout of
373
Twisted, a system installed version of Twisted (for their operating
374
system's tools) and a project which provides Twisted plugins.
376
self.savedPath = sys.path[:]
377
self.savedModules = sys.modules.copy()
378
self.fakeRoot = FilePath(self.mktemp())
379
self.fakeRoot.createDirectory()
380
self.systemPath = self.fakeRoot.child('system_path')
381
self.devPath = self.fakeRoot.child('development_path')
382
self.appPath = self.fakeRoot.child('application_path')
383
self.systemPackage = _createPluginDummy(
384
self.systemPath, pluginFileContents('system'),
385
True, 'plugindummy_builtin')
386
self.devPackage = _createPluginDummy(
387
self.devPath, pluginFileContents('dev'),
388
True, 'plugindummy_builtin')
389
self.appPackage = _createPluginDummy(
390
self.appPath, pluginFileContents('app'),
391
False, 'plugindummy_app')
393
# Now we're going to do the system installation.
394
sys.path.extend([x.path for x in [self.systemPath,
396
# Run all the way through the plugins list to cause the
397
# L{plugin.getPlugins} generator to write cache files for the system
400
self.sysplug = self.systemPath.child('plugindummy').child('plugins')
401
self.syscache = self.sysplug.child('dropin.cache')
402
# Make sure there's a nice big difference in modification times so that
403
# we won't re-build the system cache.
406
self.sysplug.child('plugindummy_builtin.py').path,
408
os.utime(self.syscache.path, (now - 2000,) * 2)
409
# For extra realism, let's make sure that the system path is no longer
412
self.resetEnvironment()
415
def lockSystem(self):
417
Lock the system directories, as if they were unwritable by this user.
419
os.chmod(self.sysplug.path, 0555)
420
os.chmod(self.syscache.path, 0555)
423
def unlockSystem(self):
425
Unlock the system directories, as if they were writable by this user.
427
os.chmod(self.sysplug.path, 0777)
428
os.chmod(self.syscache.path, 0777)
431
def getAllPlugins(self):
433
Get all the plugins loadable from our dummy package, and return their
436
# Import the module we just added to our path. (Local scope because
437
# this package doesn't exist outside of this test.)
438
import plugindummy.plugins
439
x = list(plugin.getPlugins(ITestPlugin, plugindummy.plugins))
440
return [plug.__name__ for plug in x]
443
def resetEnvironment(self):
445
Change the environment to what it should be just as the test is
448
self.unsetEnvironment()
449
sys.path.extend([x.path for x in [self.devPath,
453
def unsetEnvironment(self):
455
Change the Python environment back to what it was before the test was
459
sys.modules.update(self.savedModules)
460
sys.path[:] = self.savedPath
465
Reset the Python environment to what it was before this test ran, and
466
restore permissions on files which were marked read-only so that the
467
directory may be cleanly cleaned up.
469
self.unsetEnvironment()
470
# Normally we wouldn't "clean up" the filesystem like this (leaving
471
# things for post-test inspection), but if we left the permissions the
472
# way they were, we'd be leaving files around that the buildbots
473
# couldn't delete, and that would be bad.
477
def test_developmentPluginAvailability(self):
479
Plugins added in the development path should be loadable, even when
480
the (now non-importable) system path contains its own idea of the
481
list of plugins for a package. Inversely, plugins added in the
482
system path should not be available.
484
# Run 3 times: uncached, cached, and then cached again to make sure we
485
# didn't overwrite / corrupt the cache on the cached try.
487
names = self.getAllPlugins()
489
self.assertEqual(names, ['app', 'dev'])
492
def test_freshPyReplacesStalePyc(self):
494
Verify that if a stale .pyc file on the PYTHONPATH is replaced by a
495
fresh .py file, the plugins in the new .py are picked up rather than
496
the stale .pyc, even if the .pyc is still around.
498
mypath = self.appPackage.child("stale.py")
499
mypath.setContent(pluginFileContents('one'))
500
# Make it super stale
501
x = time.time() - 1000
502
os.utime(mypath.path, (x, x))
503
pyc = mypath.sibling('stale.pyc')
505
compileall.compile_dir(self.appPackage.path, quiet=1)
506
os.utime(pyc.path, (x, x))
507
# Eliminate the other option.
509
# Make sure it's the .pyc path getting cached.
510
self.resetEnvironment()
512
self.assertIn('one', self.getAllPlugins())
513
self.failIfIn('two', self.getAllPlugins())
514
self.resetEnvironment()
515
mypath.setContent(pluginFileContents('two'))
516
self.failIfIn('one', self.getAllPlugins())
517
self.assertIn('two', self.getAllPlugins())
520
def test_newPluginsOnReadOnlyPath(self):
522
Verify that a failure to write the dropin.cache file on a read-only
523
path will not affect the list of plugins returned.
525
Note: this test should pass on both Linux and Windows, but may not
526
provide useful coverage on Windows due to the different meaning of
527
"read-only directory".
530
self.sysplug.child('newstuff.py').setContent(pluginFileContents('one'))
533
# Take the developer path out, so that the system plugins are actually
535
sys.path.remove(self.devPath.path)
537
# Sanity check to make sure we're only flushing the error logged
539
self.assertEqual(len(self.flushLoggedErrors()), 0)
540
self.assertIn('one', self.getAllPlugins())
541
self.assertEqual(len(self.flushLoggedErrors()), 1)
545
class AdjacentPackageTests(unittest.TestCase):
547
Tests for the behavior of the plugin system when there are multiple
548
installed copies of the package containing the plugins being loaded.
553
Save the elements of C{sys.path} and the items of C{sys.modules}.
555
self.originalPath = sys.path[:]
556
self.savedModules = sys.modules.copy()
561
Restore C{sys.path} and C{sys.modules} to their original values.
563
sys.path[:] = self.originalPath
565
sys.modules.update(self.savedModules)
568
def createDummyPackage(self, root, name, pluginName):
570
Create a directory containing a Python package named I{dummy} with a
571
I{plugins} subpackage.
573
@type root: L{FilePath}
574
@param root: The directory in which to create the hierarchy.
577
@param name: The name of the directory to create which will contain
580
@type pluginName: C{str}
581
@param pluginName: The name of a module to create in the
582
I{dummy.plugins} package.
585
@return: The directory which was created to contain the I{dummy}
588
directory = root.child(name)
589
package = directory.child('dummy')
591
package.child('__init__.py').setContent('')
592
plugins = package.child('plugins')
594
plugins.child('__init__.py').setContent(pluginInitFile)
595
pluginModule = plugins.child(pluginName + '.py')
596
pluginModule.setContent(pluginFileContents(name))
600
def test_hiddenPackageSamePluginModuleNameObscured(self):
602
Only plugins from the first package in sys.path should be returned by
603
getPlugins in the case where there are two Python packages by the same
604
name installed, each with a plugin module by a single name.
606
root = FilePath(self.mktemp())
609
firstDirectory = self.createDummyPackage(root, 'first', 'someplugin')
610
secondDirectory = self.createDummyPackage(root, 'second', 'someplugin')
612
sys.path.append(firstDirectory.path)
613
sys.path.append(secondDirectory.path)
617
plugins = list(plugin.getPlugins(ITestPlugin, dummy.plugins))
618
self.assertEqual(['first'], [p.__name__ for p in plugins])
621
def test_hiddenPackageDifferentPluginModuleNameObscured(self):
623
Plugins from the first package in sys.path should be returned by
624
getPlugins in the case where there are two Python packages by the same
625
name installed, each with a plugin module by a different name.
627
root = FilePath(self.mktemp())
630
firstDirectory = self.createDummyPackage(root, 'first', 'thisplugin')
631
secondDirectory = self.createDummyPackage(root, 'second', 'thatplugin')
633
sys.path.append(firstDirectory.path)
634
sys.path.append(secondDirectory.path)
638
plugins = list(plugin.getPlugins(ITestPlugin, dummy.plugins))
639
self.assertEqual(['first'], [p.__name__ for p in plugins])
643
class PackagePathTests(unittest.TestCase):
645
Tests for L{plugin.pluginPackagePaths} which constructs search paths for
651
Save the elements of C{sys.path}.
653
self.originalPath = sys.path[:]
658
Restore C{sys.path} to its original value.
660
sys.path[:] = self.originalPath
663
def test_pluginDirectories(self):
665
L{plugin.pluginPackagePaths} should return a list containing each
666
directory in C{sys.path} with a suffix based on the supplied package
669
foo = FilePath('foo')
670
bar = FilePath('bar')
671
sys.path = [foo.path, bar.path]
673
plugin.pluginPackagePaths('dummy.plugins'),
674
[foo.child('dummy').child('plugins').path,
675
bar.child('dummy').child('plugins').path])
678
def test_pluginPackagesExcluded(self):
680
L{plugin.pluginPackagePaths} should exclude directories which are
681
Python packages. The only allowed plugin package (the only one
682
associated with a I{dummy} package which Python will allow to be
683
imported) will already be known to the caller of
684
L{plugin.pluginPackagePaths} and will most commonly already be in
685
the C{__path__} they are about to mutate.
687
root = FilePath(self.mktemp())
688
foo = root.child('foo').child('dummy').child('plugins')
690
foo.child('__init__.py').setContent('')
691
sys.path = [root.child('foo').path, root.child('bar').path]
693
plugin.pluginPackagePaths('dummy.plugins'),
694
[root.child('bar').child('dummy').child('plugins').path])