diff --git a/docs/changelog.rst b/docs/changelog.rst index 3509b93f..ef23bb6a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -71,8 +71,15 @@ Feature release. **Configuration** +- Add ``optional=True`` support to :class:`mopidy.config.Boolean`. + +**Logging** + - Fix proper decoding of exception messages that depends on the user's locale. +- Colorize logs depending on log level. This can be turned off with the new + :confval:`logging/color` configuration. (Fixes: :issue:`772`) + **Extension support** - Removed the :class:`~mopidy.ext.Extension` methods that were deprecated in diff --git a/docs/config.rst b/docs/config.rst index 35acc10d..f5f6bd19 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -107,6 +107,11 @@ Audio configuration Logging configuration --------------------- +.. confval:: logging/color + + Whether or not to colorize the console log based on log level. Defaults to + ``true``. + .. confval:: logging/console_format The log format used for informational logging. diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index c23707d8..3b63a1ab 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -15,6 +15,7 @@ from mopidy.utils import path, versioning logger = logging.getLogger(__name__) _logging_schema = ConfigSchema('logging') +_logging_schema['color'] = Boolean() _logging_schema['console_format'] = String() _logging_schema['debug_format'] = String() _logging_schema['debug_file'] = Path() diff --git a/mopidy/config/default.conf b/mopidy/config/default.conf index d56b7b28..6a900cf9 100644 --- a/mopidy/config/default.conf +++ b/mopidy/config/default.conf @@ -1,4 +1,5 @@ [logging] +color = true console_format = %(levelname)-8s %(message)s debug_format = %(levelname)-8s %(asctime)s [%(process)d:%(threadName)s] %(name)s\n %(message)s debug_file = mopidy.log diff --git a/mopidy/config/types.py b/mopidy/config/types.py index a6736371..4498cb67 100644 --- a/mopidy/config/types.py +++ b/mopidy/config/types.py @@ -151,7 +151,13 @@ class Boolean(ConfigValue): true_values = ('1', 'yes', 'true', 'on') false_values = ('0', 'no', 'false', 'off') + def __init__(self, optional=False): + self._required = not optional + def deserialize(self, value): + validators.validate_required(value, self._required) + if not value: + return None if value.lower() in self.true_values: return True elif value.lower() in self.false_values: diff --git a/mopidy/http/actor.py b/mopidy/http/actor.py index 484fc946..96c71c8c 100644 --- a/mopidy/http/actor.py +++ b/mopidy/http/actor.py @@ -14,6 +14,7 @@ import tornado.websocket from mopidy import models, zeroconf from mopidy.core import CoreListener from mopidy.http import handlers +from mopidy.utils import formatting logger = logging.getLogger(__name__) @@ -88,7 +89,8 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener): logger.debug( 'HTTP routes from extensions: %s', - list((l[0], l[1]) for l in request_handlers)) + formatting.indent('\n'.join( + '%r: %r' % (r[0], r[1]) for r in request_handlers))) return request_handlers def _get_app_request_handlers(self): diff --git a/mopidy/http/handlers.py b/mopidy/http/handlers.py index 18b56de2..048c9ddf 100644 --- a/mopidy/http/handlers.py +++ b/mopidy/http/handlers.py @@ -152,6 +152,9 @@ class ClientListHandler(tornado.web.RequestHandler): self.apps = apps self.statics = statics + def get_template_path(self): + return os.path.dirname(__file__) + def get(self): set_mopidy_headers(self) diff --git a/mopidy/mpd/actor.py b/mopidy/mpd/actor.py index 684b4968..52cf6746 100644 --- a/mopidy/mpd/actor.py +++ b/mopidy/mpd/actor.py @@ -1,11 +1,10 @@ from __future__ import unicode_literals import logging -import sys import pykka -from mopidy import zeroconf +from mopidy import exceptions, zeroconf from mopidy.core import CoreListener from mopidy.mpd import session from mopidy.utils import encoding, network, process @@ -34,10 +33,9 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): max_connections=config['mpd']['max_connections'], timeout=config['mpd']['connection_timeout']) except IOError as error: - logger.error( - 'MPD server startup failed: %s', + raise exceptions.FrontendError( + 'MPD server startup failed: %s' % encoding.locale_decode(error)) - sys.exit(1) logger.info('MPD server running at [%s]:%s', self.hostname, self.port) diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index cde07693..5d6d3635 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -3,6 +3,16 @@ from __future__ import unicode_literals import logging import logging.config import logging.handlers +import platform + + +LOG_LEVELS = { + -1: dict(root=logging.ERROR, mopidy=logging.WARNING), + 0: dict(root=logging.ERROR, mopidy=logging.INFO), + 1: dict(root=logging.WARNING, mopidy=logging.DEBUG), + 2: dict(root=logging.INFO, mopidy=logging.DEBUG), + 3: dict(root=logging.DEBUG, mopidy=logging.DEBUG), +} class DelayedHandler(logging.Handler): @@ -46,13 +56,41 @@ def setup_logging(config, verbosity_level, save_debug_log): _delayed_handler.release() -LOG_LEVELS = { - -1: dict(root=logging.ERROR, mopidy=logging.WARNING), - 0: dict(root=logging.ERROR, mopidy=logging.INFO), - 1: dict(root=logging.WARNING, mopidy=logging.DEBUG), - 2: dict(root=logging.INFO, mopidy=logging.DEBUG), - 3: dict(root=logging.DEBUG, mopidy=logging.DEBUG), -} +def setup_console_logging(config, verbosity_level): + if verbosity_level < min(LOG_LEVELS.keys()): + verbosity_level = min(LOG_LEVELS.keys()) + if verbosity_level > max(LOG_LEVELS.keys()): + verbosity_level = max(LOG_LEVELS.keys()) + + loglevels = config.get('loglevels', {}) + has_debug_loglevels = any([ + level < logging.INFO for level in loglevels.values()]) + + verbosity_filter = VerbosityFilter(verbosity_level, loglevels) + + if verbosity_level < 1 and not has_debug_loglevels: + log_format = config['logging']['console_format'] + else: + log_format = config['logging']['debug_format'] + formatter = logging.Formatter(log_format) + + if config['logging']['color']: + handler = ColorizingStreamHandler() + else: + handler = logging.StreamHandler() + handler.addFilter(verbosity_filter) + handler.setFormatter(formatter) + + logging.getLogger('').addHandler(handler) + + +def setup_debug_logging_to_file(config): + formatter = logging.Formatter(config['logging']['debug_format']) + handler = logging.handlers.RotatingFileHandler( + config['logging']['debug_file'], maxBytes=10485760, backupCount=3) + handler.setFormatter(formatter) + + logging.getLogger('').addHandler(handler) class VerbosityFilter(logging.Filter): @@ -72,35 +110,74 @@ class VerbosityFilter(logging.Filter): return record.levelno >= required_log_level -def setup_console_logging(config, verbosity_level): - if verbosity_level < min(LOG_LEVELS.keys()): - verbosity_level = min(LOG_LEVELS.keys()) - if verbosity_level > max(LOG_LEVELS.keys()): - verbosity_level = max(LOG_LEVELS.keys()) +class ColorizingStreamHandler(logging.StreamHandler): + """ + Stream handler which colorizes the log using ANSI escape sequences. - loglevels = config.get('loglevels', {}) - has_debug_loglevels = any([ - level < logging.INFO for level in loglevels.values()]) + Does nothing on Windows, which doesn't support ANSI escape sequences. - verbosity_filter = VerbosityFilter(verbosity_level, loglevels) + This implementation is based upon https://gist.github.com/vsajip/758430, + which is: - if verbosity_level < 1 and not has_debug_loglevels: - log_format = config['logging']['console_format'] - else: - log_format = config['logging']['debug_format'] - formatter = logging.Formatter(log_format) + Copyright (C) 2010-2012 Vinay Sajip. All rights reserved. + Licensed under the new BSD license. + """ - handler = logging.StreamHandler() - handler.addFilter(verbosity_filter) - handler.setFormatter(formatter) + color_map = { + 'black': 0, + 'red': 1, + 'green': 2, + 'yellow': 3, + 'blue': 4, + 'magenta': 5, + 'cyan': 6, + 'white': 7, + } - logging.getLogger('').addHandler(handler) + # Map logging levels to (background, foreground, bold/intense) + level_map = { + logging.DEBUG: (None, 'blue', False), + logging.INFO: (None, 'white', False), + logging.WARNING: (None, 'yellow', False), + logging.ERROR: (None, 'red', False), + logging.CRITICAL: ('red', 'white', True), + } + csi = '\x1b[' + reset = '\x1b[0m' + is_windows = platform.system() == 'Windows' -def setup_debug_logging_to_file(config): - formatter = logging.Formatter(config['logging']['debug_format']) - handler = logging.handlers.RotatingFileHandler( - config['logging']['debug_file'], maxBytes=10485760, backupCount=3) - handler.setFormatter(formatter) + @property + def is_tty(self): + isatty = getattr(self.stream, 'isatty', None) + return isatty and isatty() - logging.getLogger('').addHandler(handler) + def emit(self, record): + try: + message = self.format(record) + self.stream.write(message) + self.stream.write(getattr(self, 'terminator', '\n')) + self.flush() + except Exception: + self.handleError(record) + + def format(self, record): + message = logging.StreamHandler.format(self, record) + if not self.is_tty or self.is_windows: + return message + return self.colorize(message, record) + + def colorize(self, message, record): + if record.levelno in self.level_map: + bg, fg, bold = self.level_map[record.levelno] + params = [] + if bg in self.color_map: + params.append(str(self.color_map[bg] + 40)) + if fg in self.color_map: + params.append(str(self.color_map[fg] + 30)) + if bold: + params.append('1') + if params: + message = ''.join(( + self.csi, ';'.join(params), 'm', message, self.reset)) + return message diff --git a/tests/config/test_types.py b/tests/config/test_types.py index e4a6f22e..dfb439be 100644 --- a/tests/config/test_types.py +++ b/tests/config/test_types.py @@ -214,6 +214,10 @@ class BooleanTest(unittest.TestCase): self.assertEqual(b'false', result) self.assertIsInstance(result, bytes) + def test_deserialize_respects_optional(self): + value = types.Boolean(optional=True) + self.assertEqual(None, value.deserialize('')) + # TODO: test None or other invalid values into serialize?