620 lines
21 KiB
Python
Executable File
620 lines
21 KiB
Python
Executable File
#! /usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
The core bot class. Say good bye to PYthon 2.
|
|
"""
|
|
import collections
|
|
import os
|
|
import re
|
|
import sys
|
|
import threading
|
|
import time
|
|
|
|
import tools
|
|
import irc
|
|
from db import SopelDB
|
|
from tools import stderr, Identifier
|
|
import tools.jobs
|
|
from trigger import Trigger
|
|
from module import NOLIMIT
|
|
from logger import get_logger
|
|
import loader
|
|
|
|
|
|
LOGGER = get_logger(__name__)
|
|
|
|
|
|
class _CapReq(object):
|
|
def __init__(self, prefix, module, failure=None, arg=None, success=None):
|
|
def nop(bot, cap):
|
|
pass
|
|
# TODO at some point, reorder those args to be sane
|
|
self.prefix = prefix
|
|
self.module = module
|
|
self.arg = arg
|
|
self.failure = failure or nop
|
|
self.success = success or nop
|
|
|
|
|
|
class Sopel(irc.Bot):
|
|
def __init__(self, config, daemon=False):
|
|
irc.Bot.__init__(self, config)
|
|
self._daemon = daemon # Used for iPython. TODO something saner here
|
|
# `re.compile('.*') is re.compile('.*')` because of caching, so we need
|
|
# to associate a list with each regex, since they are unexpectedly
|
|
# indistinct.
|
|
self._callables = {
|
|
'high': collections.defaultdict(list),
|
|
'medium': collections.defaultdict(list),
|
|
'low': collections.defaultdict(list)
|
|
}
|
|
self.config = config
|
|
"""The :class:`sopel.config.Config` for the current Sopel instance."""
|
|
self.doc = {}
|
|
"""
|
|
A dictionary of command names to their docstring and example, if
|
|
declared. The first item in a callable's commands list is used as the
|
|
key in version *3.2* onward. Prior to *3.2*, the name of the function
|
|
as declared in the source code was used.
|
|
"""
|
|
self._command_groups = collections.defaultdict(list)
|
|
"""A mapping of module names to a list of commands in it."""
|
|
self._times = {}
|
|
"""
|
|
A dictionary mapping lower-case'd nicks to dictionaries which map
|
|
funtion names to the time which they were last used by that nick.
|
|
"""
|
|
|
|
self.server_capabilities = {}
|
|
"""A dict mapping supported IRCv3 capabilities to their options.
|
|
|
|
For example, if the server specifies the capability ``sasl=EXTERNAL``,
|
|
it will be here as ``{"sasl": "EXTERNAL"}``. Capabilities specified
|
|
without any options will have ``None`` as the value.
|
|
|
|
For servers that do not support IRCv3, this will be an empty set."""
|
|
self.enabled_capabilities = set()
|
|
"""A set containing the IRCv3 capabilities that the bot has enabled."""
|
|
self._cap_reqs = dict()
|
|
"""A dictionary of capability names to a list of requests"""
|
|
|
|
self.privileges = dict()
|
|
"""A dictionary of channels to their users and privilege levels
|
|
|
|
The value associated with each channel is a dictionary of
|
|
:class:`sopel.tools.Identifier`\s to
|
|
a bitwise integer value, determined by combining the appropriate
|
|
constants from :mod:`sopel.module`.
|
|
|
|
.. deprecated:: 6.2.0
|
|
Use :attr:`channels` instead.
|
|
"""
|
|
|
|
self.channels = tools.SopelMemory() # name to chan obj
|
|
"""A map of the channels that Sopel is in.
|
|
|
|
The keys are Identifiers of the channel names, and map to
|
|
:class:`sopel.tools.target.Channel` objects which contain the users in
|
|
the channel and their permissions.
|
|
"""
|
|
self.users = tools.SopelMemory() # name to user obj
|
|
"""A map of the users that Sopel is aware of.
|
|
|
|
The keys are Identifiers of the nicknames, and map to
|
|
:class:`sopel.tools.target.User` instances. In order for Sopel to be
|
|
aware of a user, it must be in at least one channel which they are also
|
|
in.
|
|
"""
|
|
|
|
self.db = SopelDB(config)
|
|
"""The bot's database, as a :class:`sopel.db.SopelDB` instance."""
|
|
|
|
self.memory = tools.SopelMemory()
|
|
"""
|
|
A thread-safe dict for storage of runtime data to be shared between
|
|
modules. See :class:`sopel.tools.Sopel.SopelMemory`
|
|
"""
|
|
|
|
self.scheduler = tools.jobs.JobScheduler(self)
|
|
self.scheduler.start()
|
|
|
|
# Set up block lists
|
|
# Default to empty
|
|
if not self.config.core.nick_blocks:
|
|
self.config.core.nick_blocks = []
|
|
if not self.config.core.host_blocks:
|
|
self.config.core.host_blocks = []
|
|
self.setup()
|
|
|
|
# Backwards-compatibility aliases to attributes made private in 6.2. Remove
|
|
# these in 7.0
|
|
times = property(lambda self: getattr(self, '_times'))
|
|
command_groups = property(lambda self: getattr(self, '_command_groups'))
|
|
|
|
def write(self, args, text=None): # Shim this in here for autodocs
|
|
"""Send a command to the server.
|
|
|
|
``args`` is an iterable of strings, which are joined by spaces.
|
|
``text`` is treated as though it were the final item in ``args``, but
|
|
is preceeded by a ``:``. This is a special case which means that
|
|
``text``, unlike the items in ``args`` may contain spaces (though this
|
|
constraint is not checked by ``write``).
|
|
|
|
In other words, both ``sopel.write(('PRIVMSG',), 'Hello, world!')``
|
|
and ``sopel.write(('PRIVMSG', ':Hello, world!'))`` will send
|
|
``PRIVMSG :Hello, world!`` to the server.
|
|
|
|
Newlines and carriage returns ('\\n' and '\\r') are removed before
|
|
sending. Additionally, if the message (after joining) is longer than
|
|
than 510 characters, any remaining characters will not be sent.
|
|
"""
|
|
irc.Bot.write(self, args, text=text)
|
|
|
|
def setup(self):
|
|
stderr("\nWelcome to Sopel. Loading modules...\n\n")
|
|
|
|
modules = loader.enumerate_modules(self.config)
|
|
|
|
error_count = 0
|
|
success_count = 0
|
|
for name in modules:
|
|
path, type_ = modules[name]
|
|
|
|
try:
|
|
module, _ = loader.load_module(name, path, type_)
|
|
except Exception as e:
|
|
error_count = error_count + 1
|
|
filename, lineno = tools.get_raising_file_and_line()
|
|
rel_path = os.path.relpath(filename, os.path.dirname(__file__))
|
|
raising_stmt = "%s:%d" % (rel_path, lineno)
|
|
stderr("Error loading %s: %s (%s)" % (name, e, raising_stmt))
|
|
else:
|
|
try:
|
|
if hasattr(module, 'setup'):
|
|
module.setup(self)
|
|
relevant_parts = loader.clean_module(
|
|
module, self.config)
|
|
except Exception as e:
|
|
error_count = error_count + 1
|
|
filename, lineno = tools.get_raising_file_and_line()
|
|
rel_path = os.path.relpath(
|
|
filename, os.path.dirname(__file__)
|
|
)
|
|
raising_stmt = "%s:%d" % (rel_path, lineno)
|
|
stderr("Error in %s setup procedure: %s (%s)"
|
|
% (name, e, raising_stmt))
|
|
else:
|
|
self.register(*relevant_parts)
|
|
success_count += 1
|
|
|
|
if len(modules) > 1: # coretasks is counted
|
|
stderr('\n\nRegistered %d modules,' % (success_count - 1))
|
|
stderr('%d modules failed to load\n\n' % error_count)
|
|
else:
|
|
stderr("Warning: Couldn't load any modules")
|
|
|
|
def unregister(self, obj):
|
|
if not callable(obj):
|
|
return
|
|
if hasattr(obj, 'rule'): # commands and intents have it added
|
|
for rule in obj.rule:
|
|
callb_list = self._callables[obj.priority][rule]
|
|
if obj in callb_list:
|
|
callb_list.remove(obj)
|
|
if hasattr(obj, 'interval'):
|
|
# TODO this should somehow find the right job to remove, rather than
|
|
# clearing the entire queue. Issue #831
|
|
self.scheduler.clear_jobs()
|
|
if (getattr(obj, '__name__', None) == 'shutdown'
|
|
and obj in self.shutdown_methods):
|
|
self.shutdown_methods.remove(obj)
|
|
|
|
def register(self, callables, jobs, shutdowns, urls):
|
|
self.shutdown_methods = shutdowns
|
|
for callbl in callables:
|
|
for rule in callbl.rule:
|
|
self._callables[callbl.priority][rule].append(callbl)
|
|
if hasattr(callbl, 'commands'):
|
|
module_name = callbl.__module__.rsplit('.', 1)[-1]
|
|
# TODO doc and make decorator for this. Not sure if this is how
|
|
# it should work yet, so not making it public for 6.0.
|
|
category = getattr(callbl, 'category', module_name)
|
|
self._command_groups[category].append(callbl.commands[0])
|
|
for command, docs in callbl._docs.items():
|
|
self.doc[command] = docs
|
|
for func in jobs:
|
|
for interval in func.interval:
|
|
job = tools.jobs.Job(interval, func)
|
|
self.scheduler.add_job(job)
|
|
|
|
if not self.memory.contains('url_callbacks'):
|
|
self.memory['url_callbacks'] = tools.SopelMemory()
|
|
for func in urls:
|
|
self.memory['url_callbacks'][func.url_regex] = func
|
|
|
|
def part(self, channel, msg=None):
|
|
"""Part a channel."""
|
|
self.write(['PART', channel], msg)
|
|
|
|
def join(self, channel, password=None):
|
|
"""Join a channel
|
|
|
|
If `channel` contains a space, and no `password` is given, the space is
|
|
assumed to split the argument into the channel to join and its
|
|
password. `channel` should not contain a space if `password` is given.
|
|
|
|
"""
|
|
if password is None:
|
|
self.write(('JOIN', channel))
|
|
else:
|
|
self.write(['JOIN', channel, password])
|
|
|
|
def say(self, text, recipient, max_messages=1):
|
|
"""Send ``text`` as a PRIVMSG to ``recipient``.
|
|
|
|
In the context of a triggered callable, the ``recipient`` defaults to
|
|
the channel (or nickname, if a private message) from which the message
|
|
was received.
|
|
|
|
By default, this will attempt to send the entire ``text`` in one
|
|
message. If the text is too long for the server, it may be truncated.
|
|
If ``max_messages`` is given, the ``text`` will be split into at most
|
|
that many messages, each no more than 400 bytes. The split is made at
|
|
the last space character before the 400th byte, or at the 400th byte if
|
|
no such space exists. If the ``text`` is too long to fit into the
|
|
specified number of messages using the above splitting, the final
|
|
message will contain the entire remainder, which may be truncated by
|
|
the server.
|
|
"""
|
|
# We're arbitrarily saying that the max is 400 bytes of text when
|
|
# messages will be split. Otherwise, we'd have to acocunt for the bot's
|
|
# hostmask, which is hard.
|
|
max_text_length = 400
|
|
max_messages=1000
|
|
# Encode to bytes, for propper length calculation
|
|
if isinstance(text, str):
|
|
encoded_text = text.encode('utf-8')
|
|
else:
|
|
encoded_text = text
|
|
excess = ''
|
|
if b'\n' in encoded_text:
|
|
excess = encoded_text.split(b"\n", 1)[1]
|
|
encoded_text = encoded_text.split(b"\n", 1)[0]
|
|
|
|
if max_messages > 1 and len(encoded_text) > max_text_length:
|
|
last_space = encoded_text.rfind(' '.encode('utf-8'), 0, max_text_length)
|
|
if last_space == -1:
|
|
excess = encoded_text[max_text_length:]
|
|
encoded_text = encoded_text[:max_text_length]
|
|
else:
|
|
excess = encoded_text[last_space + 1:]
|
|
encoded_text = encoded_text[:last_space]
|
|
# We'll then send the excess at the end
|
|
# Back to str again, so we don't screw things up later.
|
|
text = encoded_text.decode('utf-8')
|
|
try:
|
|
self.sending.acquire()
|
|
|
|
# No messages within the last 3 seconds? Go ahead!
|
|
# Otherwise, wait so it's been at least 0.8 seconds + penalty
|
|
|
|
recipient_id = Identifier(recipient)
|
|
|
|
if recipient_id not in self.stack:
|
|
self.stack[recipient_id] = []
|
|
|
|
self.write(('PRIVMSG', recipient), text)
|
|
self.stack[recipient_id].append((time.time(), self.safe(text)))
|
|
self.stack[recipient_id] = self.stack[recipient_id][-10:]
|
|
finally:
|
|
self.sending.release()
|
|
# Now that we've sent the first part, we need to send the rest. Doing
|
|
# this recursively seems easier to me than iteratively
|
|
if excess:
|
|
self.say(recipient, excess, max_messages - 1)
|
|
|
|
def notice(self, text, dest):
|
|
"""Send an IRC NOTICE to a user or a channel.
|
|
|
|
Within the context of a triggered callable, ``dest`` will default to
|
|
the channel (or nickname, if a private message), in which the trigger
|
|
happened.
|
|
"""
|
|
self.write(('NOTICE', dest), text)
|
|
|
|
def action(self, text, dest):
|
|
"""Send ``text`` as a CTCP ACTION PRIVMSG to ``dest``.
|
|
|
|
The same loop detection and length restrictions apply as with
|
|
:func:`say`, though automatic message splitting is not available.
|
|
|
|
Within the context of a triggered callable, ``dest`` will default to
|
|
the channel (or nickname, if a private message), in which the trigger
|
|
happened.
|
|
"""
|
|
self.say('\001ACTION {}\001'.format(text), dest)
|
|
|
|
def reply(self, text, dest, reply_to, notice=False):
|
|
"""Prepend ``reply_to`` to ``text``, and send as a PRIVMSG to ``dest``.
|
|
|
|
If ``notice`` is ``True``, send a NOTICE rather than a PRIVMSG.
|
|
|
|
The same loop detection and length restrictions apply as with
|
|
:func:`say`, though automatic message splitting is not available.
|
|
|
|
Within the context of a triggered callable, ``reply_to`` will default to
|
|
the nickname of the user who triggered the call, and ``dest`` to the
|
|
channel (or nickname, if a private message), in which the trigger
|
|
happened.
|
|
"""
|
|
text = '%s: %s' % (reply_to, text)
|
|
if notice:
|
|
self.notice(text, dest)
|
|
else:
|
|
self.say(text, dest)
|
|
|
|
class SopelWrapper(object):
|
|
def __init__(self, sopel, trigger):
|
|
# The custom __setattr__ for this class sets the attribute on the
|
|
# original bot object. We don't want that for these, so we set them
|
|
# with the normal __setattr__.
|
|
object.__setattr__(self, '_bot', sopel)
|
|
object.__setattr__(self, '_trigger', trigger)
|
|
|
|
def __dir__(self):
|
|
classattrs = [attr for attr in self.__class__.__dict__
|
|
if not attr.startswith('__')]
|
|
return list(self.__dict__) + classattrs + dir(self._bot)
|
|
|
|
def __getattr__(self, attr):
|
|
return getattr(self._bot, attr)
|
|
|
|
def __setattr__(self, attr, value):
|
|
return setattr(self._bot, attr, value)
|
|
|
|
def say(self, message, destination=None, max_messages=1):
|
|
if destination is None:
|
|
destination = self._trigger.sender
|
|
self._bot.say(message, destination, max_messages)
|
|
|
|
def action(self, message, destination=None):
|
|
if destination is None:
|
|
destination = self._trigger.sender
|
|
self._bot.action(message, destination)
|
|
|
|
def notice(self, message, destination=None):
|
|
if destination is None:
|
|
destination = self._trigger.sender
|
|
self._bot.notice(message, destination)
|
|
|
|
def reply(self, message, destination=None, reply_to=None, notice=False):
|
|
if destination is None:
|
|
destination = self._trigger.sender
|
|
if reply_to is None:
|
|
reply_to = self._trigger.nick
|
|
self._bot.reply(message, destination, reply_to, notice)
|
|
|
|
def call(self, func, sopel, trigger):
|
|
nick = trigger.nick
|
|
current_time = time.time()
|
|
if nick not in self._times:
|
|
self._times[nick] = dict()
|
|
if self.nick not in self._times:
|
|
self._times[self.nick] = dict()
|
|
if not trigger.is_privmsg and trigger.sender not in self._times:
|
|
self._times[trigger.sender] = dict()
|
|
|
|
if not trigger.admin and not func.unblockable:
|
|
if func in self._times[nick]:
|
|
usertimediff = current_time - self._times[nick][func]
|
|
if func.rate > 0 and usertimediff < func.rate:
|
|
#self._times[nick][func] = current_time
|
|
LOGGER.info(
|
|
"%s prevented from using %s in %s due to user limit: %d < %d",
|
|
trigger.nick, func.__name__, trigger.sender, usertimediff,
|
|
func.rate
|
|
)
|
|
return
|
|
if func in self._times[self.nick]:
|
|
globaltimediff = current_time - self._times[self.nick][func]
|
|
if func.global_rate > 0 and globaltimediff < func.global_rate:
|
|
#self._times[self.nick][func] = current_time
|
|
LOGGER.info(
|
|
"%s prevented from using %s in %s due to global limit: %d < %d",
|
|
trigger.nick, func.__name__, trigger.sender, globaltimediff,
|
|
func.global_rate
|
|
)
|
|
return
|
|
|
|
if not trigger.is_privmsg and func in self._times[trigger.sender]:
|
|
chantimediff = current_time - self._times[trigger.sender][func]
|
|
if func.channel_rate > 0 and chantimediff < func.channel_rate:
|
|
#self._times[trigger.sender][func] = current_time
|
|
LOGGER.info(
|
|
"%s prevented from using %s in %s due to channel limit: %d < %d",
|
|
trigger.nick, func.__name__, trigger.sender, chantimediff,
|
|
func.channel_rate
|
|
)
|
|
return
|
|
|
|
try:
|
|
exit_code = func(sopel, trigger)
|
|
except Exception:
|
|
exit_code = None
|
|
self.error(trigger)
|
|
|
|
if exit_code != NOLIMIT:
|
|
self._times[nick][func] = current_time
|
|
self._times[self.nick][func] = current_time
|
|
if not trigger.is_privmsg:
|
|
self._times[trigger.sender][func] = current_time
|
|
|
|
def dispatch(self, pretrigger):
|
|
args = pretrigger.args
|
|
event, args, text = pretrigger.event, args, args[-1] if args else ''
|
|
|
|
if self.config.core.nick_blocks or self.config.core.host_blocks:
|
|
nick_blocked = self._nick_blocked(pretrigger.nick)
|
|
host_blocked = self._host_blocked(pretrigger.host)
|
|
else:
|
|
nick_blocked = host_blocked = None
|
|
|
|
list_of_blocked_functions = []
|
|
for priority in ('high', 'medium', 'low'):
|
|
items = self._callables[priority].items()
|
|
|
|
for regexp, funcs in items:
|
|
match = regexp.match(text)
|
|
if not match:
|
|
continue
|
|
user_obj = self.users.get(pretrigger.nick)
|
|
account = user_obj.account if user_obj else None
|
|
trigger = Trigger(self.config, pretrigger, match, account)
|
|
wrapper = self.SopelWrapper(self, trigger)
|
|
|
|
for func in funcs:
|
|
if (not trigger.admin and
|
|
not func.unblockable and
|
|
(nick_blocked or host_blocked)):
|
|
function_name = "%s.%s" % (
|
|
func.__module__, func.__name__
|
|
)
|
|
list_of_blocked_functions.append(function_name)
|
|
continue
|
|
|
|
if event not in func.event:
|
|
continue
|
|
if (hasattr(func, 'intents') and
|
|
trigger.tags.get('intent') not in func.intents):
|
|
continue
|
|
if func.thread:
|
|
targs = (func, wrapper, trigger)
|
|
t = threading.Thread(target=self.call, args=targs)
|
|
t.start()
|
|
else:
|
|
self.call(func, wrapper, trigger)
|
|
|
|
if list_of_blocked_functions:
|
|
if nick_blocked and host_blocked:
|
|
block_type = 'both'
|
|
elif nick_blocked:
|
|
block_type = 'nick'
|
|
else:
|
|
block_type = 'host'
|
|
LOGGER.info(
|
|
"[%s]%s prevented from using %s.",
|
|
block_type,
|
|
trigger.nick,
|
|
', '.join(list_of_blocked_functions)
|
|
)
|
|
|
|
def _host_blocked(self, host):
|
|
bad_masks = self.config.core.host_blocks
|
|
for bad_mask in bad_masks:
|
|
bad_mask = bad_mask.strip()
|
|
if not bad_mask:
|
|
continue
|
|
if (re.match(bad_mask + '$', host, re.IGNORECASE) or
|
|
bad_mask == host):
|
|
return True
|
|
return False
|
|
|
|
def _nick_blocked(self, nick):
|
|
bad_nicks = self.config.core.nick_blocks
|
|
for bad_nick in bad_nicks:
|
|
bad_nick = bad_nick.strip()
|
|
if not bad_nick:
|
|
continue
|
|
if (re.match(bad_nick + '$', nick, re.IGNORECASE) or
|
|
Identifier(bad_nick) == nick):
|
|
return True
|
|
return False
|
|
|
|
def _shutdown(self):
|
|
stderr(
|
|
'Calling shutdown for %d modules.' % (len(self.shutdown_methods),)
|
|
)
|
|
|
|
for shutdown_method in self.shutdown_methods:
|
|
try:
|
|
stderr(
|
|
"calling %s.%s" % (
|
|
shutdown_method.__module__, shutdown_method.__name__,
|
|
)
|
|
)
|
|
shutdown_method(self)
|
|
except Exception as e:
|
|
stderr(
|
|
"Error calling shutdown method for module %s:%s" % (
|
|
shutdown_method.__module__, e
|
|
)
|
|
)
|
|
|
|
def cap_req(self, module_name, capability, arg=None, failure_callback=None,
|
|
success_callback=None):
|
|
"""Tell Sopel to request a capability when it starts.
|
|
|
|
By prefixing the capability with `-`, it will be ensured that the
|
|
capability is not enabled. Simmilarly, by prefixing the capability with
|
|
`=`, it will be ensured that the capability is enabled. Requiring and
|
|
disabling is "first come, first served"; if one module requires a
|
|
capability, and another prohibits it, this function will raise an
|
|
exception in whichever module loads second. An exception will also be
|
|
raised if the module is being loaded after the bot has already started,
|
|
and the request would change the set of enabled capabilities.
|
|
|
|
If the capability is not prefixed, and no other module prohibits it, it
|
|
will be requested. Otherwise, it will not be requested. Since
|
|
capability requests that are not mandatory may be rejected by the
|
|
server, as well as by other modules, a module which makes such a
|
|
request should account for that possibility.
|
|
|
|
The actual capability request to the server is handled after the
|
|
completion of this function. In the event that the server denies a
|
|
request, the `failure_callback` function will be called, if provided.
|
|
The arguments will be a `Sopel` object, and the capability which was
|
|
rejected. This can be used to disable callables which rely on the
|
|
capability. It will be be called either if the server NAKs the request,
|
|
or if the server enabled it and later DELs it.
|
|
|
|
The `success_callback` function will be called upon acknowledgement of
|
|
the capability from the server, whether during the initial capability
|
|
negotiation, or later.
|
|
|
|
If ``arg`` is given, and does not exactly match what the server
|
|
provides or what other modules have requested for that capability, it is
|
|
considered a conflict.
|
|
"""
|
|
# TODO raise better exceptions
|
|
cap = capability[1:]
|
|
prefix = capability[0]
|
|
|
|
entry = self._cap_reqs.get(cap, [])
|
|
if any((ent.arg != arg for ent in entry)):
|
|
raise Exception('Capability conflict')
|
|
|
|
if prefix == '-':
|
|
if self.connection_registered and cap in self.enabled_capabilities:
|
|
raise Exception('Can not change capabilities after server '
|
|
'connection has been completed.')
|
|
if any((ent.prefix != '-' for ent in entry)):
|
|
raise Exception('Capability conflict')
|
|
entry.append(_CapReq(prefix, module_name, failure_callback, arg,
|
|
success_callback))
|
|
self._cap_reqs[cap] = entry
|
|
else:
|
|
if prefix != '=':
|
|
cap = capability
|
|
prefix = ''
|
|
if self.connection_registered and (cap not in
|
|
self.enabled_capabilities):
|
|
raise Exception('Can not change capabilities after server '
|
|
'connection has been completed.')
|
|
# Non-mandatory will callback at the same time as if the server
|
|
# rejected it.
|
|
if any((ent.prefix == '-' for ent in entry)) and prefix == '=':
|
|
raise Exception('Capability conflict')
|
|
entry.append(_CapReq(prefix, module_name, failure_callback, arg,
|
|
success_callback))
|
|
self._cap_reqs[cap] = entry
|