| 1 | import inspect, types, re |
|---|
| 2 | from textwrap import dedent |
|---|
| 3 | from zope.interface import implements |
|---|
| 4 | |
|---|
| 5 | from twisted.plugin import getPlugins |
|---|
| 6 | from twisted.python.components import registerAdapter |
|---|
| 7 | from twisted.python.util import mergeFunctionMetadata |
|---|
| 8 | |
|---|
| 9 | from eridanus import plugins, errors |
|---|
| 10 | from eridanus.ieridanus import (ICommand, IEridanusPluginProvider, |
|---|
| 11 | IEridanusPlugin, IAmbientEventObserver) |
|---|
| 12 | |
|---|
| 13 | |
|---|
| 14 | paramPattern = re.compile(r'([<[])(.*?)([>\]])') |
|---|
| 15 | |
|---|
| 16 | def 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 | |
|---|
| 27 | def 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 | |
|---|
| 40 | def 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 | |
|---|
| 56 | def 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 | |
|---|
| 68 | def 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 | |
|---|
| 90 | class 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 | |
|---|
| 138 | class 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 | |
|---|
| 146 | class 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 | |
|---|
| 234 | registerAdapter(MethodCommand, types.MethodType, ICommand) |
|---|
| 235 | |
|---|
| 236 | |
|---|
| 237 | class Plugin(CommandLookupMixin): |
|---|
| 238 | """ |
|---|
| 239 | Simple plugin mixin. |
|---|
| 240 | """ |
|---|
| 241 | implements(IEridanusPlugin) |
|---|
| 242 | |
|---|
| 243 | name = None |
|---|
| 244 | pluginName = None |
|---|
| 245 | |
|---|
| 246 | |
|---|
| 247 | def getAllPlugins(): |
|---|
| 248 | """ |
|---|
| 249 | Get all plugins. |
|---|
| 250 | """ |
|---|
| 251 | return getPlugins(IEridanusPluginProvider, plugins) |
|---|
| 252 | |
|---|
| 253 | |
|---|
| 254 | def 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 | |
|---|
| 275 | def getInstalledPlugins(store): |
|---|
| 276 | """ |
|---|
| 277 | Get all plugins installed on C{store}. |
|---|
| 278 | """ |
|---|
| 279 | return store.powerupsFor(IEridanusPlugin) |
|---|
| 280 | |
|---|
| 281 | |
|---|
| 282 | def 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 | |
|---|
| 293 | def getAmbientEventObservers(store): |
|---|
| 294 | """ |
|---|
| 295 | Get all Items that provide C{IAmbientEventObserver}. |
|---|
| 296 | """ |
|---|
| 297 | return store.powerupsFor(IAmbientEventObserver) |
|---|
| 298 | |
|---|
| 299 | |
|---|
| 300 | def 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 | |
|---|
| 321 | def 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 | |
|---|
| 343 | class AmbientEventObserver(object): |
|---|
| 344 | """ |
|---|
| 345 | Abstract base class for L{IAmbientEventObserver}. |
|---|
| 346 | """ |
|---|
| 347 | def publicMessageReceived(self, source, message): |
|---|
| 348 | pass |
|---|