2011-04-27 14:59:02 +02:00
|
|
|
###
|
|
|
|
# Copyright (c) 2011, Valentin Lorentz
|
|
|
|
# All rights reserved.
|
|
|
|
#
|
|
|
|
# Redistribution and use in source and binary forms, with or without
|
|
|
|
# modification, are permitted provided that the following conditions are met:
|
|
|
|
#
|
|
|
|
# * Redistributions of source code must retain the above copyright notice,
|
|
|
|
# this list of conditions, and the following disclaimer.
|
|
|
|
# * Redistributions in binary form must reproduce the above copyright notice,
|
|
|
|
# this list of conditions, and the following disclaimer in the
|
|
|
|
# documentation and/or other materials provided with the distribution.
|
|
|
|
# * Neither the name of the author of this software nor the name of
|
|
|
|
# contributors to this software may be used to endorse or promote products
|
|
|
|
# derived from this software without specific prior written consent.
|
|
|
|
#
|
|
|
|
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
|
|
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
|
|
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
|
|
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
|
|
|
|
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
|
|
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
|
|
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
|
|
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
|
|
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
|
|
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
|
|
# POSSIBILITY OF SUCH DAMAGE.
|
|
|
|
|
|
|
|
###
|
|
|
|
|
2011-04-28 11:38:48 +02:00
|
|
|
import os
|
2012-08-04 20:39:30 +02:00
|
|
|
import io
|
|
|
|
import sys
|
2011-04-27 14:59:02 +02:00
|
|
|
import json
|
2011-09-01 11:10:31 +02:00
|
|
|
import shutil
|
2011-04-28 11:38:48 +02:00
|
|
|
import tarfile
|
2011-04-27 14:59:02 +02:00
|
|
|
|
|
|
|
import supybot.log as log
|
2011-04-28 11:38:48 +02:00
|
|
|
import supybot.conf as conf
|
2011-04-27 14:59:02 +02:00
|
|
|
import supybot.utils as utils
|
|
|
|
from supybot.commands import *
|
2015-08-11 16:50:23 +02:00
|
|
|
import supybot.utils.minisix as minisix
|
2011-04-27 14:59:02 +02:00
|
|
|
import supybot.plugins as plugins
|
|
|
|
import supybot.ircutils as ircutils
|
|
|
|
import supybot.callbacks as callbacks
|
|
|
|
from supybot.i18n import PluginInternationalization, internationalizeDocstring
|
|
|
|
|
|
|
|
_ = PluginInternationalization('PluginDownloader')
|
|
|
|
|
|
|
|
class Repository:
|
|
|
|
pass
|
|
|
|
|
|
|
|
class VersionnedRepository(Repository):
|
|
|
|
pass
|
|
|
|
|
|
|
|
class GitRepository(VersionnedRepository):
|
|
|
|
pass
|
|
|
|
|
|
|
|
class GithubRepository(GitRepository):
|
2017-08-25 03:58:09 +02:00
|
|
|
def __init__(self, username, reponame, path='/', branch='master'):
|
2011-04-27 14:59:02 +02:00
|
|
|
self._username = username
|
|
|
|
self._reponame = reponame
|
2017-08-25 03:58:09 +02:00
|
|
|
self._branch = branch
|
2011-04-28 11:38:48 +02:00
|
|
|
if not path.startswith('/'):
|
|
|
|
path = '/' + path
|
|
|
|
if not path.endswith('/'):
|
|
|
|
path += '/'
|
|
|
|
self._path = path
|
2011-04-28 13:57:06 +02:00
|
|
|
|
2017-08-25 03:58:09 +02:00
|
|
|
self._downloadUrl = 'https://github.com/%s/%s/tarball/%s' % \
|
2011-04-28 11:38:48 +02:00
|
|
|
(
|
|
|
|
self._username,
|
|
|
|
self._reponame,
|
2017-08-25 03:58:09 +02:00
|
|
|
self._branch
|
2011-04-28 11:38:48 +02:00
|
|
|
)
|
|
|
|
|
2011-04-27 14:59:02 +02:00
|
|
|
|
2012-06-13 18:07:23 +02:00
|
|
|
_apiUrl = 'https://api.github.com'
|
2011-04-27 14:59:02 +02:00
|
|
|
def _query(self, type_, uri_end, args={}):
|
|
|
|
args = dict([(x,y) for x,y in args.items() if y is not None])
|
|
|
|
url = '%s/%s/%s?%s' % (self._apiUrl, type_, uri_end,
|
2015-08-10 17:55:25 +02:00
|
|
|
utils.web.urlencode(args))
|
2012-08-04 20:39:30 +02:00
|
|
|
return json.loads(utils.web.getUrl(url).decode('utf8'))
|
2011-04-27 14:59:02 +02:00
|
|
|
|
|
|
|
def getPluginList(self):
|
2017-08-25 03:58:09 +02:00
|
|
|
plugins = self._query('repos',
|
|
|
|
'%s/%s/contents%s' % (self._username,
|
|
|
|
self._reponame,
|
|
|
|
self._path),
|
|
|
|
args={'ref': self._branch}
|
|
|
|
)
|
2012-06-13 18:07:23 +02:00
|
|
|
if plugins is None:
|
2011-04-27 14:59:02 +02:00
|
|
|
log.error((
|
|
|
|
'Cannot get plugins list from repository %s/%s '
|
|
|
|
'at Github'
|
|
|
|
) % (self._username, self._reponame))
|
|
|
|
return []
|
2012-06-13 18:07:23 +02:00
|
|
|
plugins = [x['name'] for x in plugins if x['type'] == 'dir']
|
2011-04-27 14:59:02 +02:00
|
|
|
return plugins
|
|
|
|
|
2012-04-29 19:55:41 +02:00
|
|
|
def _download(self, plugin):
|
|
|
|
try:
|
2012-08-04 20:39:30 +02:00
|
|
|
response = utils.web.getUrlFd(self._downloadUrl)
|
2015-08-09 00:23:03 +02:00
|
|
|
if minisix.PY2:
|
2012-08-04 20:39:30 +02:00
|
|
|
assert response.getcode() == 200, response.getcode()
|
|
|
|
else:
|
|
|
|
assert response.status == 200, response.status
|
2015-08-10 17:55:25 +02:00
|
|
|
fileObject = minisix.io.BytesIO()
|
2012-08-04 20:39:30 +02:00
|
|
|
fileObject.write(response.read())
|
|
|
|
finally: # urllib does not handle 'with' statements :(
|
|
|
|
response.close()
|
|
|
|
fileObject.seek(0)
|
|
|
|
return tarfile.open(fileobj=fileObject, mode='r:gz')
|
2017-08-25 03:58:09 +02:00
|
|
|
|
2011-04-28 11:38:48 +02:00
|
|
|
def install(self, plugin):
|
2012-04-29 19:55:41 +02:00
|
|
|
archive = self._download(plugin)
|
|
|
|
prefix = archive.getnames()[0]
|
|
|
|
dirname = ''.join((self._path, plugin))
|
2011-04-28 11:38:48 +02:00
|
|
|
directories = conf.supybot.directories.plugins()
|
|
|
|
directory = self._getWritableDirectoryFromList(directories)
|
2012-12-19 17:55:54 +01:00
|
|
|
assert directory is not None, \
|
|
|
|
'No valid directory in supybot.directories.plugins.'
|
2011-04-28 11:38:48 +02:00
|
|
|
|
|
|
|
try:
|
2012-12-19 17:55:54 +01:00
|
|
|
assert archive.getmember(prefix + dirname).isdir(), \
|
|
|
|
'This is not a valid plugin (it is a file, not a directory).'
|
2011-04-28 11:38:48 +02:00
|
|
|
|
2015-08-09 00:23:03 +02:00
|
|
|
run_2to3 = minisix.PY3
|
2011-04-28 11:38:48 +02:00
|
|
|
for file in archive.getmembers():
|
|
|
|
if file.name.startswith(prefix + dirname):
|
|
|
|
extractedFile = archive.extractfile(file)
|
|
|
|
newFileName = os.path.join(*file.name.split('/')[1:])
|
2011-04-28 13:57:06 +02:00
|
|
|
newFileName = newFileName[len(self._path)-1:]
|
2011-04-28 11:38:48 +02:00
|
|
|
newFileName = os.path.join(directory, newFileName)
|
2011-07-14 17:34:27 +02:00
|
|
|
if os.path.exists(newFileName):
|
2012-12-19 17:55:54 +01:00
|
|
|
assert os.path.isdir(newFileName), newFileName + \
|
|
|
|
'should not be a file.'
|
2011-09-01 11:10:31 +02:00
|
|
|
shutil.rmtree(newFileName)
|
2011-04-28 11:38:48 +02:00
|
|
|
if extractedFile is None:
|
|
|
|
os.mkdir(newFileName)
|
|
|
|
else:
|
2013-11-26 18:13:56 +01:00
|
|
|
with open(newFileName, 'ab') as fd:
|
|
|
|
reload_imported = False
|
|
|
|
for line in extractedFile.readlines():
|
2015-08-09 00:23:03 +02:00
|
|
|
if minisix.PY3:
|
2016-08-01 16:09:11 +02:00
|
|
|
if b'import reload' in line:
|
2013-11-26 18:13:56 +01:00
|
|
|
reload_imported = True
|
|
|
|
elif not reload_imported and \
|
2016-08-01 16:09:11 +02:00
|
|
|
b'reload(' in line:
|
|
|
|
fd.write(b'from imp import reload\n')
|
2013-11-26 18:13:56 +01:00
|
|
|
reload_imported = True
|
|
|
|
fd.write(line)
|
|
|
|
if newFileName.endswith('__init__.py'):
|
|
|
|
with open(newFileName) as fd:
|
|
|
|
lines = list(filter(lambda x:'import plugin' in x,
|
|
|
|
fd.readlines()))
|
|
|
|
if lines and lines[0].startswith('from . import'):
|
|
|
|
# This should be already Python 3-compatible
|
|
|
|
run_2to3 = False
|
2011-04-28 11:38:48 +02:00
|
|
|
finally:
|
|
|
|
archive.close()
|
2012-04-29 19:55:41 +02:00
|
|
|
del archive
|
2013-11-26 18:13:56 +01:00
|
|
|
if run_2to3:
|
|
|
|
try:
|
|
|
|
import lib2to3
|
|
|
|
except ImportError:
|
|
|
|
return _('Plugin is probably not compatible with your '
|
2014-03-22 15:23:50 +01:00
|
|
|
'Python version (3.x) and could not be converted '
|
2013-11-26 18:13:56 +01:00
|
|
|
'because 2to3 is not installed.')
|
|
|
|
import subprocess
|
|
|
|
fixers = []
|
|
|
|
subprocess.Popen(['2to3', '-wn', os.path.join(directory, plugin)]) \
|
|
|
|
.wait()
|
|
|
|
return _('Plugin was designed for Python 2, but an attempt to '
|
|
|
|
'convert it to Python 3 has been made. There is no '
|
2014-11-10 09:15:42 +01:00
|
|
|
'guarantee it will work, though.')
|
2013-11-26 18:13:56 +01:00
|
|
|
else:
|
|
|
|
return _('Plugin successfully installed.')
|
2012-04-29 19:55:41 +02:00
|
|
|
|
|
|
|
def getInfo(self, plugin):
|
|
|
|
archive = self._download(plugin)
|
|
|
|
prefix = archive.getnames()[0]
|
|
|
|
dirname = ''.join((self._path, plugin))
|
|
|
|
for file in archive.getmembers():
|
2012-09-16 15:01:16 +02:00
|
|
|
if file.name.startswith(prefix + dirname + '/README'):
|
2012-09-16 14:53:34 +02:00
|
|
|
extractedFile = archive.extractfile(file)
|
2013-07-30 10:53:58 +02:00
|
|
|
content = extractedFile.read()
|
2015-08-09 00:23:03 +02:00
|
|
|
if minisix.PY3:
|
2013-07-30 10:53:58 +02:00
|
|
|
content = content.decode()
|
|
|
|
return content
|
2011-04-28 11:38:48 +02:00
|
|
|
|
|
|
|
def _getWritableDirectoryFromList(self, directories):
|
|
|
|
for directory in directories:
|
|
|
|
if os.access(directory, os.W_OK):
|
|
|
|
return directory
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
2015-01-16 08:12:59 +01:00
|
|
|
repositories = utils.InsensitivePreservingDict({
|
2011-04-28 14:20:36 +02:00
|
|
|
'ProgVal': GithubRepository(
|
|
|
|
'ProgVal',
|
|
|
|
'Supybot-plugins'
|
|
|
|
),
|
|
|
|
'quantumlemur': GithubRepository(
|
|
|
|
'quantumlemur',
|
|
|
|
'Supybot-plugins',
|
|
|
|
),
|
|
|
|
'stepnem': GithubRepository(
|
|
|
|
'stepnem',
|
|
|
|
'supybot-plugins',
|
|
|
|
),
|
2013-01-05 19:20:35 +01:00
|
|
|
'code4lib-snapshot':GithubRepository(
|
|
|
|
'code4lib',
|
2011-04-28 14:20:36 +02:00
|
|
|
'supybot-plugins',
|
|
|
|
'Supybot-plugins-20060723',
|
|
|
|
),
|
2013-01-05 19:20:35 +01:00
|
|
|
'code4lib-edsu': GithubRepository(
|
|
|
|
'code4lib',
|
2011-04-28 14:20:36 +02:00
|
|
|
'supybot-plugins',
|
|
|
|
'edsu-plugins',
|
|
|
|
),
|
2013-01-05 19:20:35 +01:00
|
|
|
'code4lib': GithubRepository(
|
|
|
|
'code4lib',
|
2011-04-28 14:20:36 +02:00
|
|
|
'supybot-plugins',
|
|
|
|
'plugins',
|
|
|
|
),
|
|
|
|
'nanotube-bitcoin': GithubRepository(
|
|
|
|
'nanotube',
|
|
|
|
'supybot-bitcoin-'
|
|
|
|
'marketmonitor',
|
|
|
|
),
|
2011-04-28 14:47:25 +02:00
|
|
|
'mtughan-weather': GithubRepository(
|
|
|
|
'mtughan',
|
|
|
|
'Supybot-Weather',
|
|
|
|
),
|
|
|
|
'SpiderDave': GithubRepository(
|
|
|
|
'SpiderDave',
|
|
|
|
'spidey-supybot-plugins',
|
|
|
|
'Plugins',
|
|
|
|
),
|
2012-04-16 22:09:55 +02:00
|
|
|
'doorbot': GithubRepository(
|
|
|
|
'hacklab',
|
|
|
|
'doorbot',
|
|
|
|
),
|
|
|
|
'boombot': GithubRepository(
|
|
|
|
'nod',
|
|
|
|
'boombot',
|
|
|
|
'plugins',
|
|
|
|
),
|
|
|
|
'mailed-notifier': GithubRepository(
|
|
|
|
'tbielawa',
|
|
|
|
'supybot-mailed-notifier',
|
|
|
|
),
|
|
|
|
'pingdom': GithubRepository(
|
|
|
|
'rynop',
|
|
|
|
'supyPingdom',
|
|
|
|
'plugins',
|
|
|
|
),
|
|
|
|
'scrum': GithubRepository(
|
|
|
|
'amscanne',
|
|
|
|
'supybot-scrum',
|
|
|
|
),
|
2012-06-29 17:04:37 +02:00
|
|
|
'Hoaas': GithubRepository(
|
|
|
|
'Hoaas',
|
|
|
|
'Supybot-plugins'
|
|
|
|
),
|
2012-07-09 13:08:51 +02:00
|
|
|
'nyuszika7h': GithubRepository(
|
2014-03-16 22:45:22 +01:00
|
|
|
'nyuszika7h',
|
|
|
|
'limnoria-plugins'
|
|
|
|
),
|
|
|
|
'nyuszika7h-old': GithubRepository(
|
2012-07-09 13:08:51 +02:00
|
|
|
'nyuszika7h',
|
|
|
|
'Supybot-plugins'
|
|
|
|
),
|
2012-09-16 14:40:01 +02:00
|
|
|
'resistivecorpse': GithubRepository(
|
|
|
|
'resistivecorpse',
|
|
|
|
'supybot-plugins'
|
|
|
|
),
|
2013-01-19 18:54:48 +01:00
|
|
|
'frumious': GithubRepository(
|
|
|
|
'frumiousbandersnatch',
|
|
|
|
'sobrieti-plugins',
|
|
|
|
'plugins',
|
|
|
|
),
|
2013-02-27 22:46:43 +01:00
|
|
|
'jonimoose': GithubRepository(
|
|
|
|
'Jonimoose',
|
|
|
|
'Supybot-plugins',
|
|
|
|
),
|
2013-11-19 19:18:28 +01:00
|
|
|
'skgsergio': GithubRepository(
|
|
|
|
'skgsergio',
|
|
|
|
'Limnoria-plugins',
|
|
|
|
),
|
2014-02-02 18:39:40 +01:00
|
|
|
'GLolol': GithubRepository(
|
|
|
|
'GLolol',
|
|
|
|
'SupyPlugins',
|
|
|
|
),
|
2017-08-25 04:13:06 +02:00
|
|
|
'GLolol-py2legacy': GithubRepository(
|
|
|
|
'GLolol',
|
|
|
|
'SupyPlugins',
|
|
|
|
branch='python2-legacy'
|
|
|
|
),
|
2014-04-03 18:01:41 +02:00
|
|
|
'Iota': GithubRepository(
|
2016-11-09 18:23:31 +01:00
|
|
|
'IotaSpencer',
|
2014-04-03 18:01:41 +02:00
|
|
|
'supyplugins',
|
|
|
|
),
|
2015-04-16 00:25:39 +02:00
|
|
|
'waratte': GithubRepository(
|
|
|
|
'waratte',
|
|
|
|
'supybot',
|
|
|
|
),
|
2015-05-18 15:24:18 +02:00
|
|
|
't3chguy': GithubRepository(
|
|
|
|
't3chguy',
|
|
|
|
'Limnoria-Plugins',
|
|
|
|
),
|
2015-05-19 21:50:40 +02:00
|
|
|
'prgmrbill': GithubRepository(
|
|
|
|
'prgmrbill',
|
|
|
|
'limnoria-plugins',
|
|
|
|
),
|
2016-11-28 10:24:56 +01:00
|
|
|
'fudster': GithubRepository(
|
|
|
|
'fudster',
|
|
|
|
'supybot-plugins',
|
|
|
|
),
|
|
|
|
|
2015-01-16 08:12:59 +01:00
|
|
|
})
|
2011-04-27 14:59:02 +02:00
|
|
|
|
|
|
|
class PluginDownloader(callbacks.Plugin):
|
2012-04-29 18:25:44 +02:00
|
|
|
"""This plugin allows you to install unofficial plugins from
|
|
|
|
multiple repositories easily. Use the "repolist" command to see list of
|
2017-08-25 03:58:09 +02:00
|
|
|
available repositories and "repolist <repository>" to list plugins,
|
2012-04-29 18:25:44 +02:00
|
|
|
which are available in that repository. When you want to install plugin,
|
|
|
|
just run command "install <repository> <plugin>"."""
|
2011-04-27 14:59:02 +02:00
|
|
|
|
2012-05-17 16:45:58 +02:00
|
|
|
threaded = True
|
|
|
|
|
2011-04-27 14:59:02 +02:00
|
|
|
@internationalizeDocstring
|
|
|
|
def repolist(self, irc, msg, args, repository):
|
|
|
|
"""[<repository>]
|
|
|
|
|
|
|
|
Displays the list of plugins in the <repository>.
|
|
|
|
If <repository> is not given, returns a list of available
|
|
|
|
repositories."""
|
|
|
|
|
|
|
|
global repositories
|
|
|
|
if repository is None:
|
2014-09-17 20:32:17 +02:00
|
|
|
irc.reply(_(', ').join(sorted(x for x in repositories)))
|
2011-04-28 11:38:48 +02:00
|
|
|
elif repository not in repositories:
|
|
|
|
irc.error(_(
|
|
|
|
'This repository does not exist or is not known by '
|
|
|
|
'this bot.'
|
|
|
|
))
|
2011-04-27 14:59:02 +02:00
|
|
|
else:
|
|
|
|
plugins = repositories[repository].getPluginList()
|
|
|
|
if plugins == []:
|
|
|
|
irc.error(_('No plugin found in this repository.'))
|
|
|
|
else:
|
|
|
|
irc.reply(_(', ').join([x for x in plugins]))
|
|
|
|
repolist = wrap(repolist, [optional('something')])
|
|
|
|
|
2011-04-28 11:38:48 +02:00
|
|
|
@internationalizeDocstring
|
|
|
|
def install(self, irc, msg, args, repository, plugin):
|
|
|
|
"""<repository> <plugin>
|
|
|
|
|
|
|
|
Downloads and installs the <plugin> from the <repository>."""
|
2017-09-24 21:11:21 +02:00
|
|
|
if not conf.supybot.commands.allowShell():
|
|
|
|
irc.error(_('This command is not available, because '
|
|
|
|
'supybot.commands.allowShell is False.'), Raise=True)
|
2011-04-28 11:38:48 +02:00
|
|
|
global repositories
|
|
|
|
if repository not in repositories:
|
|
|
|
irc.error(_(
|
|
|
|
'This repository does not exist or is not known by '
|
|
|
|
'this bot.'
|
|
|
|
))
|
2011-04-28 14:20:36 +02:00
|
|
|
elif plugin not in repositories[repository].getPluginList():
|
|
|
|
irc.error(_('This plugin does not exist in this repository.'))
|
2011-04-28 11:38:48 +02:00
|
|
|
else:
|
|
|
|
try:
|
2013-11-26 18:13:56 +01:00
|
|
|
irc.reply(repositories[repository].install(plugin))
|
2011-04-28 11:38:48 +02:00
|
|
|
except Exception as e:
|
2012-12-19 17:43:14 +01:00
|
|
|
import traceback
|
2013-11-10 17:48:09 +01:00
|
|
|
traceback.print_exc()
|
2011-04-28 11:38:48 +02:00
|
|
|
log.error(str(e))
|
2012-12-19 17:43:14 +01:00
|
|
|
irc.error('The plugin could not be installed. Check the logs '
|
|
|
|
'for a more detailed error.')
|
2011-04-28 11:38:48 +02:00
|
|
|
|
|
|
|
install = wrap(install, ['owner', 'something', 'something'])
|
|
|
|
|
2012-04-29 19:55:41 +02:00
|
|
|
@internationalizeDocstring
|
|
|
|
def info(self, irc, msg, args, repository, plugin):
|
|
|
|
"""<repository> <plugin>
|
|
|
|
|
|
|
|
Displays informations on the <plugin> in the <repository>."""
|
|
|
|
global repositories
|
|
|
|
if repository not in repositories:
|
|
|
|
irc.error(_(
|
|
|
|
'This repository does not exist or is not known by '
|
|
|
|
'this bot.'
|
|
|
|
))
|
|
|
|
elif plugin not in repositories[repository].getPluginList():
|
|
|
|
irc.error(_('This plugin does not exist in this repository.'))
|
|
|
|
else:
|
|
|
|
info = repositories[repository].getInfo(plugin)
|
|
|
|
if info is None:
|
2014-11-10 09:15:42 +01:00
|
|
|
irc.error(_('No README found for this plugin.'))
|
2012-04-29 19:55:41 +02:00
|
|
|
else:
|
|
|
|
if info.startswith('Insert a description of your plugin here'):
|
|
|
|
irc.error(_('This plugin has no description.'))
|
|
|
|
else:
|
|
|
|
info = info.split('\n\n')[0]
|
2013-11-10 17:46:30 +01:00
|
|
|
irc.reply(info.replace('\n', ' '))
|
2012-04-29 19:55:41 +02:00
|
|
|
info = wrap(info, ['something', optional('something')])
|
|
|
|
|
2011-04-27 14:59:02 +02:00
|
|
|
|
|
|
|
PluginDownloader = internationalizeDocstring(PluginDownloader)
|
|
|
|
Class = PluginDownloader
|
|
|
|
|
|
|
|
|
|
|
|
# vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79:
|