diff --git a/.travis.yml b/.travis.yml index f2564ad..f380d8a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,16 +1,24 @@ language: python + +services: + - redis-server + python: - "2.6" - "2.7" + - "3.2" - "3.3" + - "3.4" - "pypy" install: # this fixes SemLock issues on travis - "sudo rm -rf /dev/shm && sudo ln -s /run/shm /dev/shm" - - "sudo apt-get install libzmq3-dev redis-server" - - "python scripts/pypi_mirror_setup.py http://a.pypi.python.org/simple" + - "sudo apt-add-repository -y ppa:chris-lea/zeromq" + - "sudo apt-get update" + - "sudo apt-get install -y libzmq3-dev" - "pip install cython redis" + - "easy_install pyzmq" - "make test_setup" - "python setup.py develop" diff --git a/CHANGES b/CHANGES index 8673c74..c9f4cf9 100644 --- a/CHANGES +++ b/CHANGES @@ -2,6 +2,16 @@ ================= Here you can see the full list of changes between each Logbook release. + +Version 0.7.0 +------------- + +Released on May 12th 2014. Codename "not_just_yet" + +- Restored Python 3.2 support (thanks @rnortman) +- NullHandlers now respect filters - allows to only drop/mute certain records (#73) +- redirect_logging now sets the legacy root logger's level to DEBUG by default. This can be changed by specifying `set_root_logger_level=False` (#96) +- Bugfixes Version 0.6.0 ------------- diff --git a/docs/api/more.rst b/docs/api/more.rst index 7fe1874..ca91612 100644 --- a/docs/api/more.rst +++ b/docs/api/more.rst @@ -30,6 +30,9 @@ .. autoclass:: ExceptionHandler :members: +.. autoclass:: DedupHandler + :members: + Colorized Handlers ------------------ diff --git a/docs/conf.py b/docs/conf.py index 03a6628..859877a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,9 +48,9 @@ # built documents. # # The short X.Y version. -version = '0.6.1-dev' +version = '0.7.0' # The full version, including alpha/beta/rc tags. -release = '0.6.1-dev' +release = '0.7.0' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/logbook/__init__.py b/logbook/__init__.py index a000db2..3ba0b64 100644 --- a/logbook/__init__.py +++ b/logbook/__init__.py @@ -23,7 +23,7 @@ LimitingHandlerMixin, WrapperHandler, FingersCrossedHandler, \ GroupHandler -__version__ = '0.6.1-dev' +__version__ = '0.7.0' # create an anonymous default logger and provide all important # methods of that logger as global functions diff --git a/logbook/base.py b/logbook/base.py index 4340d1a..2c47613 100644 --- a/logbook/base.py +++ b/logbook/base.py @@ -147,10 +147,10 @@ try: return dict.__getitem__(self, key) except KeyError: - return u'' + return u('') else: def __missing__(self, key): - return u'' + return u('') def copy(self): return self.__class__(self) @@ -474,6 +474,7 @@ self._channel = None if isinstance(self.time, string_types): self.time = parse_iso8601(self.time) + self.extra = ExtraDict(self.extra) return self @cached_property @@ -842,6 +843,15 @@ if not handler.should_handle(record): continue + # a filter can still veto the handling of the record. This + # however is already operating on an initialized and processed + # record. The impact is that filters are slower than the + # handler's should_handle function in case there is no default + # handler that would handle the record (delayed init). + if handler.filter is not None \ + and not handler.filter(record, handler): + continue + # if this is a blackhole handler, don't even try to # do further processing, stop right away. Technically # speaking this is not 100% correct because if the handler @@ -863,15 +873,6 @@ self.process_record(record) record_initialized = True - # a filter can still veto the handling of the record. This - # however is already operating on an initialized and processed - # record. The impact is that filters are slower than the - # handler's should_handle function in case there is no default - # handler that would handle the record (delayed init). - if handler.filter is not None \ - and not handler.filter(record, handler): - continue - # handle the record. If the record was handled and # the record is not bubbling we can abort now. if handler.handle(record) and not handler.bubble: diff --git a/logbook/compat.py b/logbook/compat.py index 2c7224c..548e2a5 100644 --- a/logbook/compat.py +++ b/logbook/compat.py @@ -20,13 +20,18 @@ _epoch_ord = date(1970, 1, 1).toordinal() -def redirect_logging(): +def redirect_logging(set_root_logger_level=True): """Permanently redirects logging to the stdlib. This also removes all otherwise registered handlers on root logger of the logging system but leaves the other loggers untouched. + + :param set_root_logger_level: controls of the default level of the legacy root logger is changed + so that all legacy log messages get redirected to Logbook """ del logging.root.handlers[:] logging.root.addHandler(RedirectLoggingHandler()) + if set_root_logger_level: + logging.root.setLevel(logging.DEBUG) class redirected_logging(object): @@ -38,14 +43,17 @@ with redirected_logging(): ... """ - def __init__(self): + def __init__(self, set_root_logger_level=True): self.old_handlers = logging.root.handlers[:] + self.old_level = logging.root.level + self.set_root_logger_level = set_root_logger_level def start(self): - redirect_logging() + redirect_logging(self.set_root_logger_level) def end(self, etype=None, evalue=None, tb=None): logging.root.handlers[:] = self.old_handlers + logging.root.setLevel(self.old_level) __enter__ = start __exit__ = end diff --git a/logbook/handlers.py b/logbook/handlers.py index cbd512a..e89f410 100644 --- a/logbook/handlers.py +++ b/logbook/handlers.py @@ -28,15 +28,15 @@ NOTSET, level_name_property, _missing, lookup_level, \ Flags, ContextObject, ContextStackManager from logbook.helpers import rename, b, _is_text_stream, is_unicode, PY2, \ - zip, xrange, string_types, integer_types, iteritems, reraise + zip, xrange, string_types, integer_types, reraise, u DEFAULT_FORMAT_STRING = ( - u'[{record.time:%Y-%m-%d %H:%M}] ' - u'{record.level_name}: {record.channel}: {record.message}' + u('[{record.time:%Y-%m-%d %H:%M}] ') + + u('{record.level_name}: {record.channel}: {record.message}') ) -SYSLOG_FORMAT_STRING = u'{record.channel}: {record.message}' -NTLOG_FORMAT_STRING = u'''\ +SYSLOG_FORMAT_STRING = u('{record.channel}: {record.message}') +NTLOG_FORMAT_STRING = u('''\ Message Level: {record.level_name} Location: {record.filename}:{record.lineno} Module: {record.module} @@ -46,10 +46,10 @@ Event provided Message: {record.message} -''' +''') TEST_FORMAT_STRING = \ -u'[{record.level_name}] {record.channel}: {record.message}' -MAIL_FORMAT_STRING = u'''\ +u('[{record.level_name}] {record.channel}: {record.message}') +MAIL_FORMAT_STRING = u('''\ Subject: {handler.subject} Message type: {record.level_name} @@ -61,14 +61,14 @@ Message: {record.message} -''' -MAIL_RELATED_FORMAT_STRING = u'''\ +''') +MAIL_RELATED_FORMAT_STRING = u('''\ Message type: {record.level_name} Location: {record.filename}:{record.lineno} Module: {record.module} Function: {record.func_name} {record.message} -''' +''') SYSLOG_PORT = 514 @@ -125,12 +125,12 @@ # all here goes to that handler handler.pop_application() - By default messages send to that handler will not go to a handler on + By default messages sent to that handler will not go to a handler on an outer level on the stack, if handled. This can be changed by setting bubbling to `True`. This setup for example would not have any effect:: - handler = NullHandler(bubble=False) + handler = NullHandler(bubble=True) handler.push_application() Whereas this setup disables all logging for the application:: @@ -374,7 +374,7 @@ line = self.format_record(record, handler) exc = self.format_exception(record) if exc: - line += u'\n' + exc + line += u('\n') + exc return line @@ -420,7 +420,7 @@ """Returns a hashlib object with the hash of the record.""" hash = sha1() hash.update(('%d\x00' % record.level).encode('ascii')) - hash.update((record.channel or u'').encode('utf-8') + b('\x00')) + hash.update((record.channel or u('')).encode('utf-8') + b('\x00')) hash.update(record.filename.encode('utf-8') + b('\x00')) hash.update(b(str(record.lineno))) return hash @@ -1032,7 +1032,7 @@ """ default_format_string = MAIL_FORMAT_STRING default_related_format_string = MAIL_RELATED_FORMAT_STRING - default_subject = u'Server Error in Application' + default_subject = u('Server Error in Application') #: the maximum number of record hashes in the cache for the limiting #: feature. Afterwards, record_cache_prune percent of the oldest @@ -1156,7 +1156,7 @@ """ from smtplib import SMTP, SMTP_PORT, SMTP_SSL_PORT if self.server_addr is None: - host = 'localhost' + host = '127.0.0.1' port = self.secure and SMTP_SSL_PORT or SMTP_PORT else: host, port = self.server_addr @@ -1353,10 +1353,10 @@ return (facility << 3) | priority def emit(self, record): - prefix = u'' + prefix = u('') if self.application_name is not None: - prefix = self.application_name + u':' - self.send_to_socket((u'<%d>%s%s\x00' % ( + prefix = self.application_name + u(':') + self.send_to_socket((u('<%d>%s%s\x00') % ( self.encode_priority(record), prefix, self.format(record) diff --git a/logbook/more.py b/logbook/more.py index 82fbfc5..9e65bfb 100644 --- a/logbook/more.py +++ b/logbook/more.py @@ -10,13 +10,14 @@ """ import re import os +from collections import defaultdict from cgi import parse_qsl -from logbook.base import RecordDispatcher, NOTSET, ERROR, NOTICE +from logbook.base import RecordDispatcher, dispatch_record, NOTSET, ERROR, NOTICE from logbook.handlers import Handler, StringFormatter, \ StringFormatterHandlerMixin, StderrHandler from logbook._termcolors import colorize -from logbook.helpers import PY2, string_types, iteritems +from logbook.helpers import PY2, string_types, iteritems, u from logbook.ticketing import TicketingHandler as DatabaseHandler from logbook.ticketing import BackendBase @@ -28,7 +29,7 @@ _ws_re = re.compile(r'(\s+)(?u)') TWITTER_FORMAT_STRING = \ -u'[{record.channel}] {record.level_name}: {record.message}' +u('[{record.channel}] {record.level_name}: {record.message}') TWITTER_ACCESS_TOKEN_URL = 'https://twitter.com/oauth/access_token' NEW_TWEET_URL = 'https://api.twitter.com/1/statuses/update.json' @@ -39,7 +40,7 @@ def setup_backend(self): from couchdb import Server - uri = self.options.pop('uri', u'') + uri = self.options.pop('uri', u('')) couch = Server(uri) db_name = self.options.pop('db') self.database = couch[db_name] @@ -63,8 +64,8 @@ max_length = 140 def format_exception(self, record): - return u'%s: %s' % (record.exception_shortname, - record.exception_message) + return u('%s: %s') % (record.exception_shortname, + record.exception_message) def __call__(self, record, handler): formatted = StringFormatter.__call__(self, record, handler) @@ -74,10 +75,10 @@ length += len(piece) if length > self.max_length: if length - len(piece) < self.max_length: - rv.append(u'…') + rv.append(u('…')) break rv.append(piece) - return u''.join(rv) + return u('').join(rv) class TaggingLogger(RecordDispatcher): @@ -357,3 +358,51 @@ if self.should_handle(record): raise self.exc_type(self.format(record)) return False + +class DedupHandler(Handler): + """A handler that deduplicates log messages. + + It emits each unique log record once, along with the number of times it was emitted. + Example::: + + with logbook.more.DedupHandler(): + logbook.error('foo') + logbook.error('bar') + logbook.error('foo') + + The expected output::: + + message repeated 2 times: foo + message repeated 1 times: bar + """ + def __init__(self, format_string='message repeated {count} times: {message}', *args, **kwargs): + Handler.__init__(self, bubble=False, *args, **kwargs) + self._format_string = format_string + self.clear() + + def clear(self): + self._message_to_count = defaultdict(int) + self._unique_ordered_records = [] + + def pop_application(self): + Handler.pop_application(self) + self.flush() + + def pop_thread(self): + Handler.pop_thread(self) + self.flush() + + def handle(self, record): + if not record.message in self._message_to_count: + self._unique_ordered_records.append(record) + self._message_to_count[record.message] += 1 + return True + + def flush(self): + for record in self._unique_ordered_records: + record.message = self._format_string.format(message=record.message, count=self._message_to_count[record.message]) + # record.dispatcher is the logger who created the message, it's sometimes supressed (by logbook.info for example) + dispatch = record.dispatcher.call_handlers if record.dispatcher is not None else dispatch_record + dispatch(record) + self.clear() + diff --git a/logbook/notifiers.py b/logbook/notifiers.py index 18237c4..e7a4346 100644 --- a/logbook/notifiers.py +++ b/logbook/notifiers.py @@ -45,7 +45,7 @@ def make_title(self, record): """Called to get the title from the record.""" - return u'%s: %s' % (record.channel, record.level_name.title()) + return u('%s: %s') % (record.channel, record.level_name.title()) def make_text(self, record): """Called to get the text of the record.""" @@ -219,7 +219,7 @@ con = http_client.HTTPSConnection('boxcar.io') con.request('POST', '/notifications/', headers={ 'Authorization': 'Basic ' + - base64.b64encode((u'%s:%s' % + base64.b64encode((u('%s:%s') % (self.email, self.password)).encode('utf-8')).strip(), }, body=body) con.close() diff --git a/logbook/queues.py b/logbook/queues.py index 9dc0b1a..78cdd0e 100644 --- a/logbook/queues.py +++ b/logbook/queues.py @@ -14,7 +14,7 @@ import platform from logbook.base import NOTSET, LogRecord, dispatch_record from logbook.handlers import Handler, WrapperHandler -from logbook.helpers import PY2 +from logbook.helpers import PY2, u if PY2: from Queue import Empty, Queue as ThreadQueue @@ -31,7 +31,7 @@ Example setup:: - handler = RedisHandler('http://localhost', port='9200', key='redis') + handler = RedisHandler('http://127.0.0.1', port='9200', key='redis') If your Redis instance is password protected, you can securely connect passing your password when creating a RedisHandler object. @@ -42,7 +42,7 @@ More info about the default buffer size: wp.me/p3tYJu-3b """ - def __init__(self, host='localhost', port=6379, key='redis', extra_fields={}, + def __init__(self, host='127.0.0.1', port=6379, key='redis', extra_fields={}, flush_threshold=128, flush_time=1, level=NOTSET, filter=None, password=False, bubble=True, context=None): Handler.__init__(self, level, filter, bubble) @@ -120,20 +120,20 @@ self._flush_buffer() -class RabbitMQHandler(Handler): - """A handler that acts as a RabbitMQ publisher, which publishes each record - as json dump. Requires the kombu module. +class MessageQueueHandler(Handler): + """A handler that acts as a message queue publisher, which publishes each + record as json dump. Requires the kombu module. The queue will be filled with JSON exported log records. To receive such - log records from a queue you can use the :class:`RabbitMQSubscriber`. - + log records from a queue you can use the :class:`MessageQueueSubscriber`. Example setup:: - handler = RabbitMQHandler('amqp://guest:guest@localhost//', queue='my_log') - """ + handler = MessageQueueHandler('mongodb://localhost:27017/logging') + """ + def __init__(self, uri=None, queue='logging', level=NOTSET, - filter=None, bubble=False, context=None): + filter=None, bubble=False, context=None): Handler.__init__(self, level, filter, bubble) try: import kombu @@ -157,6 +157,9 @@ self.queue.close() +RabbitMQHandler = MessageQueueHandler + + class ZeroMQHandler(Handler): """A handler that acts as a ZeroMQ publisher, which publishes each record as json dump. Requires the pyzmq library. @@ -164,6 +167,11 @@ The queue will be filled with JSON exported log records. To receive such log records from a queue you can use the :class:`ZeroMQSubscriber`. + If `multi` is set to `True`, the handler will use a `PUSH` socket to + publish the records. This allows multiple handlers to use the same `uri`. + The records can be received by using the :class:`ZeroMQSubscriber` with + `multi` set to `True`. + Example setup:: @@ -171,7 +179,7 @@ """ def __init__(self, uri=None, level=NOTSET, filter=None, bubble=False, - context=None): + context=None, multi=False): Handler.__init__(self, level, filter, bubble) try: import zmq @@ -180,10 +188,18 @@ 'the ZeroMQHandler.') #: the zero mq context self.context = context or zmq.Context() - #: the zero mq socket. - self.socket = self.context.socket(zmq.PUB) - if uri is not None: - self.socket.bind(uri) + + if multi: + #: the zero mq socket. + self.socket = self.context.socket(zmq.PUSH) + if uri is not None: + self.socket.connect(uri) + else: + #: the zero mq socket. + self.socket = self.context.socket(zmq.PUB) + if uri is not None: + self.socket.bind(uri) + def export_record(self, record): """Exports the record into a dictionary ready for JSON dumping.""" @@ -275,27 +291,27 @@ return controller -class RabbitMQSubscriber(SubscriberBase): - """A helper that acts as RabbitMQ subscriber and will dispatch received - log records to the active handler setup. There are multiple ways to - use this class. +class MessageQueueSubscriber(SubscriberBase): + """A helper that acts as a message queue subscriber and will dispatch + received log records to the active handler setup. There are multiple ways + to use this class. It can be used to receive log records from a queue:: - subscriber = RabbitMQSubscriber('amqp://guest:guest@localhost//') + subscriber = MessageQueueSubscriber('mongodb://localhost:27017/logging') record = subscriber.recv() But it can also be used to receive and dispatch these in one go:: with target_handler: - subscriber = RabbitMQSubscriber('amqp://guest:guest@localhost//') + subscriber = MessageQueueSubscriber('mongodb://localhost:27017/logging') subscriber.dispatch_forever() This will take all the log records from that queue and dispatch them over to `target_handler`. If you want you can also do that in the background:: - subscriber = RabbitMQSubscriber('amqp://guest:guest@localhost//') + subscriber = MessageQueueSubscriber('mongodb://localhost:27017/logging') controller = subscriber.dispatch_in_background(target_handler) The controller returned can be used to shut down the background @@ -303,13 +319,11 @@ controller.stop() """ - def __init__(self, uri=None, queue='logging'): try: import kombu except ImportError: - raise RuntimeError('The kombu library is required for ' - 'the RabbitMQSubscriber.') + raise RuntimeError('The kombu library is required.') if uri: connection = kombu.Connection(uri) @@ -344,6 +358,9 @@ return LogRecord.from_dict(log_record) +RabbitMQSubscriber = MessageQueueSubscriber + + class ZeroMQSubscriber(SubscriberBase): """A helper that acts as ZeroMQ subscriber and will dispatch received log records to the active handler setup. There are multiple ways to @@ -371,9 +388,14 @@ thread:: controller.stop() - """ - - def __init__(self, uri=None, context=None): + + If `multi` is set to `True`, the subscriber will use a `PULL` socket + and listen to records published by a `PUSH` socket (usually via a + :class:`ZeroMQHandler` with `multi` set to `True`). This allows a + single subscriber to dispatch multiple handlers. + """ + + def __init__(self, uri=None, context=None, multi=False): try: import zmq except ImportError: @@ -383,11 +405,18 @@ #: the zero mq context self.context = context or zmq.Context() - #: the zero mq socket. - self.socket = self.context.socket(zmq.SUB) - if uri is not None: - self.socket.connect(uri) - self.socket.setsockopt_unicode(zmq.SUBSCRIBE, u'') + + if multi: + #: the zero mq socket. + self.socket = self.context.socket(zmq.PULL) + if uri is not None: + self.socket.bind(uri) + else: + #: the zero mq socket. + self.socket = self.context.socket(zmq.SUB) + if uri is not None: + self.socket.connect(uri) + self.socket.setsockopt_unicode(zmq.SUBSCRIBE, u('')) def __del__(self): try: @@ -529,7 +558,7 @@ def __init__(self, channel): self.channel = channel - def recv(self, timeout=-1): + def recv(self, timeout=None): try: rv = self.channel.receive(timeout=timeout) except self.channel.RemoteError: @@ -639,7 +668,7 @@ subscribers = SubscriberGroup([ MultiProcessingSubscriber(queue), - ZeroMQSubscriber('tcp://localhost:5000') + ZeroMQSubscriber('tcp://127.0.0.1:5000') ]) with target_handler: subscribers.dispatch_forever() diff --git a/logbook/ticketing.py b/logbook/ticketing.py index b242652..aaf7e0e 100644 --- a/logbook/ticketing.py +++ b/logbook/ticketing.py @@ -13,7 +13,7 @@ import json from logbook.base import NOTSET, level_name_property, LogRecord from logbook.handlers import Handler, HashingHandlerMixin -from logbook.helpers import cached_property, b, PY2 +from logbook.helpers import cached_property, b, PY2, u class Ticket(object): """Represents a ticket from the database.""" @@ -192,9 +192,9 @@ row = cnx.execute(self.tickets.insert().values( record_hash=hash, level=record.level, - channel=record.channel or u'', - location=u'%s:%d' % (record.filename, record.lineno), - module=record.module or u'', + channel=record.channel or u(''), + location=u('%s:%d') % (record.filename, record.lineno), + module=record.module or u(''), occurrence_count=0, solved=False, app_id=app_id @@ -287,7 +287,7 @@ from pymongo.errors import AutoReconnect _connection = None - uri = self.options.pop('uri', u'') + uri = self.options.pop('uri', u('')) _connection_attempts = 0 parsed_uri = parse_uri(uri, Connection.PORT) @@ -336,9 +336,9 @@ doc = { 'record_hash': hash, 'level': record.level, - 'channel': record.channel or u'', - 'location': u'%s:%d' % (record.filename, record.lineno), - 'module': record.module or u'', + 'channel': record.channel or u(''), + 'location': u('%s:%d') % (record.filename, record.lineno), + 'module': record.module or u(''), 'occurrence_count': 0, 'solved': False, 'app_id': app_id, @@ -448,7 +448,7 @@ filter=None, bubble=False, hash_salt=None, backend=None, **db_options): if hash_salt is None: - hash_salt = u'apphash-' + app_id + hash_salt = u('apphash-') + app_id TicketingBaseHandler.__init__(self, hash_salt, level, filter, bubble) if backend is None: backend = self.default_backend diff --git a/scripts/pypi_mirror_setup.py b/scripts/pypi_mirror_setup.py deleted file mode 100644 index f365dc0..0000000 --- a/scripts/pypi_mirror_setup.py +++ /dev/null @@ -1,25 +0,0 @@ -#! /usr/bin/python -import os -import sys - - -if __name__ == '__main__': - mirror = sys.argv[1] - f = open(os.path.expanduser("~/.pydistutils.cfg"), "w") - f.write(""" -[easy_install] -index_url = %s -""" % mirror) - f.close() - pip_dir = os.path.expanduser("~/.pip") - if not os.path.isdir(pip_dir): - os.makedirs(pip_dir) - f = open(os.path.join(pip_dir, "pip.conf"), "w") - f.write(""" -[global] -index-url = %s - -[install] -use-mirrors = true -""" % mirror) - f.close() diff --git a/scripts/test_setup.py b/scripts/test_setup.py index 3d6ae8d..8bebeaa 100644 --- a/scripts/test_setup.py +++ b/scripts/test_setup.py @@ -1,6 +1,6 @@ #! /usr/bin/python -import platform import subprocess +import os import sys def _execute(*args, **kwargs): @@ -9,17 +9,21 @@ sys.exit(result) if __name__ == '__main__': - python_version = platform.python_version() + python_version = sys.version_info deps = [ - "execnet", - "Jinja2", + "execnet>=1.0.9", "nose", "pyzmq", "sqlalchemy", ] - if python_version < "2.7": + if python_version < (2, 7): deps.append("unittest2") + if (3, 2) <= python_version < (3, 3): + deps.append("markupsafe==0.15") + deps.append("Jinja2==2.6") + else: + deps.append("Jinja2") print("Setting up dependencies...") - _execute("pip install %s" % " ".join(deps), shell=True) + _execute([os.path.join(os.path.dirname(sys.executable), "pip"), "install"] + deps, shell=False) diff --git a/setup.py b/setup.py index 69c11e7..5cf2417 100644 --- a/setup.py +++ b/setup.py @@ -106,7 +106,7 @@ features['speedups'] = speedups setup( name='Logbook', - version='0.6.1-dev', + version='0.7.0', license='BSD', url='http://logbook.pocoo.org/', author='Armin Ronacher, Georg Brandl', diff --git a/tests/test_logbook.py b/tests/test_logbook.py index 412da02..e8fbe4a 100644 --- a/tests/test_logbook.py +++ b/tests/test_logbook.py @@ -28,6 +28,7 @@ from thread import get_ident except ImportError: from _thread import get_ident +import base64 __file_without_pyc__ = __file__ if __file_without_pyc__.endswith(".pyc"): @@ -253,7 +254,7 @@ def test_file_handler_unicode(self): with capturing_stderr_context() as captured: with self.thread_activation_strategy(logbook.FileHandler(self.filename)) as h: - self.log.info(u'\u0431') + self.log.info(u('\u0431')) self.assertFalse(captured.getvalue()) def test_file_handler_delay(self): @@ -352,7 +353,7 @@ self.assertEqual(f.readline().rstrip(), '[02:00] Third One') def test_mail_handler(self): - subject = u'\xf8nicode' + subject = u('\xf8nicode') handler = make_fake_mail_handler(subject=subject) with capturing_stderr_context() as fallback: with self.thread_activation_strategy(handler): @@ -360,7 +361,7 @@ try: 1 / 0 except Exception: - self.log.exception(u'Viva la Espa\xf1a') + self.log.exception(u('Viva la Espa\xf1a')) if not handler.mails: # if sending the mail failed, the reason should be on stderr @@ -371,16 +372,19 @@ mail = mail.replace("\r", "") self.assertEqual(sender, handler.from_addr) self.assert_('=?utf-8?q?=C3=B8nicode?=' in mail) - self.assertRegexpMatches(mail, 'Message type:\s+ERROR') - self.assertRegexpMatches(mail, 'Location:.*%s' % __file_without_pyc__) - self.assertRegexpMatches(mail, 'Module:\s+%s' % __name__) - self.assertRegexpMatches(mail, 'Function:\s+test_mail_handler') - body = u'Message:\n\nViva la Espa\xf1a' + header, data = mail.split("\n\n", 1) + if "Content-Transfer-Encoding: base64" in header: + data = base64.b64decode(data).decode("utf-8") + self.assertRegexpMatches(data, 'Message type:\s+ERROR') + self.assertRegexpMatches(data, 'Location:.*%s' % __file_without_pyc__) + self.assertRegexpMatches(data, 'Module:\s+%s' % __name__) + self.assertRegexpMatches(data, 'Function:\s+test_mail_handler') + body = u('Viva la Espa\xf1a') if sys.version_info < (3, 0): body = body.encode('utf-8') - self.assertIn(body, mail) - self.assertIn('\n\nTraceback (most', mail) - self.assertIn('1 / 0', mail) + self.assertIn(body, data) + self.assertIn('\nTraceback (most', data) + self.assertIn('1 / 0', data) self.assertIn('This is not mailed', fallback.getvalue()) def test_mail_handler_record_limits(self): @@ -478,8 +482,8 @@ except socket.error: self.fail('got timeout on socket') self.assertEqual(rv, ( - u'<12>%stestlogger: Syslog is weird\x00' % - (app_name and app_name + u':' or u'')).encode('utf-8')) + u('<12>%stestlogger: Syslog is weird\x00') % + (app_name and app_name + u(':') or u(''))).encode('utf-8')) def test_handler_processors(self): handler = make_fake_mail_handler(format_string='''\ @@ -679,6 +683,22 @@ self.assertFalse(handler.has_warning('bar', channel='Logger2')) self.assertFalse(outer_handler.has_warning('foo', channel='Logger1')) self.assert_(outer_handler.has_warning('bar', channel='Logger2')) + + def test_null_handler_filtering(self): + logger1 = logbook.Logger("1") + logger2 = logbook.Logger("2") + outer = logbook.TestHandler() + inner = logbook.NullHandler() + + inner.filter = lambda record, handler: record.dispatcher is logger1 + + with self.thread_activation_strategy(outer): + with self.thread_activation_strategy(inner): + logger1.warn("1") + logger2.warn("2") + + self.assertTrue(outer.has_warning("2", channel="2")) + self.assertFalse(outer.has_warning("1", channel="1")) def test_different_context_pushing(self): h1 = logbook.TestHandler(level=logbook.DEBUG) @@ -890,25 +910,36 @@ class LoggingCompatTestCase(LogbookTestCase): - def test_basic_compat(self): - from logging import getLogger + def test_basic_compat_with_level_setting(self): + self._test_basic_compat(True) + def test_basic_compat_without_level_setting(self): + self._test_basic_compat(False) + + def _test_basic_compat(self, set_root_logger_level): + import logging from logbook.compat import redirected_logging + # mimic the default logging setting + self.addCleanup(logging.root.setLevel, logging.root.level) + logging.root.setLevel(logging.WARNING) + name = 'test_logbook-%d' % randrange(1 << 32) - logger = getLogger(name) - with capturing_stderr_context() as captured: - redirector = redirected_logging() - redirector.start() - try: - logger.debug('This is from the old system') - logger.info('This is from the old system') - logger.warn('This is from the old system') - logger.error('This is from the old system') - logger.critical('This is from the old system') - finally: - redirector.end() + logger = logging.getLogger(name) + + with logbook.TestHandler(bubble=True) as handler: + with capturing_stderr_context() as captured: + with redirected_logging(set_root_logger_level): + logger.debug('This is from the old system') + logger.info('This is from the old system') + logger.warn('This is from the old system') + logger.error('This is from the old system') + logger.critical('This is from the old system') self.assertIn(('WARNING: %s: This is from the old system' % name), captured.getvalue()) + if set_root_logger_level: + self.assertEquals(handler.records[0].level, logbook.DEBUG) + else: + self.assertEquals(handler.records[0].level, logbook.WARNING) def test_redirect_logbook(self): import logging @@ -1081,20 +1112,34 @@ self.assertIn('WARNING: testlogger: here i am', caught.exception.args[0]) self.assertIn('this is irrelevant', test_handler.records[0].message) + def test_dedup_handler(self): + from logbook.more import DedupHandler + with logbook.TestHandler() as test_handler: + with DedupHandler(): + self.log.info('foo') + self.log.info('bar') + self.log.info('foo') + self.assertEqual(2, len(test_handler.records)) + self.assertIn('message repeated 2 times: foo', test_handler.records[0].message) + self.assertIn('message repeated 1 times: bar', test_handler.records[1].message) + class QueuesTestCase(LogbookTestCase): - def _get_zeromq(self): + def _get_zeromq(self, multi=False): from logbook.queues import ZeroMQHandler, ZeroMQSubscriber # Get an unused port tempsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - tempsock.bind(('localhost', 0)) + tempsock.bind(('127.0.0.1', 0)) host, unused_port = tempsock.getsockname() tempsock.close() # Retrieve the ZeroMQ handler and subscriber uri = 'tcp://%s:%d' % (host, unused_port) - handler = ZeroMQHandler(uri) - subscriber = ZeroMQSubscriber(uri) + if multi: + handler = [ZeroMQHandler(uri, multi=True) for _ in range(3)] + else: + handler = ZeroMQHandler(uri) + subscriber = ZeroMQSubscriber(uri, multi=multi) # Enough time to start time.sleep(0.1) return handler, subscriber @@ -1102,9 +1147,9 @@ @require_module('zmq') def test_zeromq_handler(self): tests = [ - u'Logging something', - u'Something with umlauts äöü', - u'Something else for good measure', + u('Logging something'), + u('Something with umlauts äöü'), + u('Something else for good measure'), ] handler, subscriber = self._get_zeromq() for test in tests: @@ -1115,6 +1160,22 @@ self.assertEqual(record.channel, self.log.name) @require_module('zmq') + def test_multi_zeromq_handler(self): + tests = [ + u('Logging something'), + u('Something with umlauts äöü'), + u('Something else for good measure'), + ] + handlers, subscriber = self._get_zeromq(multi=True) + for handler in handlers: + for test in tests: + with handler: + self.log.warn(test) + record = subscriber.recv() + self.assertEqual(record.message, test) + self.assertEqual(record.channel, self.log.name) + + @require_module('zmq') def test_zeromq_background_thread(self): handler, subscriber = self._get_zeromq() test_handler = logbook.TestHandler() @@ -1127,7 +1188,7 @@ # stop the controller. This will also stop the loop and join the # background process. Before that we give it a fraction of a second # to get all results - time.sleep(0.1) + time.sleep(0.2) controller.stop() self.assertTrue(test_handler.has_warning('This is a warning')) @@ -1347,16 +1408,16 @@ rv = to_safe_json([ None, 'foo', - u'jäger', + u('jäger'), 1, datetime(2000, 1, 1), - {'jäger1': 1, u'jäger2': 2, Bogus(): 3, 'invalid': object()}, + {'jäger1': 1, u('jäger2'): 2, Bogus(): 3, 'invalid': object()}, object() # invalid ]) self.assertEqual( - rv, [None, u'foo', u'jäger', 1, '2000-01-01T00:00:00Z', - {u('jäger1'): 1, u'jäger2': 2, u'bogus': 3, - u'invalid': None}, None]) + rv, [None, u('foo'), u('jäger'), 1, '2000-01-01T00:00:00Z', + {u('jäger1'): 1, u('jäger2'): 2, u('bogus'): 3, + u('invalid'): None}, None]) def test_datehelpers(self): from logbook.helpers import format_iso8601, parse_iso8601 diff --git a/tox.ini b/tox.ini index 2b52a28..d66c330 100644 --- a/tox.ini +++ b/tox.ini @@ -1,9 +1,9 @@ [tox] -envlist=py26,py27,py33,pypy,docs +envlist=py26,py27,py32,py33,py34,pypy,docs [testenv] commands= - python {toxinidir}/scripts/test_setup.py + {envpython} {toxinidir}/scripts/test_setup.py nosetests -w tests deps= nose