Skip to content

Commit

Permalink
Add utils.setup_logging to help set up logging outside of Client.run
Browse files Browse the repository at this point in the history
  • Loading branch information
Rapptz committed Aug 18, 2022
1 parent 1c7747f commit 2bf2bfc
Show file tree
Hide file tree
Showing 5 changed files with 139 additions and 89 deletions.
95 changes: 6 additions & 89 deletions discord/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,6 @@
import asyncio
import datetime
import logging
import sys
import os
from typing import (
Any,
AsyncIterator,
Expand Down Expand Up @@ -113,61 +111,6 @@ def __getattr__(self, attr: str) -> None:
_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.
Expand Down Expand Up @@ -858,10 +801,6 @@ def run(
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.
Expand All @@ -877,32 +816,13 @@ async def runner():
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())
Expand All @@ -911,9 +831,6 @@ async def runner():
# `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

Expand Down
117 changes: 117 additions & 0 deletions discord/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,11 @@
from urllib.parse import urlencode
import json
import re
import os
import sys
import types
import warnings
import logging

import yarl

Expand All @@ -91,6 +93,7 @@
'as_chunks',
'format_dt',
'MISSING',
'setup_logging',
)

DISCORD_EPOCH = 1420070400000
Expand Down Expand Up @@ -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)
2 changes: 2 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions docs/logging.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions examples/advanced_startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down

0 comments on commit 2bf2bfc

Please sign in to comment.