sopel.tools.SopelMemoryWithDefault.__init__()   A
last analyzed

Complexity

Conditions 1

Size

Total Lines 3
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 3
dl 0
loc 3
rs 10
c 0
b 0
f 0
cc 1
nop 2
1
# coding=utf-8
2
"""Useful miscellaneous tools and shortcuts for Sopel modules
3
4
*Availability: 3+*
5
"""
6
7
# tools.py - Sopel misc tools
8
# Copyright 2008, Sean B. Palmer, inamidst.com
9
# Copyright © 2012, Elad Alfassa <[email protected]>
10
# Copyright 2012, Elsie Powell, embolalia.com
11
# Licensed under the Eiffel Forum License 2.
12
13
# https://sopel.chat
14
15
from __future__ import unicode_literals, absolute_import, print_function, division
16
17
import codecs
18
import functools
19
import logging
20
import os
21
import re
22
import sys
23
import threading
24
import traceback
25
from collections import defaultdict
26
27
from sopel.tools._events import events  # NOQA
28
29
if sys.version_info.major >= 3:
30
    raw_input = input
31
    unicode = str
32
    iteritems = dict.items
33
    itervalues = dict.values
34
    iterkeys = dict.keys
35
else:
36
    iteritems = dict.iteritems
37
    itervalues = dict.itervalues
38
    iterkeys = dict.iterkeys
39
40
_channel_prefixes = ('#', '&', '+', '!')
41
42
# Can be implementation-dependent
43
_regex_type = type(re.compile(''))
44
45
46
def get_input(prompt):
47
    """Get decoded input from the terminal (equivalent to Python 3's ``input``).
48
49
    :param str prompt: what to display as a prompt on the terminal
50
    :return: the user's input
51
    :rtype: str
52
    """
53
    if sys.version_info.major >= 3:
54
        return input(prompt)
55
    else:
56
        return raw_input(prompt).decode('utf8')
0 ignored issues
show
introduced by
The variable raw_input does not seem to be defined for all execution paths.
Loading history...
57
58
59
def compile_rule(nick, pattern, alias_nicks):
60
    """Compile a rule regex and fill in nickname placeholders.
61
62
    :param str nick: the nickname to use when replacing ``$nick`` and
63
                     ``$nickname`` placeholders in the ``pattern``
64
    :param str pattern: the rule regex pattern
65
    :param list alias_nicks: a list of alternatives that should also be accepted
66
                             instead of ``nick``
67
    :return: the compiled regex ``pattern``, with placeholders for ``$nick`` and
68
             ``$nickname`` filled in
69
    :rtype: :py:class:`re.Pattern`
70
71
    Will not recompile an already compiled pattern.
72
    """
73
    # Not sure why this happens on reloads, but it shouldn't cause problems…
74
    if isinstance(pattern, _regex_type):
75
        return pattern
76
77
    if alias_nicks:
78
        nicks = list(alias_nicks)  # alias_nicks.copy() doesn't work in py2
79
        nicks.append(nick)
80
        nicks = map(re.escape, nicks)
81
        nick = '(?:%s)' % '|'.join(nicks)
82
    else:
83
        nick = re.escape(nick)
84
85
    pattern = pattern.replace('$nickname', nick)
86
    pattern = pattern.replace('$nick', r'{}[,:]\s+'.format(nick))
87
    flags = re.IGNORECASE
88
    if '\n' in pattern:
89
        flags |= re.VERBOSE
90
    return re.compile(pattern, flags)
91
92
93
def get_command_regexp(prefix, command):
94
    """Get a compiled regexp object that implements the command.
95
96
    :param str prefix: the command prefix (interpreted as regex)
97
    :param str command: the name of the command
98
    :return: a compiled regexp object that implements the command
99
    :rtype: :py:class:`re.Pattern`
100
    """
101
    # Escape all whitespace with a single backslash. This ensures that regexp
102
    # in the prefix is treated as it was before the actual regexp was changed
103
    # to use the verbose syntax.
104
    prefix = re.sub(r"(\s)", r"\\\1", prefix)
105
106
    pattern = get_command_pattern(prefix, command)
107
    return re.compile(pattern, re.IGNORECASE | re.VERBOSE)
108
109
110
def get_command_pattern(prefix, command):
111
    """Get the uncompiled regex pattern for standard commands.
112
113
    :param str prefix: the command prefix (interpreted as regex)
114
    :param str command: the command name
115
    :return: a regex pattern that will match the given command
116
    :rtype: str
117
    """
118
    # This regexp matches equivalently and produces the same
119
    # groups 1 and 2 as the old regexp: r'^%s(%s)(?: +(.*))?$'
120
    # The only differences should be handling all whitespace
121
    # like spaces and the addition of groups 3-6.
122
    return r"""
123
        (?:{prefix})({command}) # Command as group 1.
124
        (?:\s+              # Whitespace to end command.
125
        (                   # Rest of the line as group 2.
126
        (?:(\S+))?          # Parameters 1-4 as groups 3-6.
127
        (?:\s+(\S+))?
128
        (?:\s+(\S+))?
129
        (?:\s+(\S+))?
130
        .*                  # Accept anything after the parameters.
131
                            # Leave it up to the module to parse
132
                            # the line.
133
        ))?                 # Group 2 must be None, if there are no
134
                            # parameters.
135
        $                   # EoL, so there are no partial matches.
136
        """.format(prefix=prefix, command=command)
137
138
139
def get_nickname_command_regexp(nick, command, alias_nicks):
140
    """Get a compiled regexp object that implements the nickname command.
141
142
    :param str nick: the bot's nickname
143
    :param str command: the command name
144
    :param list alias_nicks: a list of alternatives that should also be accepted
145
                             instead of ``nick``
146
    :return: a compiled regex pattern that implements the given nickname command
147
    :rtype: :py:class:`re.Pattern`
148
    """
149
    if isinstance(alias_nicks, unicode):
150
        alias_nicks = [alias_nicks]
151
    elif not isinstance(alias_nicks, list):
152
        raise ValueError('A list or string is required.')
153
154
    return compile_rule(nick, get_nickname_command_pattern(command), alias_nicks)
155
156
157
def get_nickname_command_pattern(command):
158
    """Get the uncompiled regex pattern for a nickname command.
159
160
    :param str command: the command name
161
    :return: a regex pattern that will match the given nickname command
162
    :rtype: str
163
    """
164
    return r"""
165
        ^
166
        $nickname[:,]? # Nickname.
167
        \s+({command}) # Command as group 1.
168
        (?:\s+         # Whitespace to end command.
169
        (              # Rest of the line as group 2.
170
        (?:(\S+))?     # Parameters 1-4 as groups 3-6.
171
        (?:\s+(\S+))?
172
        (?:\s+(\S+))?
173
        (?:\s+(\S+))?
174
        .*             # Accept anything after the parameters. Leave it up to
175
                       # the module to parse the line.
176
        ))?            # Group 1 must be None, if there are no parameters.
177
        $              # EoL, so there are no partial matches.
178
        """.format(command=command)
179
180
181
def get_action_command_regexp(command):
182
    """Get a compiled regexp object that implements the command.
183
184
    :param str command: the name of the command
185
    :return: a compiled regexp object that implements the command
186
    :rtype: :py:class:`re.Pattern`
187
    """
188
    pattern = get_action_command_pattern(command)
189
    return re.compile(pattern, re.IGNORECASE | re.VERBOSE)
190
191
192
def get_action_command_pattern(command):
193
    """Get the uncompiled regex pattern for action commands.
194
195
    :param str command: the command name
196
    :return: a regex pattern that will match the given command
197
    :rtype: str
198
    """
199
    # This regexp matches equivalently and produces the same
200
    # groups 1 and 2 as the old regexp: r'^%s(%s)(?: +(.*))?$'
201
    # The only differences should be handling all whitespace
202
    # like spaces and the addition of groups 3-6.
203
    return r"""
204
        ({command}) # Command as group 1.
205
        (?:\s+              # Whitespace to end command.
206
        (                   # Rest of the line as group 2.
207
        (?:(\S+))?          # Parameters 1-4 as groups 3-6.
208
        (?:\s+(\S+))?
209
        (?:\s+(\S+))?
210
        (?:\s+(\S+))?
211
        .*                  # Accept anything after the parameters.
212
                            # Leave it up to the module to parse
213
                            # the line.
214
        ))?                 # Group 2 must be None, if there are no
215
                            # parameters.
216
        $                   # EoL, so there are no partial matches.
217
        """.format(command=command)
218
219
220
def get_sendable_message(text, max_length=400):
221
    """Get a sendable ``text`` message, with its excess when needed.
222
223
    :param str txt: text to send (expects Unicode-encoded string)
224
    :param int max_length: maximum length of the message to be sendable
225
    :return: a tuple of two values, the sendable text and its excess text
226
    :rtype: (str, str)
227
228
    We're arbitrarily saying that the max is 400 bytes of text when
229
    messages will be split. Otherwise, we'd have to account for the bot's
230
    hostmask, which is hard.
231
232
    The ``max_length`` is the max length of text in **bytes**, but we take
233
    care of Unicode 2-byte characters by working on the Unicode string,
234
    then making sure the bytes version is smaller than the max length.
235
    """
236
    unicode_max_length = max_length
237
    excess = ''
238
239
    while len(text.encode('utf-8')) > max_length:
240
        last_space = text.rfind(' ', 0, unicode_max_length)
241
        if last_space == -1:
242
            # No last space, just split where it is possible
243
            excess = text[unicode_max_length:] + excess
244
            text = text[:unicode_max_length]
245
            # Decrease max length for the unicode string
246
            unicode_max_length = unicode_max_length - 1
247
        else:
248
            # Split at the last best space found
249
            excess = text[last_space:]
250
            text = text[:last_space]
251
252
    return text, excess.lstrip()
253
254
255
def deprecated(reason=None, version=None, removed_in=None, func=None):
256
    """Decorator to mark deprecated functions in Sopel's API
257
258
    :param str reason: optional text added to the deprecation warning
259
    :param str version: optional version number when the decorated function
260
                        is deprecated
261
    :param str removed_in: optional version number when the deprecated function
262
                           will be removed
263
    :param callable func: deprecated function
264
    :return: a callable that depends on how the decorator is called; either
265
             the decorated function, or a decorator with the appropriate
266
             parameters
267
268
    Any time the decorated ``func`` is called, a deprecation warning will be
269
    printed to ``sys.stderr``, with the last frame of the traceback.
270
271
    It can be used with or without arguments::
272
273
        from sopel import tools
274
275
        @tools.deprecated
276
        def func1():
277
            print('func 1')
278
279
        @tools.deprecated()
280
        def func2():
281
            print('func 2')
282
283
        @tools.deprecated(reason='obsolete', version='7.0', removed_in='8.0')
284
        def func3():
285
            print('func 3')
286
287
    which will output the following in a console::
288
289
        >>> func1()
290
        Deprecated: func1
291
        File "<stdin>", line 1, in <module>
292
        func 1
293
        >>> func2()
294
        Deprecated: func2
295
        File "<stdin>", line 1, in <module>
296
        func 2
297
        >>> func3()
298
        Deprecated since 7.0, will be removed in 8.0: obsolete
299
        File "<stdin>", line 1, in <module>
300
        func 3
301
302
    .. note::
303
304
        There is nothing that prevents this decorator to be used on a class's
305
        method, or on any existing callable.
306
307
    .. versionadded:: 7.0
308
        Parameters ``reason``, ``version``, and ``removed_in``.
309
    """
310
    if not any([reason, version, removed_in, func]):
311
        # common usage: @deprecated()
312
        return deprecated
313
314
    if callable(reason):
315
        # common usage: @deprecated
316
        return deprecated(func=reason)
317
318
    if func is None:
319
        # common usage: @deprecated(message, version, removed_in)
320
        def decorator(func):
321
            return deprecated(reason, version, removed_in, func)
322
        return decorator
323
324
    # now, we have everything we need to have:
325
    # - message is not a callable (could be None)
326
    # - func is not None
327
    # - version and removed_in can be None but that's OK
328
    # so now we can return the actual decorated function
329
330
    message = reason or getattr(func, '__name__', '<anonymous-function>')
331
332
    template = 'Deprecated: {message}'
333
    if version and removed_in:
334
        template = (
335
            'Deprecated since {version}, '
336
            'will be removed in {removed_in}: '
337
            '{message}')
338
    elif version:
339
        template = 'Deprecated since {version}: {message}'
340
    elif removed_in:
341
        template = 'Deprecated, will be removed in {removed_in}: {message}'
342
343
    text = template.format(
344
        message=message, version=version, removed_in=removed_in)
345
346
    @functools.wraps(func)
347
    def deprecated_func(*args, **kwargs):
348
        stderr(text)
349
        # Only display the last frame
350
        trace = traceback.extract_stack()
351
        stderr(traceback.format_list(trace[:-1])[-1][:-1])
352
        return func(*args, **kwargs)
353
354
    return deprecated_func
355
356
357
# This class was useful before Python 2.5, when ``defaultdict`` was added
358
# to the built-in ``collections`` module.
359
# It is now deprecated.
360
class Ddict(dict):
361
    """A default dict implementation available for Python 2.x support.
362
363
    It was used to make multi-dimensional ``dict``\\s easy to use when the
364
    bot worked with Python version < 2.5.
365
366
    .. deprecated:: 7.0
367
        Use :class:`collections.defaultdict` instead.
368
    """
369
    @deprecated('use "collections.defaultdict" instead', '7.0', '8.0')
370
    def __init__(self, default=None):
371
        self.default = default
372
373
    def __getitem__(self, key):
374
        if key not in self:
375
            self[key] = self.default()
376
        return dict.__getitem__(self, key)
377
378
379
class Identifier(unicode):
380
    """A `unicode` subclass which acts appropriately for IRC identifiers.
381
382
    When used as normal `unicode` objects, case will be preserved.
383
    However, when comparing two Identifier objects, or comparing a Identifier
384
    object with a `unicode` object, the comparison will be case insensitive.
385
    This case insensitivity includes the case convention conventions regarding
386
    ``[]``, ``{}``, ``|``, ``\\``, ``^`` and ``~`` described in RFC 2812.
387
    """
388
    # May want to tweak this and update documentation accordingly when dropping
389
    # Python 2 support, since in py3 plain str is Unicode and a "unicode" type
390
    # no longer exists. Probably lots of code will need tweaking, tbh.
391
392
    def __new__(cls, identifier):
393
        # According to RFC2812, identifiers have to be in the ASCII range.
394
        # However, I think it's best to let the IRCd determine that, and we'll
395
        # just assume unicode. It won't hurt anything, and is more internally
396
        # consistent. And who knows, maybe there's another use case for this
397
        # weird case convention.
398
        s = unicode.__new__(cls, identifier)
399
        s._lowered = Identifier._lower(identifier)
400
        return s
401
402
    def lower(self):
403
        """Get the RFC 2812-compliant lowercase version of this identifier.
404
405
        :return: RFC 2812-compliant lowercase version of the
406
                 :py:class:`Identifier` instance
407
        :rtype: str
408
        """
409
        return self._lowered
410
411
    @staticmethod
412
    def _lower(identifier):
413
        """Convert an identifier to lowercase per RFC 2812.
414
415
        :param str identifier: the identifier (nickname or channel) to convert
416
        :return: RFC 2812-compliant lowercase version of ``identifier``
417
        :rtype: str
418
        """
419
        if isinstance(identifier, Identifier):
420
            return identifier._lowered
421
        # The tilde replacement isn't needed for identifiers, but is for
422
        # channels, which may be useful at some point in the future.
423
        low = identifier.lower().replace('{', '[').replace('}', ']')
424
        low = low.replace('|', '\\').replace('^', '~')
425
        return low
426
427
    def __repr__(self):
428
        return "%s(%r)" % (
429
            self.__class__.__name__,
430
            self.__str__()
431
        )
432
433
    def __hash__(self):
434
        return self._lowered.__hash__()
435
436
    def __lt__(self, other):
437
        if isinstance(other, unicode):
438
            other = Identifier._lower(other)
439
        return unicode.__lt__(self._lowered, other)
440
441
    def __le__(self, other):
442
        if isinstance(other, unicode):
443
            other = Identifier._lower(other)
444
        return unicode.__le__(self._lowered, other)
445
446
    def __gt__(self, other):
447
        if isinstance(other, unicode):
448
            other = Identifier._lower(other)
449
        return unicode.__gt__(self._lowered, other)
450
451
    def __ge__(self, other):
452
        if isinstance(other, unicode):
453
            other = Identifier._lower(other)
454
        return unicode.__ge__(self._lowered, other)
455
456
    def __eq__(self, other):
457
        if isinstance(other, unicode):
458
            other = Identifier._lower(other)
459
        return unicode.__eq__(self._lowered, other)
460
461
    def __ne__(self, other):
462
        return not (self == other)
463
464
    def is_nick(self):
465
        """Check if the Identifier is a nickname (i.e. not a channel)
466
467
        :return: ``True`` if this :py:class:`Identifier` is a nickname;
468
                 ``False`` if it appears to be a channel
469
        """
470
        return self and not self.startswith(_channel_prefixes)
471
472
473
class OutputRedirect(object):
474
    """Redirect the output to the terminal and a log file.
475
476
    A simplified object used to write to both the terminal and a log file.
477
    """
478
479
    def __init__(self, logpath, stderr=False, quiet=False):
480
        """Create an object which will log to both a file and the terminal.
481
482
        :param str logpath: path to the log file
483
        :param bool stderr: write output to stderr if ``True``, or to stdout
484
                            otherwise
485
        :param bool quiet: write to the log file only if ``True`` (and not to
486
                           the terminal)
487
488
        Create an object which will log to the file at ``logpath`` as well as
489
        the terminal.
490
        """
491
        self.logpath = logpath
492
        self.stderr = stderr
493
        self.quiet = quiet
494
495
    def write(self, string):
496
        """Write the given ``string`` to the logfile and terminal.
497
498
        :param str string: the string to write
499
        """
500
        if not self.quiet:
501
            try:
502
                if self.stderr:
503
                    sys.__stderr__.write(string)
504
                else:
505
                    sys.__stdout__.write(string)
506
            except Exception:  # TODO: Be specific
507
                pass
508
509
        with codecs.open(self.logpath, 'ab', encoding="utf8",
510
                         errors='xmlcharrefreplace') as logfile:
511
            try:
512
                logfile.write(string)
513
            except UnicodeDecodeError:
514
                # we got an invalid string, safely encode it to utf-8
515
                logfile.write(unicode(string, 'utf8', errors="replace"))
516
517
    def flush(self):
518
        """Flush the file writing buffer."""
519
        if self.stderr:
520
            sys.__stderr__.flush()
521
        else:
522
            sys.__stdout__.flush()
523
524
525
# These seems to trace back to when we thought we needed a try/except on prints,
526
# because it looked like that was why we were having problems.
527
# We'll drop it in Sopel 8.0 because it has been here for far too long already.
528
@deprecated('Use `print()` instead of sopel.tools.stdout', removed_in='8.0')
529
def stdout(string):
530
    print(string)
531
532
533
def stderr(string):
534
    """Print the given ``string`` to stderr.
535
536
    :param str string: the string to output
537
538
    This is equivalent to ``print >> sys.stderr, string``
539
    """
540
    print(string, file=sys.stderr)
541
542
543
def check_pid(pid):
544
    """Check if a process is running with the given ``PID``.
545
546
    :param int pid: PID to check
547
    :return bool: ``True`` if the given PID is running, ``False`` otherwise
548
549
    *Availability: POSIX systems only.*
550
551
    .. note::
552
        Matching the :py:func:`os.kill` behavior this function needs on Windows
553
        was rejected in
554
        `Python issue #14480 <https://bugs.python.org/issue14480>`_, so
555
        :py:func:`check_pid` cannot be used on Windows systems.
556
    """
557
    try:
558
        os.kill(pid, 0)
559
    except OSError:
560
        return False
561
    else:
562
        return True
563
564
565
def get_hostmask_regex(mask):
566
    """Get a compiled regex pattern for an IRC hostmask
567
568
    :param str mask: the hostmask that the pattern should match
569
    :return: a compiled regex pattern matching the given ``mask``
570
    :rtype: :py:class:`re.Pattern`
571
    """
572
    mask = re.escape(mask)
573
    mask = mask.replace(r'\*', '.*')
574
    return re.compile(mask + '$', re.I)
575
576
577
def get_logger(plugin_name):
578
    """Return a logger for a plugin.
579
580
    :param str plugin_name: name of the plugin
581
    :return: the logger for the given plugin
582
583
    This::
584
585
        from sopel import plugins
586
        LOGGER = plugins.get_logger('my_custom_plugin')
587
588
    is equivalent to this::
589
590
        import logging
591
        LOGGER = logging.getLogger('sopel.externals.my_custom_plugin')
592
593
    Internally, Sopel configures logging for the ``sopel`` namespace, so
594
    external plugins can't benefit from it with ``logging.getLogger(__name__)``
595
    as they won't be in the same namespace. This function uses the
596
    ``plugin_name`` with a prefix inside this namespace.
597
    """
598
    return logging.getLogger('sopel.externals.%s' % plugin_name)
599
600
601
class SopelMemory(dict):
602
    """A simple thread-safe ``dict`` implementation.
603
604
    In order to prevent exceptions when iterating over the values and changing
605
    them at the same time from different threads, we use a blocking lock in
606
    ``__setitem__`` and ``contains``.
607
608
    .. versionadded:: 3.1
609
        As ``Willie.WillieMemory``
610
    .. versionchanged:: 4.0
611
        Moved to ``tools.WillieMemory``
612
    .. versionchanged:: 6.0
613
        Renamed from ``WillieMemory`` to ``SopelMemory``
614
    """
615
    def __init__(self, *args):
616
        dict.__init__(self, *args)
617
        self.lock = threading.Lock()
618
619
    def __setitem__(self, key, value):
620
        """Set a key equal to a value.
621
622
        The dict is locked for other writes while doing so.
623
        """
624
        self.lock.acquire()
625
        result = dict.__setitem__(self, key, value)
626
        self.lock.release()
627
        return result
628
629
    def __contains__(self, key):
630
        """Check if a key is in the dict.
631
632
        The dict is locked for writes while doing so.
633
        """
634
        self.lock.acquire()
635
        result = dict.__contains__(self, key)
636
        self.lock.release()
637
        return result
638
639
    @deprecated
640
    def contains(self, key):
641
        """Check if ``key`` is in the memory
642
643
        :param str key: key to check for
644
645
        .. deprecated:: 7.0
646
            Will be removed in Sopel 8. If you aren't already using the ``in``
647
            operator, you should be.
648
        """
649
        return self.__contains__(key)
650
651
652
class SopelMemoryWithDefault(defaultdict):
653
    """Same as SopelMemory, but subclasses from collections.defaultdict.
654
655
    .. versionadded:: 4.3
656
        As ``WillieMemoryWithDefault``
657
    .. versionchanged:: 6.0
658
        Renamed to ``SopelMemoryWithDefault``
659
    """
660
    def __init__(self, *args):
661
        defaultdict.__init__(self, *args)
662
        self.lock = threading.Lock()
663
664
    def __setitem__(self, key, value):
665
        """Set a key equal to a value.
666
667
        The dict is locked for other writes while doing so.
668
        """
669
        self.lock.acquire()
670
        result = defaultdict.__setitem__(self, key, value)
671
        self.lock.release()
672
        return result
673
674
    def __contains__(self, key):
675
        """Check if a key is in the dict.
676
677
        The dict is locked for writes while doing so.
678
        """
679
        self.lock.acquire()
680
        result = defaultdict.__contains__(self, key)
681
        self.lock.release()
682
        return result
683
684
    @deprecated
685
    def contains(self, key):
686
        """Check if ``key`` is in the memory
687
688
        :param str key: key to check for
689
690
        .. deprecated:: 7.0
691
            Will be removed in Sopel 8. If you aren't already using the ``in``
692
            operator, you should be.
693
        """
694
        return self.__contains__(key)
695
696
697
@deprecated(version='7.0', removed_in='8.0')
698
def get_raising_file_and_line(tb=None):
699
    """Get the file and line number where an exception happened.
700
701
    :param tb: the traceback (uses the most recent exception if not given)
702
    :return: a tuple of the filename and line number
703
    :rtype: (str, int)
704
705
    .. deprecated:: 7.0
706
707
        Use Python's built-in logging system, with the ``logger.exception``
708
        method. This method makes sure to log the exception with the traceback
709
        and the relevant information (filename, line number, etc.).
710
    """
711
    if not tb:
712
        tb = sys.exc_info()[2]
713
714
    filename, lineno, _context, _line = traceback.extract_tb(tb)[-1]
715
716
    return filename, lineno
717