diff --git a/discord/client.py b/discord/client.py index 8fc932e7f..6415d3536 100644 --- a/discord/client.py +++ b/discord/client.py @@ -27,8 +27,6 @@ from __future__ import annotations import asyncio import datetime import logging -import sys -import os from typing import ( Any, AsyncIterator, @@ -113,61 +111,6 @@ class _LoopSentinel: _loop: Any = _LoopSentinel() -def stream_supports_colour(stream: Any) -> bool: - is_a_tty = hasattr(stream, 'isatty') and stream.isatty() - if sys.platform != 'win32': - return is_a_tty - - # ANSICON checks for things like ConEmu - # WT_SESSION checks if this is Windows Terminal - # VSCode built-in terminal supports colour too - return is_a_tty and ('ANSICON' in os.environ or 'WT_SESSION' in os.environ or os.environ.get('TERM_PROGRAM') == 'vscode') - - -class _ColourFormatter(logging.Formatter): - - # ANSI codes are a bit weird to decipher if you're unfamiliar with them, so here's a refresher - # It starts off with a format like \x1b[XXXm where XXX is a semicolon separated list of commands - # The important ones here relate to colour. - # 30-37 are black, red, green, yellow, blue, magenta, cyan and white in that order - # 40-47 are the same except for the background - # 90-97 are the same but "bright" foreground - # 100-107 are the same as the bright ones but for the background. - # 1 means bold, 2 means dim, 0 means reset, and 4 means underline. - - LEVEL_COLOURS = [ - (logging.DEBUG, '\x1b[40;1m'), - (logging.INFO, '\x1b[34;1m'), - (logging.WARNING, '\x1b[33;1m'), - (logging.ERROR, '\x1b[31m'), - (logging.CRITICAL, '\x1b[41m'), - ] - - FORMATS = { - level: logging.Formatter( - f'\x1b[30;1m%(asctime)s\x1b[0m {colour}%(levelname)-8s\x1b[0m \x1b[35m%(name)s\x1b[0m %(message)s', - '%Y-%m-%d %H:%M:%S', - ) - for level, colour in LEVEL_COLOURS - } - - def format(self, record): - formatter = self.FORMATS.get(record.levelno) - if formatter is None: - formatter = self.FORMATS[logging.DEBUG] - - # Override the traceback to always print in red - if record.exc_info: - text = formatter.formatException(record.exc_info) - record.exc_text = f'\x1b[31m{text}\x1b[0m' - - output = formatter.format(record) - - # Remove the cache layer - record.exc_text = None - return output - - class Client: r"""Represents a client connection that connects to Discord. This class is used to interact with the Discord WebSocket and API. @@ -858,10 +801,6 @@ class Client: The default log level for the library's logger. This is only applied if the ``log_handler`` parameter is not ``None``. Defaults to ``logging.INFO``. - Note that the *root* logger will always be set to ``logging.INFO`` and this - only controls the library's log level. To control the root logger's level, - you can use ``logging.getLogger().setLevel(level)``. - .. versionadded:: 2.0 root_logger: :class:`bool` Whether to set up the root logger rather than the library logger. @@ -877,32 +816,13 @@ class Client: async with self: await self.start(token, reconnect=reconnect) - if log_level is MISSING: - log_level = logging.INFO - - if log_handler is MISSING: - log_handler = logging.StreamHandler() - - if log_formatter is MISSING: - if isinstance(log_handler, logging.StreamHandler) and stream_supports_colour(log_handler.stream): - log_formatter = _ColourFormatter() - else: - dt_fmt = '%Y-%m-%d %H:%M:%S' - log_formatter = logging.Formatter('[{asctime}] [{levelname:<8}] {name}: {message}', dt_fmt, style='{') - - logger = None if log_handler is not None: - library, _, _ = __name__.partition('.') - logger = logging.getLogger(library) - - log_handler.setFormatter(log_formatter) - logger.setLevel(log_level) - logger.addHandler(log_handler) - - if root_logger: - logger = logging.getLogger() - logger.setLevel(log_level) - logger.addHandler(log_handler) + utils.setup_logging( + handler=log_handler, + formatter=log_formatter, + level=log_level, + root=root_logger, + ) try: asyncio.run(runner()) @@ -911,9 +831,6 @@ class Client: # `asyncio.run` handles the loop cleanup # and `self.start` closes all sockets and the HTTPClient instance. return - finally: - if log_handler is not None and logger is not None: - logger.removeHandler(log_handler) # properties diff --git a/discord/utils.py b/discord/utils.py index 6e27aeac7..e49a78bfd 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -63,9 +63,11 @@ from operator import attrgetter from urllib.parse import urlencode import json import re +import os import sys import types import warnings +import logging import yarl @@ -91,6 +93,7 @@ __all__ = ( 'as_chunks', 'format_dt', 'MISSING', + 'setup_logging', ) DISCORD_EPOCH = 1420070400000 @@ -1180,3 +1183,117 @@ def format_dt(dt: datetime.datetime, /, style: Optional[TimestampStyle] = None) if style is None: return f'' return f'' + + +def stream_supports_colour(stream: Any) -> bool: + is_a_tty = hasattr(stream, 'isatty') and stream.isatty() + if sys.platform != 'win32': + return is_a_tty + + # ANSICON checks for things like ConEmu + # WT_SESSION checks if this is Windows Terminal + # VSCode built-in terminal supports colour too + return is_a_tty and ('ANSICON' in os.environ or 'WT_SESSION' in os.environ or os.environ.get('TERM_PROGRAM') == 'vscode') + + +class _ColourFormatter(logging.Formatter): + + # ANSI codes are a bit weird to decipher if you're unfamiliar with them, so here's a refresher + # It starts off with a format like \x1b[XXXm where XXX is a semicolon separated list of commands + # The important ones here relate to colour. + # 30-37 are black, red, green, yellow, blue, magenta, cyan and white in that order + # 40-47 are the same except for the background + # 90-97 are the same but "bright" foreground + # 100-107 are the same as the bright ones but for the background. + # 1 means bold, 2 means dim, 0 means reset, and 4 means underline. + + LEVEL_COLOURS = [ + (logging.DEBUG, '\x1b[40;1m'), + (logging.INFO, '\x1b[34;1m'), + (logging.WARNING, '\x1b[33;1m'), + (logging.ERROR, '\x1b[31m'), + (logging.CRITICAL, '\x1b[41m'), + ] + + FORMATS = { + level: logging.Formatter( + f'\x1b[30;1m%(asctime)s\x1b[0m {colour}%(levelname)-8s\x1b[0m \x1b[35m%(name)s\x1b[0m %(message)s', + '%Y-%m-%d %H:%M:%S', + ) + for level, colour in LEVEL_COLOURS + } + + def format(self, record): + formatter = self.FORMATS.get(record.levelno) + if formatter is None: + formatter = self.FORMATS[logging.DEBUG] + + # Override the traceback to always print in red + if record.exc_info: + text = formatter.formatException(record.exc_info) + record.exc_text = f'\x1b[31m{text}\x1b[0m' + + output = formatter.format(record) + + # Remove the cache layer + record.exc_text = None + return output + + +def setup_logging( + *, + handler: logging.Handler = MISSING, + formatter: logging.Formatter = MISSING, + level: int = MISSING, + root: bool = True, +) -> None: + """A helper function to setup logging. + + This is superficially similar to :func:`logging.basicConfig` but + uses different defaults and a colour formatter if the stream can + display colour. + + This is used by the :class:`~discord.Client` to set up logging + if ``log_handler`` is not ``None``. + + .. versionadded:: 2.0 + + Parameters + ----------- + handler: :class:`logging.Handler` + The log handler to use for the library's logger. + + The default log handler if not provided is :class:`logging.StreamHandler`. + formatter: :class:`logging.Formatter` + The formatter to use with the given log handler. If not provided then it + defaults to a colour based logging formatter (if available). If colour + is not available then a simple logging formatter is provided. + level: :class:`int` + The default log level for the library's logger. Defaults to ``logging.INFO``. + root: :class:`bool` + Whether to set up the root logger rather than the library logger. + Unlike the default for :class:`~discord.Client`, this defaults to ``True``. + """ + + if level is MISSING: + level = logging.INFO + + if handler is MISSING: + handler = logging.StreamHandler() + + if formatter is MISSING: + if isinstance(handler, logging.StreamHandler) and stream_supports_colour(handler.stream): + formatter = _ColourFormatter() + else: + dt_fmt = '%Y-%m-%d %H:%M:%S' + formatter = logging.Formatter('[{asctime}] [{levelname:<8}] {name}: {message}', dt_fmt, style='{') + + if root: + logger = logging.getLogger() + else: + library, _, _ = __name__.partition('.') + logger = logging.getLogger(library) + + handler.setFormatter(formatter) + logger.setLevel(level) + logger.addHandler(handler) diff --git a/docs/api.rst b/docs/api.rst index 4f0fbe95a..bae97b298 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1370,6 +1370,8 @@ Utility Functions .. autofunction:: discord.utils.get +.. autofunction:: discord.utils.setup_logging + .. autofunction:: discord.utils.snowflake_time .. autofunction:: discord.utils.time_snowflake diff --git a/docs/logging.rst b/docs/logging.rst index 9cbb81798..739ca0ebe 100644 --- a/docs/logging.rst +++ b/docs/logging.rst @@ -43,6 +43,17 @@ Likewise, configuring the log level to ``logging.DEBUG`` is also possible: This is recommended, especially at verbose levels such as ``DEBUG``, as there are a lot of events logged and it would clog the stderr of your program. +If you want to setup logging using the library provided configuration without using :meth:`Client.run`, you can use :func:`discord.utils.setup_logging`: + +.. code-block:: python3 + + import discord + + discord.utils.setup_logging() + + # or, for example + discord.utils.setup_logging(level=logging.INFO, root=False) + More advanced setups are possible with the :mod:`logging` module. The example below configures a rotating file handler that outputs DEBUG output for everything the library outputs, except for HTTP requests: .. code-block:: python3 diff --git a/examples/advanced_startup.py b/examples/advanced_startup.py index 5d7d24bbe..c521841df 100644 --- a/examples/advanced_startup.py +++ b/examples/advanced_startup.py @@ -75,6 +75,9 @@ async def main(): handler.setFormatter(formatter) logger.addHandler(handler) + # Alternatively, you could use: + # discord.utils.setup_logging(handler=handler, root=False) + # One of the reasons to take over more of the process though # is to ensure use with other libraries or tools which also require their own cleanup.