root/eridanus/plugin.py

Revision 185, 9.2 kB (checked in by Jonathan Jacobs <korpse@…>, 20 months ago)

Help format tweaking.

Line 
1import inspect, types, re
2from textwrap import dedent
3from zope.interface import implements
4
5from twisted.plugin import getPlugins
6from twisted.python.components import registerAdapter
7from twisted.python.util import mergeFunctionMetadata
8
9from eridanus import plugins, errors
10from eridanus.ieridanus import (ICommand, IEridanusPluginProvider,
11    IEridanusPlugin, IAmbientEventObserver)
12
13
14paramPattern = re.compile(r'([<[])(.*?)([>\]])')
15
16def formatUsage(s):
17    """
18    Add some IRC formatting to defined parameters.
19
20    Parameters are detected by being enclosed in C{<>} or C{[]}.
21
22    @returns: Marked-up string
23    """
24    return paramPattern.sub(r'\1\002\2\002\3', s)
25
26
27def formatHelp(help, sep=' '):
28    """
29    Dedent help text and strip blank lines.
30
31    @returns: A "short help" and the complete help
32    @rtype: C{(shortHelp, help)}
33    """
34    lines = [line.strip()
35             for line in dedent(help).splitlines()
36             if line.strip()]
37    return lines[0], sep.join(lines)
38
39
40def usage(desc):
41    """
42    Decorate a function with usage and help information.
43
44    Help text is extracted from the function's doc string.
45
46    @param desc: Usage description
47    @type desc: C{str} or C{unicode}
48    """
49    def fact(f):
50        f.usage = formatUsage(desc)
51        f.help = f.__doc__
52        return f
53    return fact
54
55
56def alias(f, name=None):
57    """
58    Create an alias of another command.
59    """
60    newCmd = mergeFunctionMetadata(f, lambda *a, **kw: f(*a, **kw))
61    newCmd.alias = True
62    if name is not None:
63        newCmd.func_name = name
64    newCmd.arglimits = getCommandArgLimits(f)
65    return newCmd
66
67
68def getCommandArgLimits(method, minargs=None, maxargs=None):
69    """
70    Find the argument limits on an ICommand method.
71    """
72    args, vararg, varkw, defaults = inspect.getargspec(method)
73    # Exclude self and source parameters.
74    normArgCount = len(args) - 2
75
76    if minargs is None:
77        # Exclude default arguments from impacting the minimum number of
78        # required arguments.
79        minargs = normArgCount - len(defaults or [])
80
81    if maxargs is None:
82        if vararg is None:
83            maxargs = normArgCount
84        else:
85            maxargs = None
86
87    return minargs, maxargs
88
89
90class CommandLookupMixin(object):
91    """
92    L{ICommand} implementation that locates methods suitable for invocation.
93
94    Methods whose names begin with C{cmd_} are adapted to L{ICommand} when
95    locating commands.
96    """
97    implements(ICommand)
98
99    name = None
100    usage = None
101
102    @property
103    def help(self):
104        """
105        Extract help text from C{__doc__}.
106        """
107        help = self.__doc__
108        if help is None:
109            return 'No additional help.'
110        return formatHelp(help)[1]
111
112    def getCommands(self):
113        for name in dir(self):
114            if name.startswith('cmd_'):
115                yield ICommand(getattr(self, name))
116
117    ### ICommand
118
119    def locateCommand(self, params):
120        cmd = params.pop(0).lower()
121        method = getattr(self,
122                         'cmd_%s' % cmd,
123                         None)
124        if method is None:
125            msg = 'Unknown command "%s"' % (cmd,)
126            raise errors.UsageError(msg)
127
128        cmd = ICommand(method)
129        # XXX: This might not be the best route.  Primarily useful for making
130        # SubCommand not quite so useless (access to the parent's store etc.)
131        cmd.parent = self
132        return cmd, params
133
134    def invoke(self, source):
135        raise errors.UsageError('Not a command')
136
137
138class SubCommand(CommandLookupMixin):
139    # XXX: maybe this could actually work?
140    alias = False
141
142    def invoke(self, source):
143        raise errors.UsageError('Too few parameters -- ' + self.help)
144
145
146class MethodCommand(object):
147    """
148    Wraps a method in something that implements L{ICommand}.
149
150    This is most useful when combined with L{eridanus.plugin.usage} to generate
151    (and format) the relevant help strings.
152
153    @ivar method: The method being wrapped
154
155    @ivar usage: The command's usage, extracted from C{method.usage}
156                 or C{defaultUsage}
157    @type usage: C{str} or C{unicode}
158
159    @ivar help: The command's complete help, extracted from C{method.help}
160                or C{defaultHelp}
161    @type help: C{str} or C{unicode}
162
163    @ivar shortHelp: The first line of C{help}, which should be a brief
164                     description of the command's purpose
165    """
166    implements(ICommand)
167
168    defaultUsage = 'No usage information'
169    defaultHelp = """No additional help."""
170
171    def __init__(self, method):
172        super(MethodCommand, self).__init__()
173
174        usage = getattr(method, 'usage', None)
175        if usage is None:
176            usage = self.defaultUsage
177
178        help = getattr(method, 'help', None)
179        if help is None:
180            help = self.defaultHelp
181
182        self.method = method
183        self.params = []
184        self.name = method.__name__[4:]
185        self.usage = usage
186        self.shortHelp, self.help = formatHelp(help)
187
188        self.minargs, self.maxargs = self.getArgLimits()
189
190    def __repr__(self):
191        return '<%s wrapping %s>' % (type(self).__name__, self.method)
192
193    def getArgLimits(self):
194        """
195        Find the minimum and maximum arguments for L{MethodCommand.method}.
196
197        If the method has an C{arglimits} 2-tuple attribute, these values
198        are used.  Otherwise the limits are calculated by inspecting the
199        method.
200
201        Implicit and default parameters are not taken into account when
202        calculating the minimum number of arguments.
203
204        The maximum number of arguments will be unbounded if the method accepts
205        varargs.
206
207        @returns: The minimum and maximum number of arguments that the method
208                  will accept
209        @rtype: C{(min, max)}
210        """
211        minargs, maxargs = arglimits = getattr(self.method, 'arglimits', (None, None))
212        return getCommandArgLimits(self.method, minargs, maxargs)
213
214    @property
215    def alias(self):
216        return getattr(self.method, 'alias', False)
217
218    ### ICommand
219
220    def locateCommand(self, params):
221        self.params = params
222        return self, []
223
224    def invoke(self, source):
225        numargs = len(self.params)
226
227        if numargs < self.minargs:
228            raise errors.UsageError('Not enough arguments -- ' + self.usage)
229        if self.maxargs is not None and numargs > self.maxargs:
230            raise errors.UsageError('Too many arguments -- ' + self.usage)
231
232        return self.method(source, *self.params)
233
234registerAdapter(MethodCommand, types.MethodType, ICommand)
235
236
237class Plugin(CommandLookupMixin):
238    """
239    Simple plugin mixin.
240    """
241    implements(IEridanusPlugin)
242
243    name = None
244    pluginName = None
245
246
247def getAllPlugins():
248    """
249    Get all plugins.
250    """
251    return getPlugins(IEridanusPluginProvider, plugins)
252
253
254def getPluginByName(store, name):
255    """
256    Get an C{IEridanusPlugin} provider by name.
257
258    @type store: C{axiom.store.Store}
259
260    @param name: Name of the plugin to find
261    @type name: C{unicode}
262
263    @raises PluginNotFound: If no plugin named C{name} could be found
264
265    @returns: The plugin item
266    @rtype: C{IEridanusPlugin}
267    """
268    for plugin in store.powerupsFor(IEridanusPlugin):
269        if plugin.name == name:
270            return plugin
271
272    raise errors.PluginNotInstalled(name)
273
274
275def getInstalledPlugins(store):
276    """
277    Get all plugins installed on C{store}.
278    """
279    return store.powerupsFor(IEridanusPlugin)
280
281
282def getPluginProvidersByName(pluginName):
283    """
284    Get all objects that provide C{IEridanusPluginProvider}.
285    """
286    for plugin in getPlugins(IEridanusPluginProvider, plugins):
287        if plugin.pluginName == pluginName:
288            yield plugin
289
290    raise errors.PluginNotFound(u'No plugin named "%s".' % (pluginName,))
291
292
293def getAmbientEventObservers(store):
294    """
295    Get all Items that provide C{IAmbientEventObserver}.
296    """
297    return store.powerupsFor(IAmbientEventObserver)
298
299
300def installPlugin(store, pluginName):
301    """
302    Install a plugin on a store.
303
304    @type store: C{axiom.store.Store}
305
306    @param pluginName: Name of the plugin to install
307    @type pluginName: C{unicode}
308
309    @raises PluginNotFound: If no plugin named C{pluginName} could be found
310    """
311    for plugin in getPluginProvidersByName(pluginName):
312        p = store.findOrCreate(plugin)
313        store.powerUp(p, IEridanusPlugin)
314        if IAmbientEventObserver.providedBy(plugin):
315            store.powerUp(p, IAmbientEventObserver)
316        return
317
318    raise errors.PluginNotFound(u'No plugin named "%s".' % (pluginName,))
319
320
321def uninstallPlugin(store, pluginName):
322    """
323    Uninstall a plugin from a store.
324
325    @type store: C{axiom.store.Store}
326
327    @param pluginName: Name of the plugin to install
328    @type pluginName: C{unicode}
329
330    @raise errors.PluginNotInstalled: If C{pluginName} is not installed on
331        C{store}
332    """
333    # XXX: this should probably use store.powerupsFor
334    for plugin in getPluginProvidersByName(pluginName):
335        p = store.findUnique(plugin, default=None)
336        if p is None:
337            raise errors.PluginNotInstalled(pluginName)
338
339        store.powerDown(p, IEridanusPlugin)
340        return
341
342
343class AmbientEventObserver(object):
344    """
345    Abstract base class for L{IAmbientEventObserver}.
346    """
347    def publicMessageReceived(self, source, message):
348        pass
Note: See TracBrowser for help on using the browser.