Cleaned up Builtin handling and reduced driver/plugin duplication.
[hooke.git] / hooke / plugin / __init__.py
1 """The plugin module provides optional submodules that add new Hooke
2 commands.
3
4 All of the science happens in here.
5 """
6
7 import ConfigParser as configparser
8 import Queue as queue
9
10 from ..config import Setting
11 from ..util.graph import Node, Graph
12
13 PLUGIN_MODULES = [
14 #    ('autopeak', True),
15 #    ('curvetools', True),
16     ('cut', True),
17 #    ('fit', True),
18 #    ('flatfilts-rolf', True),
19 #    ('flatfilts', True),
20 #    ('generalclamp', True),
21 #    ('generaltccd', True),
22 #    ('generalvclamp', True),
23 #    ('jumpstat', True),
24 #    ('macro', True),
25 #    ('massanalysis', True),
26 #    ('multidistance', True),
27 #    ('multifit', True),
28 #    ('pcluster', True),
29 #    ('procplots', True),
30 #    ('review', True),
31 #    ('showconvoluted', True),
32 #    ('superimpose', True),
33 #    ('tutorial', True),
34 #    ('viewer', True),
35     ]
36 """List of plugin modules and whether they should be included by
37 default.  TODO: autodiscovery
38 """
39
40 BUILTIN_MODULES = [
41     'playlist',
42     ]
43 """List of builtin modules.  TODO: autodiscovery
44 """
45
46
47 # Plugins and settings
48
49 class Plugin (object):
50     """A pluggable collection of Hooke commands.
51
52     Fulfills the same role for Hooke that a software package does for
53     an operating system.
54     """
55     def __init__(self, name):
56         self.name = name
57         self.setting_section = '%s plugin' % self.name
58         self.config = {}
59
60     def dependencies(self):
61         """Return a list of :class:`Plugin`\s we require."""
62         return []
63
64     def default_settings(self):
65         """Return a list of :class:`hooke.config.Setting`\s for any
66         configurable plugin settings.
67
68         The suggested section setting is::
69
70             Setting(section=self.setting_section, help=self.__doc__)
71         """
72         return []
73
74     def commands(self):
75         """Return a list of :class:`Commands` provided."""
76         return []
77
78 class Builtin (Plugin):
79     """A required collection of Hooke commands.
80
81     These "core" plugins provide essential administrative commands
82     (playlist handling, etc.).
83     """
84     pass
85
86 # Commands and arguments
87
88 class CommandExit (Exception):
89     def __str__(self):
90         return self.__class__.__name__
91
92 class Success (CommandExit):
93     pass
94
95 class Failure (CommandExit):
96     pass
97
98 class Command (object):
99     """One-line command description here.
100
101     >>> c = Command(name='test', help='An example Command.')
102     >>> status = c.run(NullQueue(), PrintQueue(), help=True) # doctest: +REPORT_UDIFF
103     ITEM:
104     Command: test
105     <BLANKLINE>
106     Arguments:
107     help BOOL (bool) Print a help message.
108     <BLANKLINE>
109     An example Command.
110     ITEM:
111     Success
112     """
113     def __init__(self, name, aliases=None, arguments=[], help=''):
114         self.name = name
115         if aliases == None:
116             aliases = []
117         self.aliases = aliases
118         self.arguments = [
119             Argument(name='help', type='bool', default=False, count=1,
120                      callback=StoreValue(True), help='Print a help message.'),
121             ] + arguments
122         self._help = help
123
124     def run(self, inqueue=None, outqueue=None, **kwargs):
125         """`Normalize inputs and handle <Argument help> before punting
126         to :meth:`_run`.
127         """
128         if inqueue == None:
129             inqueue = NullQueue()
130         if outqueue == None:
131             outqueue = NullQueue()
132         try:
133             params = self.handle_arguments(inqueue, outqueue, kwargs)
134             if params['help'] == True:
135                 outqueue.put(self.help())
136                 raise(Success())
137             self._run(inqueue, outqueue, params)
138         except CommandExit, e:
139             if isinstance(e, Failure):
140                 outqueue.put(e.message)
141                 outqueue.put(e)
142                 return 1
143         outqueue.put(e)
144         return 0
145
146     def _run(self, inqueue, outqueue, params):
147         """This is where the command-specific magic will happen.
148         """
149         pass
150
151     def handle_arguments(self, inqueue, outqueue, params):
152         """Normalize and validate input parameters (:class:`Argument` values).
153         """
154         for argument in self.arguments:
155             names = [argument.name] + argument.aliases
156             settings = [(name,v) for name,v in params.items() if name in names]
157             if len(settings) == 0:
158                 if argument.optional == True or argument.count == 0:
159                     settings = [(argument.name, argument.default)]
160                 else:
161                     raise Failure('Required argument %s not set.'
162                                   % argument.name)
163             if len(settings) > 1:
164                 raise Failure('Multiple settings for %s:\n  %s'
165                     % (argument.name,
166                        '\n  '.join(['%s: %s' % (name,value)
167                                     for name,value in sorted(settings)])))
168             name,value = settings[0]
169             if name != argument.name:
170                 params.remove(name)
171                 params[argument.name] = value
172             if argument.callback != None:
173                 value = argument.callback(self, argument, value)
174                 params[argument.name] = value
175             argument.validate(value)
176         return params
177
178     def help(self, *args):
179         name_part = 'Command: %s' % self.name
180         if len(self.aliases) > 0:
181             name_part += ' (%s)' % ', '.join(self.aliases)
182         argument_part = ['Arguments:'] + [a.help() for a in self.arguments]
183         argument_part = '\n'.join(argument_part)
184         help_part = self._help
185         return '\n\n'.join([name_part, argument_part, help_part])
186
187 class Argument (object):
188     """Structured user input for :class:`Command`\s.
189     
190     TODO: ranges for `count`?
191     """
192     def __init__(self, name, aliases=None, type='string', metavar=None,
193                  default=None, optional=True, count=1,
194                  completion_callback=None, callback=None, help=''):
195         self.name = name
196         if aliases == None:
197             aliases = []
198         self.aliases = aliases
199         self.type = type
200         if metavar == None:
201             metavar = type.upper()
202         self.metavar = metavar
203         self.default = default
204         self.optional = optional
205         self.count = count
206         self.completion_callback = completion_callback
207         self.callback = callback
208         self._help = help
209
210     def __str__(self):
211         return '<%s %s>' % (self.__class__.__name__, self.name)
212
213     def __repr__(self):
214         return self.__str__()
215
216     def help(self):
217         parts = ['%s ' % self.name]
218         if self.metavar != None:
219             parts.append('%s ' % self.metavar)
220         parts.extend(['(%s) ' % self.type, self._help])
221         return ''.join(parts)
222
223     def validate(self, value):
224         """If `value` is not appropriate, raise `ValueError`.
225         """
226         pass # TODO: validation
227
228     # TODO: type conversion
229
230 # TODO: type extensions?
231
232 # Useful callbacks
233
234 class StoreValue (object):
235     def __init__(self, value):
236         self.value = value
237     def __call__(self, command, argument, fragment=None):
238         return self.value
239
240 class NullQueue (queue.Queue):
241     """The :class:`queue.Queue` equivalent of `/dev/null`.
242
243     This is a bottomless pit.  Items go in, but never come out.
244     """
245     def get(self, block=True, timeout=None):
246         """Raise queue.Empty.
247         
248         There's really no need to override the base Queue.get, but I
249         want to know if someone tries to read from a NullQueue.  With
250         the default implementation they would just block silently
251         forever :(.
252         """
253         raise queue.Empty
254
255     def put(self, item, block=True, timeout=None):
256         """Dump an item into the void.
257
258         Block and timeout are meaningless, because there is always a
259         free slot available in a bottomless pit.
260         """
261         pass
262
263 class PrintQueue (NullQueue):
264     """Debugging :class:`NullQueue` that prints items before dropping
265     them.
266     """
267     def put(self, item, block=True, timeout=None):
268         """Print `item` and then dump it into the void.
269         """
270         print 'ITEM:\n%s' % item
271
272
273 # Construct plugin dependency graph and load plugin instances.
274
275 def construct_graph(this_modname, submodnames, class_selector,
276                     assert_name_match=True):
277     """Search the submodules `submodnames` of a module `this_modname`
278     for class objects for which `class_selector(class)` returns
279     `True`.  These classes are instantiated, and the `instance.name`
280     is compared to the `submodname` (if `assert_name_match` is
281     `True`).
282
283     The instances are further arranged into a dependency
284     :class:`hooke.util.graph.Graph` according to their
285     `instance.dependencies()` values.  The topologically sorted graph
286     is returned.
287     """
288     instances = {}
289     for submodname in submodnames:
290         count = len([s for s in submodnames if s == submodname])
291         assert count > 0, 'No %s entries: %s' % (submodname, submodnames)
292         assert count == 1, 'Multiple (%d) %s entries: %s' \
293             % (count, submodname, submodnames)
294         this_mod = __import__(this_modname, fromlist=[submodname])
295         submod = getattr(this_mod, submodname)
296         for objname in dir(submod):
297             obj = getattr(submod, objname)
298             if class_selector(obj):
299                 instance = obj()
300                 if assert_name_match == True and instance.name != submodname:
301                     raise Exception(
302                         'Instance name %s does not match module name %s'
303                         % (instance.name, submodname))
304                 instances[instance.name] = instance
305     graph = Graph([Node([instances[name] for name in i.dependencies()],
306                         data=i)
307                    for i in instances.values()])
308     graph.topological_sort()
309     return graph
310
311 class IsSubclass (object):
312     """A safe subclass comparator.
313     
314     Examples
315     --------
316
317     >>> class A (object):
318     ...     pass
319     >>> class B (A):
320     ...     pass
321     >>> C = 5
322     >>> is_subclass = IsSubclass(A)
323     >>> is_subclass(A)
324     True
325     >>> is_subclass = IsSubclass(A, blacklist=[A])
326     >>> is_subclass(A)
327     False
328     >>> is_subclass(B)
329     True
330     >>> is_subclass(C)
331     False
332     """
333     def __init__(self, base_class, blacklist=None):
334         self.base_class = base_class
335         if blacklist == None:
336             blacklist = []
337         self.blacklist = blacklist
338     def __call__(self, other):
339         try:
340             subclass = issubclass(other, self.base_class)
341         except TypeError:
342             return False
343         if other in self.blacklist:
344             return False
345         return subclass
346
347 PLUGIN_GRAPH = construct_graph(
348     this_modname=__name__,
349     submodnames=[name for name,include in PLUGIN_MODULES] + BUILTIN_MODULES,
350     class_selector=IsSubclass(Plugin, blacklist=[Plugin, Builtin]))
351 """Topologically sorted list of all possible :class:`Plugin`\s and
352 :class:`Builtin`\s.
353 """
354
355 def default_settings():
356     settings = [Setting(
357             'plugins', help='Enable/disable default plugins.')]
358     for pnode in PLUGIN_GRAPH:
359         if pnode.data.name in BUILTIN_MODULES:
360             continue # builtin inclusion is not optional
361         plugin = pnode.data
362         default_include = [di for mod_name,di in PLUGIN_MODULES
363                            if mod_name == plugin.name][0]
364         help = 'Commands: ' + ', '.join([c.name for c in plugin.commands()])
365         settings.append(Setting(
366                 section='plugins',
367                 option=plugin.name,
368                 value=str(default_include),
369                 help=help,
370                 ))
371     for pnode in PLUGIN_GRAPH:
372         plugin = pnode.data
373         settings.extend(plugin.default_settings())
374     return settings
375
376 def load_graph(graph, config, include_section):
377     items = []
378     for node in graph:
379         item = node.data
380         try:
381             include = config.getboolean(include_section, item.name)
382         except configparser.NoOptionError:
383             include = True # non-optional include (e.g. a Builtin)
384         if include == True:
385             try:
386                 item.config = dict(
387                     config.items(item.setting_section))
388             except configparser.NoSectionError:
389                 pass
390             items.append(item)
391     return items