Completed
Push — master ( ac2779...d89639 )
by dgw
17s queued 13s
created

sopel.tools.get_logger()   A

Complexity

Conditions 1

Size

Total Lines 22
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 2
dl 0
loc 22
rs 10
c 0
b 0
f 0
cc 1
nop 1
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_sendable_message(text, max_length=400):
182
    """Get a sendable ``text`` message, with its excess when needed.
183
184
    :param str txt: text to send (expects Unicode-encoded string)
185
    :param int max_length: maximum length of the message to be sendable
186
    :return: a tuple of two values, the sendable text and its excess text
187
    :rtype: (str, str)
188
189
    We're arbitrarily saying that the max is 400 bytes of text when
190
    messages will be split. Otherwise, we'd have to account for the bot's
191
    hostmask, which is hard.
192
193
    The ``max_length`` is the max length of text in **bytes**, but we take
194
    care of Unicode 2-byte characters by working on the Unicode string,
195
    then making sure the bytes version is smaller than the max length.
196
    """
197
    unicode_max_length = max_length
198
    excess = ''
199
200
    while len(text.encode('utf-8')) > max_length:
201
        last_space = text.rfind(' ', 0, unicode_max_length)
202
        if last_space == -1:
203
            # No last space, just split where it is possible
204
            excess = text[unicode_max_length:] + excess
205
            text = text[:unicode_max_length]
206
            # Decrease max length for the unicode string
207
            unicode_max_length = unicode_max_length - 1
208
        else:
209
            # Split at the last best space found
210
            excess = text[last_space:]
211
            text = text[:last_space]
212
213
    return text, excess.lstrip()
214
215
216
def deprecated(reason=None, version=None, removed_in=None, func=None):
217
    """Decorator to mark deprecated functions in Sopel's API
218
219
    :param str reason: optional text added to the deprecation warning
220
    :param str version: optional version number when the decorated function
221
                        is deprecated
222
    :param str removed_in: optional version number when the deprecated function
223
                           will be removed
224
    :param callable func: deprecated function
225
    :return: a callable that depends on how the decorator is called; either
226
             the decorated function, or a decorator with the appropriate
227
             parameters
228
229
    Any time the decorated ``func`` is called, a deprecation warning will be
230
    printed to ``sys.stderr``, with the last frame of the traceback.
231
232
    It can be used with or without arguments::
233
234
        from sopel import tools
235
236
        @tools.deprecated
237
        def func1():
238
            print('func 1')
239
240
        @tools.deprecated()
241
        def func2():
242
            print('func 2')
243
244
        @tools.deprecated(reason='obsolete', version='7.0', removed_in='8.0')
245
        def func3():
246
            print('func 3')
247
248
    which will output the following in a console::
249
250
        >>> func1()
251
        Deprecated: func1
252
        File "<stdin>", line 1, in <module>
253
        func 1
254
        >>> func2()
255
        Deprecated: func2
256
        File "<stdin>", line 1, in <module>
257
        func 2
258
        >>> func3()
259
        Deprecated since 7.0, will be removed in 8.0: obsolete
260
        File "<stdin>", line 1, in <module>
261
        func 3
262
263
    .. note::
264
265
        There is nothing that prevents this decorator to be used on a class's
266
        method, or on any existing callable.
267
268
    .. versionadded:: 7.0
269
        Parameters ``reason``, ``version``, and ``removed_in``.
270
    """
271
    if not any([reason, version, removed_in, func]):
272
        # common usage: @deprecated()
273
        return deprecated
274
275
    if callable(reason):
276
        # common usage: @deprecated
277
        return deprecated(func=reason)
278
279
    if func is None:
280
        # common usage: @deprecated(message, version, removed_in)
281
        def decorator(func):
282
            return deprecated(reason, version, removed_in, func)
283
        return decorator
284
285
    # now, we have everything we need to have:
286
    # - message is not a callable (could be None)
287
    # - func is not None
288
    # - version and removed_in can be None but that's OK
289
    # so now we can return the actual decorated function
290
291
    message = reason or getattr(func, '__name__', '<anonymous-function>')
292
293
    template = 'Deprecated: {message}'
294
    if version and removed_in:
295
        template = (
296
            'Deprecated since {version}, '
297
            'will be removed in {removed_in}: '
298
            '{message}')
299
    elif version:
300
        template = 'Deprecated since {version}: {message}'
301
    elif removed_in:
302
        template = 'Deprecated, will be removed in {removed_in}: {message}'
303
304
    text = template.format(
305
        message=message, version=version, removed_in=removed_in)
306
307
    @functools.wraps(func)
308
    def deprecated_func(*args, **kwargs):
309
        stderr(text)
310
        # Only display the last frame
311
        trace = traceback.extract_stack()
312
        stderr(traceback.format_list(trace[:-1])[-1][:-1])
313
        return func(*args, **kwargs)
314
315
    return deprecated_func
316
317
318
# This class was useful before Python 2.5, when ``defaultdict`` was added
319
# to the built-in ``collections`` module.
320
# It is now deprecated.
321
class Ddict(dict):
322
    """A default dict implementation available for Python 2.x support.
323
324
    It was used to make multi-dimensional ``dict``\\s easy to use when the
325
    bot worked with Python version < 2.5.
326
327
    .. deprecated:: 7.0
328
        Use :class:`collections.defaultdict` instead.
329
    """
330
    @deprecated('use "collections.defaultdict" instead', '7.0', '8.0')
331
    def __init__(self, default=None):
332
        self.default = default
333
334
    def __getitem__(self, key):
335
        if key not in self:
336
            self[key] = self.default()
337
        return dict.__getitem__(self, key)
338
339
340
class Identifier(unicode):
341
    """A `unicode` subclass which acts appropriately for IRC identifiers.
342
343
    When used as normal `unicode` objects, case will be preserved.
344
    However, when comparing two Identifier objects, or comparing a Identifier
345
    object with a `unicode` object, the comparison will be case insensitive.
346
    This case insensitivity includes the case convention conventions regarding
347
    ``[]``, ``{}``, ``|``, ``\\``, ``^`` and ``~`` described in RFC 2812.
348
    """
349
    # May want to tweak this and update documentation accordingly when dropping
350
    # Python 2 support, since in py3 plain str is Unicode and a "unicode" type
351
    # no longer exists. Probably lots of code will need tweaking, tbh.
352
353
    def __new__(cls, identifier):
354
        # According to RFC2812, identifiers have to be in the ASCII range.
355
        # However, I think it's best to let the IRCd determine that, and we'll
356
        # just assume unicode. It won't hurt anything, and is more internally
357
        # consistent. And who knows, maybe there's another use case for this
358
        # weird case convention.
359
        s = unicode.__new__(cls, identifier)
360
        s._lowered = Identifier._lower(identifier)
361
        return s
362
363
    def lower(self):
364
        """Get the RFC 2812-compliant lowercase version of this identifier.
365
366
        :return: RFC 2812-compliant lowercase version of the
367
                 :py:class:`Identifier` instance
368
        :rtype: str
369
        """
370
        return self._lowered
371
372
    @staticmethod
373
    def _lower(identifier):
374
        """Convert an identifier to lowercase per RFC 2812.
375
376
        :param str identifier: the identifier (nickname or channel) to convert
377
        :return: RFC 2812-compliant lowercase version of ``identifier``
378
        :rtype: str
379
        """
380
        if isinstance(identifier, Identifier):
381
            return identifier._lowered
382
        # The tilde replacement isn't needed for identifiers, but is for
383
        # channels, which may be useful at some point in the future.
384
        low = identifier.lower().replace('{', '[').replace('}', ']')
385
        low = low.replace('|', '\\').replace('^', '~')
386
        return low
387
388
    def __repr__(self):
389
        return "%s(%r)" % (
390
            self.__class__.__name__,
391
            self.__str__()
392
        )
393
394
    def __hash__(self):
395
        return self._lowered.__hash__()
396
397
    def __lt__(self, other):
398
        if isinstance(other, unicode):
399
            other = Identifier._lower(other)
400
        return unicode.__lt__(self._lowered, other)
401
402
    def __le__(self, other):
403
        if isinstance(other, unicode):
404
            other = Identifier._lower(other)
405
        return unicode.__le__(self._lowered, other)
406
407
    def __gt__(self, other):
408
        if isinstance(other, unicode):
409
            other = Identifier._lower(other)
410
        return unicode.__gt__(self._lowered, other)
411
412
    def __ge__(self, other):
413
        if isinstance(other, unicode):
414
            other = Identifier._lower(other)
415
        return unicode.__ge__(self._lowered, other)
416
417
    def __eq__(self, other):
418
        if isinstance(other, unicode):
419
            other = Identifier._lower(other)
420
        return unicode.__eq__(self._lowered, other)
421
422
    def __ne__(self, other):
423
        return not (self == other)
424
425
    def is_nick(self):
426
        """Check if the Identifier is a nickname (i.e. not a channel)
427
428
        :return: ``True`` if this :py:class:`Identifier` is a nickname;
429
                 ``False`` if it appears to be a channel
430
        """
431
        return self and not self.startswith(_channel_prefixes)
432
433
434
class OutputRedirect(object):
435
    """Redirect the output to the terminal and a log file.
436
437
    A simplified object used to write to both the terminal and a log file.
438
    """
439
440
    def __init__(self, logpath, stderr=False, quiet=False):
441
        """Create an object which will log to both a file and the terminal.
442
443
        :param str logpath: path to the log file
444
        :param bool stderr: write output to stderr if ``True``, or to stdout
445
                            otherwise
446
        :param bool quiet: write to the log file only if ``True`` (and not to
447
                           the terminal)
448
449
        Create an object which will log to the file at ``logpath`` as well as
450
        the terminal.
451
        """
452
        self.logpath = logpath
453
        self.stderr = stderr
454
        self.quiet = quiet
455
456
    def write(self, string):
457
        """Write the given ``string`` to the logfile and terminal.
458
459
        :param str string: the string to write
460
        """
461
        if not self.quiet:
462
            try:
463
                if self.stderr:
464
                    sys.__stderr__.write(string)
465
                else:
466
                    sys.__stdout__.write(string)
467
            except Exception:  # TODO: Be specific
468
                pass
469
470
        with codecs.open(self.logpath, 'ab', encoding="utf8",
471
                         errors='xmlcharrefreplace') as logfile:
472
            try:
473
                logfile.write(string)
474
            except UnicodeDecodeError:
475
                # we got an invalid string, safely encode it to utf-8
476
                logfile.write(unicode(string, 'utf8', errors="replace"))
477
478
    def flush(self):
479
        """Flush the file writing buffer."""
480
        if self.stderr:
481
            sys.__stderr__.flush()
482
        else:
483
            sys.__stdout__.flush()
484
485
486
# These seems to trace back to when we thought we needed a try/except on prints,
487
# because it looked like that was why we were having problems.
488
# We'll drop it in Sopel 8.0 because it has been here for far too long already.
489
@deprecated('Use `print()` instead of sopel.tools.stdout', removed_in='8.0')
490
def stdout(string):
491
    print(string)
492
493
494
def stderr(string):
495
    """Print the given ``string`` to stderr.
496
497
    :param str string: the string to output
498
499
    This is equivalent to ``print >> sys.stderr, string``
500
    """
501
    print(string, file=sys.stderr)
502
503
504
def check_pid(pid):
505
    """Check if a process is running with the given ``PID``.
506
507
    :param int pid: PID to check
508
    :return bool: ``True`` if the given PID is running, ``False`` otherwise
509
510
    *Availability: POSIX systems only.*
511
512
    .. note::
513
        Matching the :py:func:`os.kill` behavior this function needs on Windows
514
        was rejected in
515
        `Python issue #14480 <https://bugs.python.org/issue14480>`_, so
516
        :py:func:`check_pid` cannot be used on Windows systems.
517
    """
518
    try:
519
        os.kill(pid, 0)
520
    except OSError:
521
        return False
522
    else:
523
        return True
524
525
526
def get_hostmask_regex(mask):
527
    """Get a compiled regex pattern for an IRC hostmask
528
529
    :param str mask: the hostmask that the pattern should match
530
    :return: a compiled regex pattern matching the given ``mask``
531
    :rtype: :py:class:`re.Pattern`
532
    """
533
    mask = re.escape(mask)
534
    mask = mask.replace(r'\*', '.*')
535
    return re.compile(mask + '$', re.I)
536
537
538
def get_logger(plugin_name):
539
    """Return a logger for a plugin.
540
541
    :param str plugin_name: name of the plugin
542
    :return: the logger for the given plugin
543
544
    This::
545
546
        from sopel import plugins
547
        LOGGER = plugins.get_logger('my_custom_plugin')
548
549
    is equivalent to this::
550
551
        import logging
552
        LOGGER = logging.getLogger('sopel.externals.my_custom_plugin')
553
554
    Internally, Sopel configures logging for the ``sopel`` namespace, so
555
    external plugins can't benefit from it with ``logging.getLogger(__name__)``
556
    as they won't be in the same namespace. This function uses the
557
    ``plugin_name`` with a prefix inside this namespace.
558
    """
559
    return logging.getLogger('sopel.externals.%s' % plugin_name)
560
561
562
class SopelMemory(dict):
563
    """A simple thread-safe ``dict`` implementation.
564
565
    In order to prevent exceptions when iterating over the values and changing
566
    them at the same time from different threads, we use a blocking lock in
567
    ``__setitem__`` and ``contains``.
568
569
    .. versionadded:: 3.1
570
        As ``Willie.WillieMemory``
571
    .. versionchanged:: 4.0
572
        Moved to ``tools.WillieMemory``
573
    .. versionchanged:: 6.0
574
        Renamed from ``WillieMemory`` to ``SopelMemory``
575
    """
576
    def __init__(self, *args):
577
        dict.__init__(self, *args)
578
        self.lock = threading.Lock()
579
580
    def __setitem__(self, key, value):
581
        """Set a key equal to a value.
582
583
        The dict is locked for other writes while doing so.
584
        """
585
        self.lock.acquire()
586
        result = dict.__setitem__(self, key, value)
587
        self.lock.release()
588
        return result
589
590
    def __contains__(self, key):
591
        """Check if a key is in the dict.
592
593
        The dict is locked for writes while doing so.
594
        """
595
        self.lock.acquire()
596
        result = dict.__contains__(self, key)
597
        self.lock.release()
598
        return result
599
600
    @deprecated
601
    def contains(self, key):
602
        """Check if ``key`` is in the memory
603
604
        :param str key: key to check for
605
606
        .. deprecated:: 7.0
607
            Will be removed in Sopel 8. If you aren't already using the ``in``
608
            operator, you should be.
609
        """
610
        return self.__contains__(key)
611
612
613
class SopelMemoryWithDefault(defaultdict):
614
    """Same as SopelMemory, but subclasses from collections.defaultdict.
615
616
    .. versionadded:: 4.3
617
        As ``WillieMemoryWithDefault``
618
    .. versionchanged:: 6.0
619
        Renamed to ``SopelMemoryWithDefault``
620
    """
621
    def __init__(self, *args):
622
        defaultdict.__init__(self, *args)
623
        self.lock = threading.Lock()
624
625
    def __setitem__(self, key, value):
626
        """Set a key equal to a value.
627
628
        The dict is locked for other writes while doing so.
629
        """
630
        self.lock.acquire()
631
        result = defaultdict.__setitem__(self, key, value)
632
        self.lock.release()
633
        return result
634
635
    def __contains__(self, key):
636
        """Check if a key is in the dict.
637
638
        The dict is locked for writes while doing so.
639
        """
640
        self.lock.acquire()
641
        result = defaultdict.__contains__(self, key)
642
        self.lock.release()
643
        return result
644
645
    @deprecated
646
    def contains(self, key):
647
        """Check if ``key`` is in the memory
648
649
        :param str key: key to check for
650
651
        .. deprecated:: 7.0
652
            Will be removed in Sopel 8. If you aren't already using the ``in``
653
            operator, you should be.
654
        """
655
        return self.__contains__(key)
656
657
658
@deprecated(version='7.0', removed_in='8.0')
659
def get_raising_file_and_line(tb=None):
660
    """Get the file and line number where an exception happened.
661
662
    :param tb: the traceback (uses the most recent exception if not given)
663
    :return: a tuple of the filename and line number
664
    :rtype: (str, int)
665
666
    .. deprecated:: 7.0
667
668
        Use Python's built-in logging system, with the ``logger.exception``
669
        method. This method makes sure to log the exception with the traceback
670
        and the relevant information (filename, line number, etc.).
671
    """
672
    if not tb:
673
        tb = sys.exc_info()[2]
674
675
    filename, lineno, _context, _line = traceback.extract_tb(tb)[-1]
676
677
    return filename, lineno
678