views:

72

answers:

4

I have a package of plug-in style modules. It looks like this:

/Plugins 
/Plugins/__init__.py
/Plugins/Plugin1.py
/Plugins/Plugin2.py 
etc...

Each .py file contains a class that derives from PluginBaseClass. So I need to list every module in the Plugins package and then search for any classes that implement PluginBaseClass. Ideally I want to be able to do something like this:

for klass in iter_plugins(project.Plugins):
    action = klass()
    action.run()

I have seen some other answers out there, but my situation is different. I have an actual import to the base package (ie: import project.Plugins) and I need to find the classes after discovering the modules.

A: 

If you don't know what's going to be in Plugins ahead of time, you can get a list of python files in the package's directory, and import them like so:

# compute a list of modules in the Plugins package

import os
import Plugins
plugin_modules = [f[:-3] for f in os.listdir(os.path.dirname(Plugins.__file__)) 
                  if f.endswith('.py') and f != '__init__.py']

Sorry, that comprehension might be a mouthful for someone relatively new to python. Here's a more verbose version (might be easier to follow):

plugin_modules = []

package_path = Plugins.__file__
file_list = os.listdir(os.path.dirname(package_path))
for file_name in file_list:
    if file_name.endswith('.py') and file_name != '__init__.py':
        plugin_modules.append(file_name)

Then you can use __import__ to get the module:

# get the first one
plugin = __import__('Plugins.' + plugin_modules[0])
Seth
Well that gets the module names, but when I import like you show, I get back the module that is one lower. So `__import__("Plugins.Plugin1")` prints out `<module 'Plugins' from 'D:\source\Plugins\\__init__.pyc'>`. This is the same problem I have had from other methods as well.
Jason Webb
+3  A: 

Edit: here's a revised solution. I realised I was making a mistake while testing my previous one, and it doesn't really work the way you would expect. So here is a more complete solution:

import os
from imp import find_module
from types import ModuleType, ClassType

def iter_plugins(package):
    """Receives package (as a string) and, for all of its contained modules,
    generates all classes that are subclasses of PluginBaseClass."""

    # Despite the function name, "find_module" will find the package
    # (the "filename" part of the return value will be None, in this case)
    filename, path, description = find_module(package)

    # dir(some_package) will not list the modules within the package,
    # so we explicitly look for files. If you need to recursively descend
    # a directory tree, you can adapt this to use os.walk instead of os.listdir
    modules =  sorted(set(i.partition('.')[0]
                          for i in os.listdir(path)
                          if i.endswith(('.py', '.pyc', '.pyo'))
                          and not i.startswith('__init__.py')))
    pkg = __import__(package, fromlist=modules)
    for m in modules:
        module = getattr(pkg, m)
        if type(module) == ModuleType:  
            for c in dir(module):
                klass = getattr(module, c)
                if (type(klass) == ClassType and
                    klass is not PluginBaseClass and
                    issubclass(klass, PluginBaseClass)):
                    yield klass

My previous solution was:

You could try something like:

from types import ModuleType
import Plugins

classes = []
for item in dir(Plugins):
    module = getattr(Plugins, item)
    # Get all (and only) modules in Plugins
    if type(module) == ModuleType:
        for c in dir(module):
            klass = getattr(module, c)
            if isinstance(klass, PluginBaseClass):
                classes.append(klass)

Actually, even better, if you want some modularity:

from types import ModuleType

def iter_plugins(package):
    # This assumes "package" is a package name.
    # If it's the package itself, you can remove this __import__
    pkg = __import__(package)
    for item in dir(pkg):
        module = getattr(pkg, item)
        if type(module) == ModuleType:  
            for c in dir(module):
                klass = getattr(module, c)
                if issubclass(klass, PluginBaseClass):
                    yield klass
rbp
This doesn't seem to work to discover the plugin modules. If I pass it `Plugins.Plugin1` then it works up until the `isinstance` call which doesn't work since we haven't instantiated the class yet.
Jason Webb
`issubclass` does the trick. Just need to know how to load the sub-modules of the package.
Jason Webb
Ah, sorry, I meant "issubclass", not "isinstance". I'm fixing the answer.
rbp
By "submodules", do you mean that the packages may have an arbitrary number of module "levels"? If so, you search for classes *and* modules, around the "for c in dir(module)" line, and keep digging down. See what I mean?
rbp
I don't understand why `__import__` is returning `<module 'Plugins' from 'D:\source\Plugins\\__init__.pyc'>` which is one module lower than what I am passing into it. Any idea on that?
Jason Webb
If you pass "Plugin" (as a string), it should return exactly the same thing as you would get if you did "import Plugin". What exactly are you passing? Which return value did you expect? Please note that this `__import__` is meant to return the topmost package. From there, the code loops to find the modules that the package contains (and doesn't import them)
rbp
Jason: I just realised my previous solution had an error, which made it not work on actual packages. I've updated my answer, please try that (I have, on a directory structure similar to what you described). Let me know if you still have this problem.
rbp
+2  A: 

You may (and probably should) define __all__ in __init__.py as a list of the submodules in your package; this is so that you support people doing from Plugins import *. If you have done so, you can iterate over the modules with

import Plugins
import sys
modules = { }
for module in Plugins.__all__:
    __import__( module )
    modules[ module ] = sys.modules[ module ]
    # iterate over dir( module ) as above

The reason another answer posted here fails is that __import__ imports the lowest-level module, but returns the top-level one (see the docs). I don't know why.

katrielalex
I would like to avoid this. The Plugins package can have up to 500 active plugins in it at a time.
Jason Webb
@Jason: I believe this is the only robust way to do this, because of e.g. symlinks, dynamic creation of modules, that sort of thing. However, if you are guaranteed that all the modules will exist as Python files in the directory, you can instead iterate over `( module for module in os.listdir( "." ) if module.endswith( ".py" ) )`.NB Edited to comment on why `__import__` is returning the wrong value.
katrielalex
The pointer on the `__import__` statement was helpful. Now that I have worked past that, it seems that `dir()` doesn't actually return sub-modules. Is there another way to accomplish that, that you have heard of?
Jason Webb
@Jason: Ah, I was being a bit naive. `dir` won't return sub-modules, because it can't -- if it did, `__all__` would be redundant. The only way to do this if you haven't defined `__all__` is to list the files in the directory: `( m for m in os.listdir( "." ) if m.endswith( ".py" )`
katrielalex
See http://docs.python.org/tutorial/modules.html#importing-from-a-package -- it appears the main reasons that `from ... import *` is not supported are speed and the possibility of side-effects.
katrielalex
+1  A: 

Scanning modules isn't good idea. If you need class registry you should look at metaclasses or use existing solutions like zope.interface. Simple solution through metaclasses may look like that:

from functools import reduce
class DerivationRegistry(type):
    def __init__(cls,name,bases,cls_dict):
        type.__init__(cls,name,bases,cls_dict)
        cls._subclasses = set()
        for base in bases:
            if isinstance(base,DerivationRegistry):
                base._subclasses.add(cls)

    def getSubclasses(cls):
        return reduce( set.union,
                       ( succ.getSubclasses() for succ  in cls._subclasses if isinstance(succ,DerivationRegistry)),
                       cls._subclasses)

class Base(object):
    __metaclass__ = DerivationRegistry

class Cls1(object):
    pass

class Cls2(Base):
    pass

class Cls3(Cls2,Cls1):
    pass

class Cls4(Cls3):
    pass

print(Base.getSubclasses())
Odomontois