Repository: hikari-py/hikari
Pull request #1485 (2023-01-25 13:02:25)

Improve logging consistency and add support for `.ini` files
### Summary
<!-- Small summary of the merge request -->

### Checklist
<!-- Make sure to tick all the following boxes by putting an `x` in between (like this `[x]`) -->
- [x] I have run `nox` and all the pipelines have passed.
- [x] I have made unittests according to the code I have added/modified/deleted.



Patch:
diff --git a/changes/1480.bugfix.md b/changes/1480.bugfix.md
new file mode 100644
index 00000000..4226a0ca
--- /dev/null
+++ b/changes/1480.bugfix.md
@@ -0,0 +1,2 @@
+Fix `hikari.webhooks.ChannelFollowWebhook` not including source in all cases.
+- `source_channel` and `source_guild` will be `None` instead.
diff --git a/changes/1485.breaking.md b/changes/1485.breaking.md
new file mode 100644
index 00000000..db1d4823
--- /dev/null
+++ b/changes/1485.breaking.md
@@ -0,0 +1,1 @@
+Default logging to `sys.stdout` stream to bring more in-line with banner output.
diff --git a/changes/1485.bugfix.md b/changes/1485.bugfix.md
new file mode 100644
index 00000000..899ae373
--- /dev/null
+++ b/changes/1485.bugfix.md
@@ -0,0 +1,1 @@
+Fix colour logging not occurring on specific terminals (ie, Pycharm).
diff --git a/changes/1485.feature.md b/changes/1485.feature.md
new file mode 100644
index 00000000..3d47417e
--- /dev/null
+++ b/changes/1485.feature.md
@@ -0,0 +1,1 @@
+Support loading files through `logging.config.fileConfig` in `init_logging`.
diff --git a/hikari/impl/bot.py b/hikari/impl/bot.py
index e597d4d3..220008d1 100644
--- a/hikari/impl/bot.py
+++ b/hikari/impl/bot.py
@@ -57,6 +57,7 @@
 
 if typing.TYPE_CHECKING:
     import concurrent.futures
+    import os
 
     from hikari import channels
     from hikari import guilds
@@ -194,30 +195,30 @@ class GatewayBot(traits.GatewayBotAware):
            we should also chunk small guilds if the presences are not declared).
         4. The members cache is enabled or there are listeners for the
            `MemberChunkEvent`.
-    logs : typing.Union[None, LoggerLevel, typing.Dict[str, typing.Any]]
-        Defaults to `"INFO"`.
+    logs : typing.Union[None, str, int, typing.Dict[str, typing.Any], os.PathLike]
+        The flavour to set the logging to.
 
-        If `None`, then the Python logging system is left uninitialized
-        on startup, and you will need to configure it manually to view most
-        logs that are output by components of this library.
+        This can be `None` to not enable logging automatically.
 
-        If one of the valid values in a `LoggerLevel`, then this will match a
-        call to `colorlog.basicConfig` (a facade for `logging.basicConfig` with
-        additional conduit for enabling coloured logging levels) with the
-        `level` kwarg matching this value.
+        If you pass a `str` or a `int`, it is interpreted as
+        the global logging level to use, and should match one of `"DEBUG"`,
+        `"INFO"`, `"WARNING"`, `"ERROR"` or `"CRITICAL"`.
+        The configuration will be set up to use a `colorlog` coloured logger,
+        and to use a sane logging format strategy. The output will be written
+        to `sys.stdout` using this configuration.
 
-        If a `typing.Dict[str, typing.Any]` equivalent, then this value is
-        passed to `logging.config.dictConfig` to allow the user to provide a
-        specialized logging configuration of their choice. If any handlers are
-        defined in the dict, default handlers will not be setup.
+        If you pass a `dict`, it is treated as the mapping to pass to
+        `logging.config.dictConfig`. If the dict defines any handlers, default
+        handlers will not be setup if `incremental` is not specified.
 
-        As a side note, you can always opt to leave this on the default value
-        and then use an incremental `logging.config.dictConfig` that applies
-        any additional changes on top of the base configuration, if you prefer.
-        An example of can be found in the `Example` section.
+        If you pass a `str` to an existing file or a `os.PathLike`, it is
+        interpreted as the file to load config from using `logging.config.fileConfig`.
 
         Note that `"TRACE_HIKARI"` is a library-specific logging level
         which is expected to be more verbose than `"DEBUG"`.
+
+        Defaults to `"INFO"`.
+
     max_rate_limit : float
         The max number of seconds to backoff for when rate limited. Anything
         greater than this will instead raise an error.
@@ -245,25 +246,36 @@ class GatewayBot(traits.GatewayBotAware):
 
     Examples
     --------
-    Setting up logging using a dictionary configuration:
+    Simple logging setup:
 
     .. code-block:: python
 
-        import os
+        hikari.GatewayBot("TOKEN", logs="INFO")  # Registered logging level
+        # or
+        hikari.GatewayBot("TOKEN", logs=20)  # Logging level as an int
+
+    File config:
 
-        import hikari
+    .. code-block:: python
 
-        # We want to make gateway logs output as DEBUG, and TRACE for all ratelimit content.
-        bot = hikari.GatewayBot(
-            token=os.environ["BOT_TOKEN"],
+        # See https://docs.python.org/3/library/logging.config.html#configuration-file-format for more info
+        hikari.GatewayBot("TOKEN", logs="path/to/file.ini")
+
+    Setting up logging through a dict config:
+
+    .. code-block:: python
+
+        # See https://docs.python.org/3/library/logging.config.html#dictionary-schema-details for more info
+        hikari.GatewayBot(
+            "TOKEN",
             logs={
                 "version": 1,
-                "incremental": True,
+                "incremental": True,  # In incremental setups, the default stream handler will be setup
                 "loggers": {
                     "hikari.gateway": {"level": "DEBUG"},
                     "hikari.ratelimits": {"level": "TRACE_HIKARI"},
                 },
-            },
+            }
         )
     """
 
@@ -307,7 +319,7 @@ def __init__(
         http_settings: typing.Optional[config_impl.HTTPSettings] = None,
         intents: intents_.Intents = intents_.Intents.ALL_UNPRIVILEGED,
         auto_chunk_members: bool = True,
-        logs: typing.Union[None, int, str, typing.Dict[str, typing.Any]] = "INFO",
+        logs: typing.Union[None, str, int, typing.Dict[str, typing.Any], os.PathLike[str]] = "INFO",
         max_rate_limit: float = 300.0,
         max_retries: int = 3,
         proxy_settings: typing.Optional[config_impl.ProxySettings] = None,
diff --git a/hikari/impl/entity_factory.py b/hikari/impl/entity_factory.py
index 497b927f..aae4354c 100644
--- a/hikari/impl/entity_factory.py
+++ b/hikari/impl/entity_factory.py
@@ -3524,21 +3524,24 @@ def deserialize_channel_follower_webhook(
         self, payload: data_binding.JSONObject
     ) -> webhook_models.ChannelFollowerWebhook:
         application_id: typing.Optional[snowflakes.Snowflake] = None
-        if (raw_application_id := payload.get("application_id")) is not None:
+        if raw_application_id := payload.get("application_id"):
             application_id = snowflakes.Snowflake(raw_application_id)
 
-        raw_source_channel = payload["source_channel"]
-        # In this case the channel type isn't provided as we can safely
-        # assume it's a news channel.
-        raw_source_channel["type"] = channel_models.ChannelType.GUILD_NEWS
-        source_channel = self.deserialize_partial_channel(raw_source_channel)
-        source_guild_payload = payload["source_guild"]
-        source_guild = guild_models.PartialGuild(
-            app=self._app,
-            id=snowflakes.Snowflake(source_guild_payload["id"]),
-            name=source_guild_payload["name"],
-            icon_hash=source_guild_payload.get("icon"),
-        )
+        source_channel: typing.Optional[channel_models.PartialChannel] = None
+        if raw_source_channel := payload.get("source_channel"):
+            # In this case the channel type isn't provided as we can safely
+            # assume it's a news channel.
+            raw_source_channel.setdefault("type", channel_models.ChannelType.GUILD_NEWS)
+            source_channel = self.deserialize_partial_channel(raw_source_channel)
+
+        source_guild: typing.Optional[guild_models.PartialGuild] = None
+        if source_guild_payload := payload.get("source_guild"):
+            source_guild = guild_models.PartialGuild(
+                app=self._app,
+                id=snowflakes.Snowflake(source_guild_payload["id"]),
+                name=source_guild_payload["name"],
+                icon_hash=source_guild_payload.get("icon"),
+            )
 
         return webhook_models.ChannelFollowerWebhook(
             app=self._app,
diff --git a/hikari/impl/rest_bot.py b/hikari/impl/rest_bot.py
index 8ee35fbc..8a56ed90 100644
--- a/hikari/impl/rest_bot.py
+++ b/hikari/impl/rest_bot.py
@@ -43,6 +43,7 @@
 
 if typing.TYPE_CHECKING:
     import concurrent.futures
+    import os
     import socket as socket_
     import ssl
 
@@ -127,30 +128,29 @@ class RESTBot(traits.RESTBotAware, interaction_server_.InteractionServer):
         customise functionality such as whether SSL-verification is enabled,
         what timeouts `aiohttp` should expect to use for requests, and behavior
         regarding HTTP-redirects.
-    logs : typing.Union[None, LoggerLevel, typing.Dict[str, typing.Any]]
-        Defaults to `"INFO"`.
+    logs : typing.Union[None, int, str, typing.Dict[str, typing.Any], os.PathLike[str]]
+        The flavour to set the logging to.
 
-        If `None`, then the Python logging system is left uninitialized
-        on startup, and you will need to configure it manually to view most
-        logs that are output by components of this library.
+        This can be `None` to not enable logging automatically.
 
-        If one of the valid values in a `LoggerLevel`, then this will match a
-        call to `colorlog.basicConfig` (a facade for `logging.basicConfig` with
-        additional conduit for enabling coloured logging levels) with the
-        `level` kwarg matching this value.
+        If you pass a `str` or a `int`, it is interpreted as
+        the global logging level to use, and should match one of `"DEBUG"`,
+        `"INFO"`, `"WARNING"`, `"ERROR"` or `"CRITICAL"`.
+        The configuration will be set up to use a `colorlog` coloured logger,
+        and to use a sane logging format strategy. The output will be written
+        to `sys.stdout` using this configuration.
 
-        If a `typing.Dict[str, typing.Any]` equivalent, then this value is
-        passed to `logging.config.dictConfig` to allow the user to provide a
-        specialized logging configuration of their choice. If any handlers are
-        defined in the dict, default handlers will not be setup.
+        If you pass a `dict`, it is treated as the mapping to pass to
+        `logging.config.dictConfig`. If the dict defines any handlers, default
+        handlers will not be setup if `incremental` is not specified.
 
-        As a side note, you can always opt to leave this on the default value
-        and then use an incremental `logging.config.dictConfig` that applies
-        any additional changes on top of the base configuration, if you prefer.
-        An example of can be found in the `Example` section.
+        If you pass a `str` to an existing file or a `os.PathLike`, it is
+        interpreted as the file to load config from using `logging.config.fileConfig`.
 
         Note that `"TRACE_HIKARI"` is a library-specific logging level
         which is expected to be more verbose than `"DEBUG"`.
+
+        Defaults to `"INFO"`.
     max_rate_limit : float
         The max number of seconds to backoff for when rate limited. Anything
         greater than this will instead raise an error.
@@ -186,6 +186,40 @@ class RESTBot(traits.RESTBotAware, interaction_server_.InteractionServer):
     ValueError
         * If `token_type` is provided when a token strategy is passed for `token`.
         * if `token_type` is left as `None` when a string is passed for `token`.
+
+    Examples
+    --------
+    Simple logging setup:
+
+    .. code-block:: python
+
+        hikari.RESTBot("TOKEN", logs="INFO")  # Registered logging level
+        # or
+        hikari.RESTBot("TOKEN", logs=20)  # Logging level as an int
+
+    File config:
+
+    .. code-block:: python
+
+        # See https://docs.python.org/3/library/logging.config.html#configuration-file-format for more info
+        hikari.RESTBot("TOKEN", logs="path/to/file.ini")
+
+    Setting up logging through a dict config:
+
+    .. code-block:: python
+
+        # See https://docs.python.org/3/library/logging.config.html#dictionary-schema-details for more info
+        hikari.RESTBot(
+            "TOKEN",
+            logs={
+                "version": 1,
+                "incremental": True,  # In incremental setups, the default stream handler will be setup
+                "loggers": {
+                    "hikari.gateway": {"level": "DEBUG"},
+                    "hikari.ratelimits": {"level": "TRACE_HIKARI"},
+                },
+            }
+        )
     """
 
     __slots__: typing.Sequence[str] = (
@@ -213,7 +247,7 @@ def __init__(
         executor: typing.Optional[concurrent.futures.Executor] = None,
         force_color: bool = False,
         http_settings: typing.Optional[config_impl.HTTPSettings] = None,
-        logs: typing.Union[None, int, str, typing.Dict[str, typing.Any]] = "INFO",
+        logs: typing.Union[None, str, int, typing.Dict[str, typing.Any], os.PathLike[str]] = "INFO",
         max_rate_limit: float = 300.0,
         max_retries: int = 3,
         proxy_settings: typing.Optional[config_impl.ProxySettings] = None,
@@ -234,7 +268,7 @@ def __init__(
         executor: typing.Optional[concurrent.futures.Executor] = None,
         force_color: bool = False,
         http_settings: typing.Optional[config_impl.HTTPSettings] = None,
-        logs: typing.Union[None, int, str, typing.Dict[str, typing.Any]] = "INFO",
+        logs: typing.Union[None, str, int, typing.Dict[str, typing.Any], os.PathLike[str]] = "INFO",
         max_rate_limit: float = 300.0,
         max_retries: int = 3,
         proxy_settings: typing.Optional[config_impl.ProxySettings] = None,
@@ -254,7 +288,7 @@ def __init__(
         executor: typing.Optional[concurrent.futures.Executor] = None,
         force_color: bool = False,
         http_settings: typing.Optional[config_impl.HTTPSettings] = None,
-        logs: typing.Union[None, int, str, typing.Dict[str, typing.Any]] = "INFO",
+        logs: typing.Union[None, str, int, typing.Dict[str, typing.Any], os.PathLike[str]] = "INFO",
         max_rate_limit: float = 300.0,
         max_retries: int = 3,
         proxy_settings: typing.Optional[config_impl.ProxySettings] = None,
diff --git a/hikari/internal/ux.py b/hikari/internal/ux.py
index b7e0c459..a1a78127 100644
--- a/hikari/internal/ux.py
+++ b/hikari/internal/ux.py
@@ -36,6 +36,7 @@
 import logging
 import logging.config
 import os
+import pathlib
 import platform
 import re
 import string
@@ -67,33 +68,44 @@
 
 
 def init_logging(
-    flavor: typing.Union[None, str, int, typing.Dict[str, typing.Any]],
+    flavor: typing.Union[None, str, int, typing.Dict[str, typing.Any], os.PathLike[str]],
     allow_color: bool,
     force_color: bool,
 ) -> None:
-    """Attempt to initialize logging for the user.
+    """Initialize logging for the user.
 
-    If any handlers already exist, this is ignored entirely. This ensures the
-    user can use any existing logging configuration without us interfering.
-    You can manually disable this by passing `None` as the `flavor` parameter.
+    .. note::
+        If any handlers already exist, some opinionated defaults will be configured
+        (mostly do to with logging efficiency and warning logging), but existing
+        handlers will not be overwritten. You can disable this by passing
+        `None` as the `flavor` parameter.
+
+    .. warning::
+        This function is blocking!
 
     Parameters
     ----------
-    flavor : typing.Optional[None, str, typing.Dict[str, typing.Any]]
+    flavor : typing.Optional[None, str, int, typing.Dict[str, typing.Any], os.PathLike[str]]
         The hint for configuring logging.
 
         This can be `None` to not enable logging automatically.
 
         If you pass a `str` or a `int`, it is interpreted as
         the global logging level to use, and should match one of `"DEBUG"`,
-        `"INFO"`, `"WARNING"`, `"ERROR"` or `"CRITICAL"`, if `str`.
+        `"INFO"`, `"WARNING"`, `"ERROR"` or `"CRITICAL"`.
         The configuration will be set up to use a `colorlog` coloured logger,
         and to use a sane logging format strategy. The output will be written
-        to `sys.stderr` using this configuration.
+        to `sys.stdout` using this configuration.
 
         If you pass a `dict`, it is treated as the mapping to pass to
         `logging.config.dictConfig`. If the dict defines any handlers, default
-        handlers will not be setup.
+        handlers will not be setup if `incremental` is not specified.
+
+        If you pass a `str` to an existing file or a `os.PathLike`, it is
+        interpreted as the file to load config from using `logging.config.fileConfig`.
+
+        Note that `"TRACE_HIKARI"` is a library-specific logging level
+        which is expected to be more verbose than `"DEBUG"`.
     allow_color : bool
         If `False`, no colour is allowed. If `True`, the
         output device must be supported for this to return `True`.
@@ -101,6 +113,39 @@ def init_logging(
         If `True`, return `True` always, otherwise only
         return `True` if the device supports colour output and the
         `allow_color` flag is not `False`.
+
+    Examples
+    --------
+    Simple logging setup:
+
+    .. code-block:: python
+
+        init_logging("INFO")  # Registered logging level
+        # or
+        init_logging(20)  # Logging level as an int
+
+    File config:
+
+    .. code-block:: python
+
+        # See https://docs.python.org/3/library/logging.config.html#configuration-file-format for more info
+        init_logging("path/to/file.ini")
+
+    Setting up logging through a dict config:
+
+    .. code-block:: python
+
+        # See https://docs.python.org/3/library/logging.config.html#dictionary-schema-details for more info
+        init_logging(
+            {
+                "version": 1,
+                "incremental": True,  # In incremental setups, the default stream handler will be setup
+                "loggers": {
+                    "hikari.gateway": {"level": "DEBUG"},
+                    "hikari.ratelimits": {"level": "TRACE_HIKARI"},
+                },
+            }
+        )
     """
     # One observation that has been repeatedly made from seeing beginners writing
     # bots in Python is that most people seem to have no idea what logging is or
@@ -113,41 +158,79 @@ def init_logging(
     # As part of Hikari's set of opinionated defaults, we turn logging on with
     # a desirable format that is coloured in an effort to draw the user's attention
     # to it, rather than encouraging them to ignore it.
+    if flavor is None:
+        return
 
-    if len(logging.root.handlers) != 0 or flavor is None:
-        # Skip, the user is using something else to configure their logging.
+    # Apparently this makes logging even more efficient!
+    logging.logThreads = False
+    logging.logProcesses = False
+    logging.logMultiprocessing = False
+
+    # DeprecationWarning is disabled by default, but it's useful to have enabled
+    warnings.simplefilter("always", DeprecationWarning)
+    logging.captureWarnings(True)
+
+    if len(logging.root.handlers) != 0:
+        # Something else is already setup, so don't overwrite it
+        return
+
+    if isinstance(flavor, str):
+        # Syntactic sugar to allow paths as strings
+        path = pathlib.Path(flavor)
+        if path.expanduser().exists():
+            flavor = path
+
+    # Config through file
+    if isinstance(flavor, os.PathLike):
+        try:
+            logging.config.fileConfig(flavor)
+        except Exception as ex:
+            raise RuntimeError("A problem occurred while trying to setup logging through file configuration") from ex
         return
 
+    # Config through dict
     if isinstance(flavor, dict):
-        logging.config.dictConfig(flavor)
+        try:
+            logging.config.dictConfig(flavor)
+        except Exception as ex:
+            raise RuntimeError("A problem occurred while trying to setup logging through dict configuration") from ex
 
-        if flavor.get("handlers"):
-            # Handlers are defined => don't configure the default ones
+        if not flavor.get("incremental"):
+            # Non-incremental setup, return
             return
 
         flavor = None
 
-    # Apparently this makes logging even more efficient!
-    logging.logThreads = False
-    logging.logProcesses = False
-
-    if supports_color(allow_color, force_color):
-        colorlog.basicConfig(
-            level=flavor,
-            format="%(log_color)s%(bold)s%(levelname)-1.1s%(thin)s %(asctime)23.23s %(bold)s%(name)s: "
-            "%(thin)s%(message)s%(reset)s",
-            stream=sys.stderr,
-        )
-    else:
-        logging.basicConfig(
-            level=flavor,
-            format="%(levelname)-1.1s %(asctime)23.23s %(name)s: %(message)s",
-            stream=sys.stderr,
-        )
+    # Default config (stream)
+    try:
+        if supports_color(allow_color, force_color):
+            logging.basicConfig(level=flavor, stream=sys.stdout)
+            handler = logging.root.handlers[0]
+            handler.setFormatter(
+                colorlog.formatter.ColoredFormatter(
+                    fmt=(
+                        "%(log_color)s%(bold)s%(levelname)-1.1s%(thin)s "  # Logging level
+                        "%(asctime)23.23s "  # Date and time
+                        "%(bold)s%(name)s: "  # Logger name
+                        "%(thin)s%(message)s%(reset)s"  # Message
+                    ),
+                    force_color=True,
+                )
+            )
+        else:
+            logging.basicConfig(
+                level=flavor,
+                stream=sys.stdout,
+                format=(
+                    "%(levelname)-1.1s "  # Logging level
+                    "%(asctime)23.23s "  # Date and time
+                    "%(name)s: "  # Logger name
+                    "%(message)s"  # Message
+                ),
+            )
 
-    # DeprecationWarning is disabled by default, but it's useful to have enabled
-    warnings.simplefilter("always", DeprecationWarning)
-    logging.captureWarnings(True)
+    except Exception as ex:
+        raise RuntimeError("A problem occurred while trying to setup default logging configuration") from ex
 
 
 _UNCONDITIONAL_ANSI_FLAGS: typing.Final[typing.FrozenSet[str]] = frozenset(("PYCHARM_HOSTED", "WT_SESSION"))
@@ -179,6 +262,9 @@ def print_banner(
     .. note::
         The `banner.txt` must be in the root folder of the package.
 
+    .. warning::
+        This function is blocking!
+
     Parameters
     ----------
     package : typing.Optional[str]
@@ -237,9 +323,9 @@ def print_banner(
         for code in colorlog.escape_codes.escape_codes:
             args[code] = ""
 
-    with open(sys.stdout.fileno(), "w", encoding="utf-8", closefd=False) as stdout:
-        stdout.write(string.Template(raw_banner).safe_substitute(args))
-        stdout.flush()
+    banner_str = string.Template(raw_banner).safe_substitute(args)
+    sys.stdout.buffer.write(banner_str.encode("utf-8"))
+    sys.stdout.flush()
 
 
 def warn_if_not_optimized(suppress: bool) -> None:
diff --git a/hikari/webhooks.py b/hikari/webhooks.py
index 31d0605c..3f80903c 100644
--- a/hikari/webhooks.py
+++ b/hikari/webhooks.py
@@ -855,11 +855,19 @@ class ChannelFollowerWebhook(PartialWebhook):
         This will be `None` when received within an audit log.
     """
 
-    source_channel: channels_.PartialChannel = attr.field(eq=False, hash=False, repr=True)
-    """The partial object of the channel this webhook is following."""
+    source_channel: typing.Optional[channels_.PartialChannel] = attr.field(eq=False, hash=False, repr=True)
+    """The partial object of the channel this webhook is following.
 
-    source_guild: guilds_.PartialGuild = attr.field(eq=False, hash=False, repr=True)
-    """The partial object of the guild this webhook is following."""
+    This will be `None` when the user that followed the channel is no
+    longer in the source guild or has lost access to the source channel.
+    """
+
+    source_guild: typing.Optional[guilds_.PartialGuild] = attr.field(eq=False, hash=False, repr=True)
+    """The partial object of the guild this webhook is following.
+
+    This will be `None` when the user that followed the channel is no
+    longer in the source guild or has lost access to the source channel.
+    """
 
     async def delete(self) -> None:
         """Delete this webhook.


Test patch:
diff --git a/tests/hikari/impl/test_entity_factory.py b/tests/hikari/impl/test_entity_factory.py
index 3929e750..bd90fd6b 100644
--- a/tests/hikari/impl/test_entity_factory.py
+++ b/tests/hikari/impl/test_entity_factory.py
@@ -6870,24 +6870,39 @@ def test_deserialize_channel_follower_webhook(
         assert webhook.source_guild.icon_hash == "bb71f469c158984e265093a81b3397fb"
         assert isinstance(webhook.source_guild, guild_models.PartialGuild)
 
-        assert webhook.source_channel == entity_factory_impl.deserialize_partial_channel(
-            {"id": "5618852344134324", "name": "announcements", "type": channel_models.ChannelType.GUILD_NEWS}
-        )
+        assert webhook.source_channel.id == 5618852344134324
+        assert webhook.source_channel.name == "announcements"
+        assert webhook.source_channel.type == channel_models.ChannelType.GUILD_NEWS
+        assert isinstance(webhook.source_channel, channel_models.PartialChannel)
+
         assert webhook.author == entity_factory_impl.deserialize_user(user_payload)
         assert isinstance(webhook, webhook_models.ChannelFollowerWebhook)
 
     def test_deserialize_channel_follower_webhook_without_optional_fields(
-        self, entity_factory_impl, mock_app, follower_webhook_payload, user_payload
+        self, entity_factory_impl, mock_app, follower_webhook_payload
     ):
         follower_webhook_payload["avatar"] = None
         del follower_webhook_payload["user"]
         del follower_webhook_payload["application_id"]
+        del follower_webhook_payload["source_guild"]
+        del follower_webhook_payload["source_channel"]
 
         webhook = entity_factory_impl.deserialize_channel_follower_webhook(follower_webhook_payload)
 
         assert webhook.avatar_hash is None
         assert webhook.application_id is None
         assert webhook.author is None
+        assert webhook.source_guild is None
+        assert webhook.source_channel is None
+
+    def test_deserialize_channel_follower_webhook_doesnt_set_source_channel_type_if_set(
+        self, entity_factory_impl, mock_app, follower_webhook_payload
+    ):
+        follower_webhook_payload["source_channel"]["type"] = channel_models.ChannelType.GUILD_VOICE
+
+        webhook = entity_factory_impl.deserialize_channel_follower_webhook(follower_webhook_payload)
+
+        assert webhook.source_channel.type == channel_models.ChannelType.GUILD_VOICE
 
     def test_deserialize_application_webhook(self, entity_factory_impl, mock_app, application_webhook_payload):
         webhook = entity_factory_impl.deserialize_application_webhook(application_webhook_payload)
diff --git a/tests/hikari/internal/test_ux.py b/tests/hikari/internal/test_ux.py
index d5cd6828..38f04c91 100644
--- a/tests/hikari/internal/test_ux.py
+++ b/tests/hikari/internal/test_ux.py
@@ -19,11 +19,12 @@
 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 # SOFTWARE.
-import builtins
 import contextlib
 import importlib
 import logging
+import logging.config
 import os
+import pathlib
 import platform
 import string
 import sys
@@ -46,103 +47,169 @@ def test_when_handlers_already_set_up(self):
         stack.enter_context(mock.patch.object(logging, "root", handlers=[None]))
         logging_dict_config = stack.enter_context(mock.patch.object(logging.config, "dictConfig"))
         logging_basic_config = stack.enter_context(mock.patch.object(logging, "basicConfig"))
-        colorlog_basic_config = stack.enter_context(mock.patch.object(colorlog, "basicConfig"))
+        colored_formatter = stack.enter_context(mock.patch.object(colorlog.formatter, "ColoredFormatter"))
 
         with stack:
             ux.init_logging("LOGGING_LEVEL", True, False)
 
         logging_dict_config.assert_not_called()
         logging_basic_config.assert_not_called()
-        colorlog_basic_config.assert_not_called()
+        colored_formatter.assert_not_called()
 
     def test_when_handlers_specify_not_to_set_up(self):
         stack = contextlib.ExitStack()
         stack.enter_context(mock.patch.object(logging, "root", handlers=[]))
         logging_dict_config = stack.enter_context(mock.patch.object(logging.config, "dictConfig"))
         logging_basic_config = stack.enter_context(mock.patch.object(logging, "basicConfig"))
-        colorlog_basic_config = stack.enter_context(mock.patch.object(colorlog, "basicConfig"))
+        colored_formatter = stack.enter_context(mock.patch.object(colorlog.formatter, "ColoredFormatter"))
 
         with stack:
             ux.init_logging(None, True, False)
 
         logging_dict_config.assert_not_called()
         logging_basic_config.assert_not_called()
-        colorlog_basic_config.assert_not_called()
+        colored_formatter.assert_not_called()
 
-    def test_when_flavour_is_a_dict_and_doesnt_define_handlers(self):
+    def test_when_flavour_is_a_dict_and_is_not_incremental(self):
         stack = contextlib.ExitStack()
         stack.enter_context(mock.patch.object(logging, "root", handlers=[]))
         stack.enter_context(mock.patch.object(ux, "supports_color", return_value=False))
         logging_dict_config = stack.enter_context(mock.patch.object(logging.config, "dictConfig"))
+        logging_file_config = stack.enter_context(mock.patch.object(logging.config, "fileConfig"))
         logging_basic_config = stack.enter_context(mock.patch.object(logging, "basicConfig"))
-        colorlog_basic_config = stack.enter_context(mock.patch.object(colorlog, "basicConfig"))
+        colored_formatter = stack.enter_context(mock.patch.object(colorlog.formatter, "ColoredFormatter"))
 
         with stack:
             ux.init_logging({"hikari": {"level": "INFO"}}, True, False)
 
+        logging_file_config.assert_not_called()
         logging_dict_config.assert_called_once_with({"hikari": {"level": "INFO"}})
-        logging_basic_config.assert_called_once_with(
-            level=None,
-            format="%(levelname)-1.1s %(asctime)23.23s %(name)s: %(message)s",
-            stream=sys.stderr,
-        )
-        colorlog_basic_config.assert_not_called()
+        logging_basic_config.assert_not_called()
+        colored_formatter.assert_not_called()
+
+    def test_when_flavour_is_a_dict_and_is_incremental(self):
+        # This will emulate it being populated during the basicConfig call
+        def _basicConfig(*args, **kwargs):
+            logging_basic_config(*args, **kwargs)
+            stack.enter_context(mock.patch.object(logging.root, "handlers", new=[handler]))
 
-    def test_when_flavour_is_a_dict_and_defines_handlers(self):
         stack = contextlib.ExitStack()
         stack.enter_context(mock.patch.object(logging, "root", handlers=[]))
+        stack.enter_context(mock.patch.object(ux, "supports_color", return_value=False))
         logging_dict_config = stack.enter_context(mock.patch.object(logging.config, "dictConfig"))
-        logging_basic_config = stack.enter_context(mock.patch.object(logging, "basicConfig"))
-        colorlog_basic_config = stack.enter_context(mock.patch.object(colorlog, "basicConfig"))
+        logging_file_config = stack.enter_context(mock.patch.object(logging.config, "fileConfig"))
+        handler = mock.Mock()
+        logging_basic_config = mock.Mock()
+        stack.enter_context(mock.patch.object(logging, "basicConfig", new=_basicConfig))
+        colored_formatter = stack.enter_context(mock.patch.object(colorlog.formatter, "ColoredFormatter"))
 
         with stack:
-            ux.init_logging({"hikari": {"level": "INFO"}, "handlers": {"some_handler": {}}}, True, False)
+            ux.init_logging({"incremental": True, "hikari": {"level": "INFO"}}, True, False)
 
-        logging_dict_config.assert_called_once_with({"hikari": {"level": "INFO"}, "handlers": {"some_handler": {}}})
-        logging_basic_config.assert_not_called()
-        colorlog_basic_config.assert_not_called()
+        logging_file_config.assert_not_called()
+        logging_dict_config.assert_called_once_with({"incremental": True, "hikari": {"level": "INFO"}})
+        logging_basic_config.assert_called_once_with(
+            level=None,
+            stream=sys.stdout,
+            format="%(levelname)-1.1s %(asctime)23.23s %(name)s: %(message)s",
+        )
+        colored_formatter.assert_not_called()
+        handler.setFormatter.assert_not_called()
 
     def test_when_supports_color(self):
+        # This will emulate it being populated during the basicConfig call
+        def _basicConfig(*args, **kwargs):
+            logging_basic_config(*args, **kwargs)
+            stack.enter_context(mock.patch.object(logging.root, "handlers", new=[handler]))
+
         stack = contextlib.ExitStack()
         stack.enter_context(mock.patch.object(logging, "root", handlers=[]))
         logging_dict_config = stack.enter_context(mock.patch.object(logging.config, "dictConfig"))
-        logging_basic_config = stack.enter_context(mock.patch.object(logging, "basicConfig"))
-        colorlog_basic_config = stack.enter_context(mock.patch.object(colorlog, "basicConfig"))
+        logging_file_config = stack.enter_context(mock.patch.object(logging.config, "fileConfig"))
+        handler = mock.Mock()
+        logging_basic_config = mock.Mock()
+        stack.enter_context(mock.patch.object(logging, "basicConfig", new=_basicConfig))
+        colored_formatter = stack.enter_context(mock.patch.object(colorlog.formatter, "ColoredFormatter"))
         supports_color = stack.enter_context(mock.patch.object(ux, "supports_color", return_value=True))
 
         with stack:
             ux.init_logging("LOGGING_LEVEL", True, False)
 
+        logging_file_config.assert_not_called()
         logging_dict_config.assert_not_called()
-        logging_basic_config.assert_not_called()
-        colorlog_basic_config.assert_called_once_with(
-            level="LOGGING_LEVEL",
-            format="%(log_color)s%(bold)s%(levelname)-1.1s%(thin)s %(asctime)23.23s %(bold)s%(name)s: "
+        logging_basic_config.assert_called_once_with(level="LOGGING_LEVEL", stream=sys.stdout)
+        colored_formatter.assert_called_once_with(
+            fmt="%(log_color)s%(bold)s%(levelname)-1.1s%(thin)s %(asctime)23.23s %(bold)s%(name)s: "
             "%(thin)s%(message)s%(reset)s",
-            stream=sys.stderr,
+            force_color=True,
         )
         supports_color.assert_called_once_with(True, False)
+        handler.setFormatter.assert_called_once_with(colored_formatter.return_value)
 
     def test_when_doesnt_support_color(self):
         stack = contextlib.ExitStack()
         stack.enter_context(mock.patch.object(logging, "root", handlers=[]))
         logging_dict_config = stack.enter_context(mock.patch.object(logging.config, "dictConfig"))
         logging_basic_config = stack.enter_context(mock.patch.object(logging, "basicConfig"))
-        colorlog_basic_config = stack.enter_context(mock.patch.object(colorlog, "basicConfig"))
+        logging_file_config = stack.enter_context(mock.patch.object(logging.config, "fileConfig"))
+        colored_formatter = stack.enter_context(mock.patch.object(colorlog.formatter, "ColoredFormatter"))
         supports_color = stack.enter_context(mock.patch.object(ux, "supports_color", return_value=False))
 
         with stack:
             ux.init_logging("LOGGING_LEVEL", True, False)
 
         logging_dict_config.assert_not_called()
+        logging_file_config.assert_not_called()
         logging_basic_config.assert_called_once_with(
             level="LOGGING_LEVEL",
             format="%(levelname)-1.1s %(asctime)23.23s %(name)s: %(message)s",
-            stream=sys.stderr,
+            stream=sys.stdout,
         )
-        colorlog_basic_config.assert_not_called()
+        colored_formatter.assert_not_called()
         supports_color.assert_called_once_with(True, False)
 
+    def test_when_flavour_is_pathlike(self):
+        path = pathlib.Path("some/path/to/somewhere.ini")
+
+        stack = contextlib.ExitStack()
+        stack.enter_context(mock.patch.object(logging, "root", handlers=[]))
+        pathlib_path = stack.enter_context(mock.patch.object(pathlib, "Path"))
+        logging_dict_config = stack.enter_context(mock.patch.object(logging.config, "dictConfig"))
+        logging_basic_config = stack.enter_context(mock.patch.object(logging, "basicConfig"))
+        logging_file_config = stack.enter_context(mock.patch.object(logging.config, "fileConfig"))
+        colored_formatter = stack.enter_context(mock.patch.object(colorlog.formatter, "ColoredFormatter"))
+        supports_color = stack.enter_context(mock.patch.object(ux, "supports_color", return_value=False))
+
+        with stack:
+            ux.init_logging(path, True, False)
+
+        pathlib_path.assert_not_called()
+        logging_dict_config.assert_not_called()
+        logging_file_config.assert_called_once_with(path)
+        logging_basic_config.assert_not_called()
+        colored_formatter.assert_not_called()
+        supports_color.assert_not_called()
+
+    def test_when_flavour_is_an_existing_path(self):
+        stack = contextlib.ExitStack()
+        stack.enter_context(mock.patch.object(logging, "root", handlers=[]))
+        pathlib_path = stack.enter_context(mock.patch.object(pathlib, "Path"))
+        logging_dict_config = stack.enter_context(mock.patch.object(logging.config, "dictConfig"))
+        logging_basic_config = stack.enter_context(mock.patch.object(logging, "basicConfig"))
+        logging_file_config = stack.enter_context(mock.patch.object(logging.config, "fileConfig"))
+        colored_formatter = stack.enter_context(mock.patch.object(colorlog.formatter, "ColoredFormatter"))
+        supports_color = stack.enter_context(mock.patch.object(ux, "supports_color", return_value=False))
+
+        with stack:
+            ux.init_logging("some/path/to/somewhere.ini", True, False)
+
+        pathlib_path.assert_called_once_with("some/path/to/somewhere.ini")
+        logging_dict_config.assert_not_called()
+        logging_file_config.assert_called_once_with(pathlib_path.return_value)
+        logging_basic_config.assert_not_called()
+        colored_formatter.assert_not_called()
+        supports_color.assert_not_called()
+
 
 class TestRedBanner:
     def test_when_above_3_9(self):
@@ -233,10 +300,9 @@ def test_when_supports_color(self, mock_args):
         supports_color = stack.enter_context(mock.patch.object(ux, "supports_color", return_value=True))
         read_banner = stack.enter_context(mock.patch.object(ux, "_read_banner"))
         template = stack.enter_context(mock.patch.object(string, "Template"))
-        builtins_open = stack.enter_context(mock.patch.object(builtins, "open"))
         abspath = stack.enter_context(mock.patch.object(os.path, "abspath", return_value="some path"))
         dirname = stack.enter_context(mock.patch.object(os.path, "dirname"))
-        fileno = stack.enter_context(mock.patch.object(sys.stdout, "fileno"))
+        stdout = stack.enter_context(mock.patch.object(sys, "stdout"))
 
         with stack:
             ux.print_banner("hikaru", True, False)
@@ -261,12 +327,13 @@ def test_when_supports_color(self, mock_args):
 
         read_banner.assert_called_once_with("hikaru")
         template.assert_called_once_with(read_banner.return_value)
-        template().safe_substitute.assert_called_once_with(args)
-        builtins_open.assert_called_once_with(fileno.return_value, "w", encoding="utf-8", closefd=False)
-        builtins_open.return_value.__enter__.return_value.write.assert_called_once_with(template().safe_substitute())
+        safe_substitute = template.return_value.safe_substitute
+        safe_substitute.assert_called_once_with(args)
         dirname.assert_called_once_with("~/hikari")
         abspath.assert_called_once_with(dirname())
         supports_color.assert_called_once_with(True, False)
+        stdout.buffer.write.assert_called_once_with(safe_substitute.return_value.encode.return_value)
+        stdout.flush.assert_called_once_with()
 
     def test_when_doesnt_supports_color(self, mock_args):
         stack = contextlib.ExitStack()
@@ -279,8 +346,7 @@ def test_when_doesnt_supports_color(self, mock_args):
         template = stack.enter_context(mock.patch.object(string, "Template"))
         abspath = stack.enter_context(mock.patch.object(os.path, "abspath", return_value="some path"))
         dirname = stack.enter_context(mock.patch.object(os.path, "dirname"))
-        builtins_open = stack.enter_context(mock.patch.object(builtins, "open"))
-        fileno = stack.enter_context(mock.patch.object(sys.stdout, "fileno"))
+        stdout = stack.enter_context(mock.patch.object(sys, "stdout"))
 
         with stack:
             ux.print_banner("hikaru", True, False)
@@ -304,12 +370,13 @@ def test_when_doesnt_supports_color(self, mock_args):
         }
 
         template.assert_called_once_with(read_banner.return_value)
-        template().safe_substitute.assert_called_once_with(args)
+        safe_substitute = template.return_value.safe_substitute
+        safe_substitute.assert_called_once_with(args)
         dirname.assert_called_once_with("~/hikari")
         abspath.assert_called_once_with(dirname())
         supports_color.assert_called_once_with(True, False)
-        builtins_open.assert_called_once_with(fileno.return_value, "w", encoding="utf-8", closefd=False)
-        builtins_open.return_value.__enter__.return_value.write.assert_called_once_with(template().safe_substitute())
+        stdout.buffer.write.assert_called_once_with(safe_substitute.return_value.encode.return_value)
+        stdout.flush.assert_called_once_with()
 
     def test_use_extra_args(self, mock_args):
         stack = contextlib.ExitStack()
@@ -317,9 +384,8 @@ def test_use_extra_args(self, mock_args):
         stack.enter_context(mock.patch.object(time, "sleep"))
         read_banner = stack.enter_context(mock.patch.object(ux, "_read_banner"))
         template = stack.enter_context(mock.patch.object(string, "Template"))
-        builtins_open = stack.enter_context(mock.patch.object(builtins, "open"))
         stack.enter_context(mock.patch.object(os.path, "abspath", return_value="some path"))
-        fileno = stack.enter_context(mock.patch.object(sys.stdout, "fileno"))
+        stdout = stack.enter_context(mock.patch.object(sys, "stdout"))
 
         extra_args = {
             "extra_argument_1": "one",
@@ -348,9 +414,10 @@ def test_use_extra_args(self, mock_args):
 
         read_banner.assert_called_once_with("hikaru")
         template.assert_called_once_with(read_banner.return_value)
-        template().safe_substitute.assert_called_once_with(args)
-        builtins_open.assert_called_once_with(fileno.return_value, "w", encoding="utf-8", closefd=False)
-        builtins_open.return_value.__enter__.return_value.write.assert_called_once_with(template().safe_substitute())
+        safe_substitute = template.return_value.safe_substitute
+        safe_substitute.assert_called_once_with(args)
+        stdout.buffer.write.assert_called_once_with(safe_substitute.return_value.encode.return_value)
+        stdout.flush.assert_called_once_with()
 
     def test_overwrite_args_raises_error(self, mock_args):
         stack = contextlib.ExitStack()