From 2bf2bfc9b4abb993b5d4b543a963e1b7d21881e7 Mon Sep 17 00:00:00 2001
From: Rapptz <rapptz@gmail.com>
Date: Wed, 17 Aug 2022 22:13:01 -0400
Subject: [PATCH] Add utils.setup_logging to help set up logging outside of
 Client.run

---
 discord/client.py            |  95 ++--------------------------
 discord/utils.py             | 117 +++++++++++++++++++++++++++++++++++
 docs/api.rst                 |   2 +
 docs/logging.rst             |  11 ++++
 examples/advanced_startup.py |   3 +
 5 files changed, 139 insertions(+), 89 deletions(-)

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'<t:{int(dt.timestamp())}>'
     return f'<t:{int(dt.timestamp())}:{style}>'
+
+
+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.