Repository: Delgan/loguru

Issue:
[Enhancement] Use ruff instead of flake8 for linting in pre-commit
I guess using ruff instead of flake8 could be considered, especially for performance reasons of lining + additional features like import sorting (like isort) and removing unused code (like autoflake).

See here: https://github.com/charliermarsh/ruff

(There is also a performance comparison between common linters)

Discussion:
hillairet: Ok I understand things more now:
1. flake8 doesn't check `*.pyi` files it seems. Ruff does by default. So I added an exclusion for that
2. flake8-bugbear was on version 23.1.20 which had [errors up to B027](https://github.com/PyCQA/flake8-bugbear/tree/23.1.20#list-of-warnings) so I excluded B033 and B028
3. Flake8-bugbear errors B9xx are opinionated and therefore not enabled by default, but they are in ruff. So I excluded B904.
4. For some reason flake8-bugbear 23.1.20 doesn't pick up `1 / 0` as a useless expression or B018, but ruff does so I excluded that too.

I'm happy to add some `noqa: B***` instead if you prefer silencing individual lines.
Also something like [B904](https://beta.ruff.rs/docs/rules/raise-without-from-inside-except/) just requires to use `raise ... from ValueError` so it's easy to fix.
Let me know what you prefer.

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index bfd2fd6..446ce23 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -12,20 +12,13 @@ repos:
     args: [--autofix]
   - id: pretty-format-yaml
     args: [--autofix, --indent, '2']
-- repo: https://github.com/pycqa/isort
-  rev: 5.12.0
-  hooks:
-  - id: isort
 - repo: https://github.com/ambv/black
   rev: 23.7.0
   hooks:
   - id: black
     args: [-l, '100', --target-version, py35]
-- repo: https://github.com/pycqa/flake8
-  rev: 6.1.0
+- repo: https://github.com/astral-sh/ruff-pre-commit
+  rev: v0.0.286
   hooks:
-  - id: flake8
-    args: [--exclude, tests/exceptions/source]
-    additional_dependencies:
-    - flake8-bugbear==23.1.20
-    - pep8-naming==0.13.3
+  - id: ruff
+    args: [--fix, --exit-non-zero-on-fix]
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 6f0c7bc..e1e2097 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -2,8 +2,13 @@
 =============
 
 - Add a new ``context`` optional argument to ``logger.add()`` specifying ``multiprocessing`` context (like ``"spawn"`` or ``"fork"``) to be used internally instead of the default one (`#851 <https://github.com/Delgan/loguru/issues/851>`_).
+- Add support for true colors on Windows using ANSI/VT console when available (`#934 <https://github.com/Delgan/loguru/issues/934>`_, thanks `@tunaflsh <https://github.com/tunaflsh>`_).
+- Fix file possibly rotating too early or too late when re-starting an application around midnight (`#894 <https://github.com/Delgan/loguru/issues/894>`_).
+- Fix inverted ``"<hide>"`` and ``"<strike>"`` color tags (`#943 <https://github.com/Delgan/loguru/pull/943>`_, thanks `@tunaflsh <https://github.com/tunaflsh>`_).
+- Fix possible untraceable errors raised when logging non-unpicklable ``Exception`` instances while using ``enqueue=True`` (`#329 <https://github.com/Delgan/loguru/issues/329>`_).
+- Fix possible errors raised when logging non-picklable ``Exception`` instances while using ``enqueue=True`` (`#342 <https://github.com/Delgan/loguru/issues/342>`_, thanks `@ncoudene <https://github.com/ncoudene>`_).
+- Fix missing seconds and microseconds when formatting timezone offset that requires such accuracy (`#961 <https://github.com/Delgan/loguru/issues/961>`_).
 - Raise ``ValueError`` if an attempt to use nanosecond precision for time formatting is detected (`#855 <https://github.com/Delgan/loguru/issues/855>`_).
-- Fix possible errors raised by logging non-picklable ``Exception`` instances while using ``enqueue=True`` (`#342 <https://github.com/Delgan/loguru/issues/342>`_, thanks `@ncoudene <https://github.com/ncoudene>`_).
 
 
 `0.7.0`_ (2023-04-10)
diff --git a/README.rst b/README.rst
index 975ebe2..46bf6be 100644
--- a/README.rst
+++ b/README.rst
@@ -239,7 +239,8 @@ Logging exceptions that occur in your code is important to track bugs, but it's
 
 The code::
 
-    logger.add("out.log", backtrace=True, diagnose=True)  # Caution, may leak sensitive data in prod
+    # Caution, "diagnose=True" is the default and may leak sensitive data in prod
+    logger.add("out.log", backtrace=True, diagnose=True)
 
     def func(a, b):
         return a / b
@@ -277,6 +278,8 @@ Would result in:
 
 Note that this feature won't work on default Python REPL due to unavailable frame data.
 
+See also: `Security considerations when using Loguru <https://loguru.readthedocs.io/en/stable/resources/recipes.html#security-considerations-when-using-loguru>`_.
+
 
 Structured logging as needed
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
diff --git a/loguru/_colorama.py b/loguru/_colorama.py
index d040e79..b69c700 100644
--- a/loguru/_colorama.py
+++ b/loguru/_colorama.py
@@ -46,10 +46,21 @@ def should_wrap(stream):
 
     from colorama.win32 import winapi_test
 
-    return winapi_test()
+    if not winapi_test():
+        return False
+
+    try:
+        from colorama.winterm import enable_vt_processing
+    except ImportError:
+        return True
+
+    try:
+        return not enable_vt_processing(stream.fileno())
+    except Exception:
+        return True
 
 
 def wrap(stream):
     from colorama import AnsiToWin32
 
-    return AnsiToWin32(stream, convert=True, strip=False, autoreset=False).stream
+    return AnsiToWin32(stream, convert=True, strip=True, autoreset=False).stream
diff --git a/loguru/_colorizer.py b/loguru/_colorizer.py
index c9fc1d2..ed87cdb 100644
--- a/loguru/_colorizer.py
+++ b/loguru/_colorizer.py
@@ -10,8 +10,8 @@ class Style:
     UNDERLINE = 4
     BLINK = 5
     REVERSE = 7
-    STRIKE = 8
-    HIDE = 9
+    HIDE = 8
+    STRIKE = 9
     NORMAL = 22
 
 
diff --git a/loguru/_datetime.py b/loguru/_datetime.py
index 4d0282f..892511f 100644
--- a/loguru/_datetime.py
+++ b/loguru/_datetime.py
@@ -27,7 +27,7 @@ class datetime(datetime_):  # noqa: N801
             raise ValueError(
                 "Invalid time format: the provided format string contains more than six successive "
                 "'S' characters. This may be due to an attempt to use nanosecond precision, which "
-                " not supported."
+                "is not supported."
             )
 
         year, month, day, hour, minute, second, weekday, yearday, _ = dt.timetuple()
@@ -36,7 +36,7 @@ class datetime(datetime_):  # noqa: N801
         tzinfo = dt.tzinfo or timezone(timedelta(seconds=0))
         offset = tzinfo.utcoffset(dt).total_seconds()
         sign = ("-", "+")[offset >= 0]
-        h, m = divmod(abs(offset // 60), 60)
+        (h, m), s = divmod(abs(offset // 60), 60), abs(offset) % 60
 
         rep = {
             "YYYY": "%04d" % year,
@@ -69,8 +69,8 @@ class datetime(datetime_):  # noqa: N801
             "SSSSS": "%05d" % (microsecond // 10),
             "SSSSSS": "%06d" % microsecond,
             "A": ("AM", "PM")[hour // 12],
-            "Z": "%s%02d:%02d" % (sign, h, m),
-            "ZZ": "%s%02d%02d" % (sign, h, m),
+            "Z": "%s%02d:%02d%s" % (sign, h, m, (":%09.06f" % s)[: 11 if s % 1 else 3] * (s > 0)),
+            "ZZ": "%s%02d%02d%s" % (sign, h, m, ("%09.06f" % s)[: 10 if s % 1 else 2] * (s > 0)),
             "zz": tzinfo.tzname(dt) or "",
             "X": "%d" % timestamp,
             "x": "%d" % (int(timestamp) * 1000000 + microsecond),
diff --git a/loguru/_file_sink.py b/loguru/_file_sink.py
index 35b252f..564e93f 100644
--- a/loguru/_file_sink.py
+++ b/loguru/_file_sink.py
@@ -128,15 +128,12 @@ class Rotation:
                     limit = start_time.astimezone(record_time.tzinfo).replace(tzinfo=None)
                     limit = self._step_forward(limit)
                 else:
-                    limit = datetime.datetime(
-                        start_time.year,
-                        start_time.month,
-                        start_time.day,
-                        time_init.hour,
-                        time_init.minute,
-                        time_init.second,
-                        time_init.microsecond,
-                        record_time.tzinfo if time_init.tzinfo is None else time_init.tzinfo,
+                    tzinfo = record_time.tzinfo if time_init.tzinfo is None else time_init.tzinfo
+                    limit = start_time.astimezone(tzinfo).replace(
+                        hour=time_init.hour,
+                        minute=time_init.minute,
+                        second=time_init.second,
+                        microsecond=time_init.microsecond,
                     )
 
                     if limit <= start_time:
diff --git a/loguru/_recattrs.py b/loguru/_recattrs.py
index 2e9cab7..b09426e 100644
--- a/loguru/_recattrs.py
+++ b/loguru/_recattrs.py
@@ -64,16 +64,27 @@ class RecordException(namedtuple("RecordException", ("type", "value", "traceback
         return "(type=%r, value=%r, traceback=%r)" % (self.type, self.value, self.traceback)
 
     def __reduce__(self):
-        # The traceback is not picklable so we need to remove it. Also, some custom exception
-        # values aren't picklable either. For user convenience, we try first to serialize it and
-        # we remove the value in case or error. As an optimization, we could have re-used the
-        # dumped value during unpickling, but this requires using "pickle.loads()" which is
-        # flagged as insecure by some security tools.
-        # __reduce__ function does not alway raise PickleError. Multidict, for example, raise
-        # TypeError. To manage all the cases, we catch Exception.
+        # The traceback is not picklable, therefore it needs to be removed. Additionally, there's a
+        # possibility that the exception value is not picklable either. In such cases, we also need
+        # to remove it. This is done for user convenience, aiming to prevent error logging caused by
+        # custom exceptions from third-party libraries. If the serialization succeeds, we can reuse
+        # the pickled value later for optimization (so that it's not pickled twice). It's important
+        # to note that custom exceptions might not necessarily raise a PickleError, hence the
+        # generic Exception catch.
         try:
-            pickle.dumps(self.value)
+            pickled_value = pickle.dumps(self.value)
         except Exception:
             return (RecordException, (self.type, None, None))
         else:
-            return (RecordException, (self.type, self.value, None))
+            return (RecordException._from_pickled_value, (self.type, pickled_value, None))
+
+    @classmethod
+    def _from_pickled_value(cls, type_, pickled_value, traceback_):
+        try:
+            # It's safe to use "pickle.loads()" in this case because the pickled value is generated
+            # by the same code and is not coming from an untrusted source.
+            value = pickle.loads(pickled_value)
+        except Exception:
+            return cls(type_, None, traceback_)
+        else:
+            return cls(type_, value, traceback_)
diff --git a/ruff.toml b/ruff.toml
new file mode 100644
index 0000000..53ff622
--- /dev/null
+++ b/ruff.toml
@@ -0,0 +1,8 @@
+# Enforce pyflakes(F), pycodestyle(E, W), isort (I), bugbears (B), and pep8-naming (N) rules
+select = ["F", "E", "W", "I", "B", "N"]
+# Ignore these errors for now that are picked up by ruff but were not by flake8
+ignore = ["B904", "B033", "B028", "B018"]
+line-length = 100
+exclude = ["tests/exceptions/source", "loguru/__init__.pyi"]
+[pycodestyle]
+max-doc-length = 100
diff --git a/tox.ini b/tox.ini
index 896d816..dd98199 100644
--- a/tox.ini
+++ b/tox.ini
@@ -23,21 +23,6 @@ description = Build the HTML documentation.
 commands =
     sphinx-build -a -b html -W --keep-going docs/ docs/build
 
-[isort]
-line_length = 100
-profile = black
-
-[flake8]
-max_line_length = 100
-max_doc_length = 100
-ignore =
-    # Line break before binary operator (PEP8 now recommend to break after binary operator)
-    W503
-    # Whitespace before ":" in slices
-    E203
-exclude =
-    tests/exceptions/source
-
 [pytest]
 addopts = -l
 testpaths =


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 705e92b..1a9541e 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -2,6 +2,7 @@ import asyncio
 import builtins
 import contextlib
 import datetime
+import io
 import logging
 import os
 import pathlib
@@ -73,6 +74,31 @@ def check_dir(dir, *, files=None, size=None):
             seen.add(filepath)
 
 
+class StubStream(io.StringIO):
+    def fileno(self):
+        return 1
+
+
+class StreamIsattyTrue(StubStream):
+    def isatty(self):
+        return True
+
+
+class StreamIsattyFalse(StubStream):
+    def isatty(self):
+        return False
+
+
+class StreamIsattyException(StubStream):
+    def isatty(self):
+        raise RuntimeError
+
+
+class StreamFilenoException(StreamIsattyTrue):
+    def fileno(self):
+        raise RuntimeError
+
+
 @contextlib.contextmanager
 def default_threading_excepthook():
     if not hasattr(threading, "excepthook"):
diff --git a/tests/test_add_option_colorize.py b/tests/test_add_option_colorize.py
index 22481c3..0d1bf85 100644
--- a/tests/test_add_option_colorize.py
+++ b/tests/test_add_option_colorize.py
@@ -1,32 +1,8 @@
-import os
-import sys
-from unittest.mock import MagicMock
-
 import pytest
 
-import loguru
 from loguru import logger
 
-from .conftest import parse
-
-
-class Stream:
-    def __init__(self, tty):
-        self.out = ""
-        self.tty = tty
-        self.closed = False
-
-    def write(self, m):
-        self.out += m
-
-    def isatty(self):
-        if self.tty is None:
-            raise RuntimeError
-        else:
-            return self.tty
-
-    def flush(self):
-        pass
+from .conftest import StreamIsattyException, StreamIsattyFalse, StreamIsattyTrue, parse
 
 
 @pytest.mark.parametrize(
@@ -59,183 +35,41 @@ def test_decolorized_format(format, message, expected, writer):
     assert writer.read() == expected
 
 
-@pytest.fixture
-def patch_colorama(monkeypatch):
-    instance = MagicMock()
-    class_ = MagicMock(return_value=instance)
-    winapi_test = MagicMock(return_value=True)
-    win32 = MagicMock(winapi_test=winapi_test)
-    colorama = MagicMock(AnsiToWin32=class_, win32=win32)
-    monkeypatch.setitem(sys.modules, "colorama", colorama)
-    monkeypatch.setitem(sys.modules, "colorama.win32", win32)
-    yield colorama
-
-
-@pytest.mark.parametrize("colorize", [True, False, None])
-@pytest.mark.parametrize("tty", [True, False])
-@pytest.mark.parametrize("replace_original", [True, False])
-@pytest.mark.skipif(os.name == "nt", reason="Colorama is required on Windows")
-def test_colorize_stream_linux(patch_colorama, monkeypatch, colorize, tty, replace_original):
-    stream = Stream(tty)
-
-    monkeypatch.delenv("TERM", raising=False)
-    monkeypatch.delenv("PYCHARM_HOSTED", raising=False)
-
-    if replace_original:
-        monkeypatch.setattr(sys, "__stdout__", stream)
-
-    logger.add(stream, format="<red>{message}</red>", colorize=colorize, catch=False)
+@pytest.mark.parametrize(
+    "stream", [StreamIsattyTrue(), StreamIsattyFalse(), StreamIsattyException()]
+)
+def test_colorize_stream(stream):
+    logger.add(stream, format="<blue>{message}</blue>", colorize=True)
     logger.debug("Message")
+    assert stream.getvalue() == parse("<blue>Message</blue>\n")
 
-    winapi_test = patch_colorama.win32.winapi_test
-    stream_write = patch_colorama.AnsiToWin32().stream.write
-
-    assert not stream_write.called
-    assert not winapi_test.called
-
-    if colorize or (colorize is None and tty):
-        assert stream.out == parse("<red>Message</red>\n")
-    else:
-        assert stream.out == "Message\n"
-
-
-@pytest.mark.parametrize("colorize", [True, False, None])
-@pytest.mark.parametrize("tty", [True, False])
-@pytest.mark.parametrize("replace_original", [True, False])
-@pytest.mark.skipif(os.name != "nt", reason="Only Windows requires Colorama")
-def test_colorize_stream_windows(patch_colorama, monkeypatch, colorize, tty, replace_original):
-    stream = Stream(tty)
-
-    monkeypatch.delenv("TERM", raising=False)
-    monkeypatch.delenv("PYCHARM_HOSTED", raising=False)
 
-    if replace_original:
-        monkeypatch.setattr(sys, "__stdout__", stream)
-
-    logger.add(stream, format="<blue>{message}</blue>", colorize=colorize, catch=False)
+@pytest.mark.parametrize(
+    "stream", [StreamIsattyTrue(), StreamIsattyFalse(), StreamIsattyException()]
+)
+def test_decolorize_stream(stream):
+    stream = StreamIsattyFalse()
+    logger.add(stream, format="<blue>{message}</blue>", colorize=False)
     logger.debug("Message")
+    assert stream.getvalue() == "Message\n"
 
-    winapi_test = patch_colorama.win32.winapi_test
-    stream_write = patch_colorama.AnsiToWin32().stream.write
 
-    if replace_original and (colorize or (colorize is None and tty)):
-        assert winapi_test.called
-        assert stream_write.called
-    else:
-        assert not winapi_test.called
-        assert not stream_write.called
-
-
-@pytest.mark.parametrize("colorize", [True, False, None])
-def test_isatty_error(colorize):
-    stream = Stream(None)
-    logger.add(stream, format="<blue>{message}</blue>", colorize=colorize)
+def test_automatic_detection_when_stream_is_a_tty():
+    stream = StreamIsattyTrue()
+    logger.add(stream, format="<blue>{message}</blue>", colorize=None)
     logger.debug("Message")
-
-    if colorize is True:
-        assert stream.out == parse("<blue>Message</blue>\n")
-    else:
-        assert stream.out == "Message\n"
+    assert stream.getvalue() == parse("<blue>Message</blue>\n")
 
 
-@pytest.mark.parametrize("stream", [sys.__stdout__, sys.__stderr__])
-def test_pycharm_fixed(monkeypatch, stream):
-    monkeypatch.setitem(os.environ, "PYCHARM_HOSTED", "1")
-    monkeypatch.setattr(stream, "isatty", lambda: False)
-    assert not stream.isatty()
-    assert loguru._colorama.should_colorize(stream)
-
-
-@pytest.mark.parametrize("stream", [None, Stream(False), Stream(None)])
-def test_pycharm_ignored(monkeypatch, stream):
-    monkeypatch.setitem(os.environ, "PYCHARM_HOSTED", "1")
-    assert not loguru._colorama.should_colorize(stream)
-
-
-def test_jupyter_fixed(monkeypatch):
-    class Shell:
-        pass
-
-    class Out:
-        pass
-
-    stream = MagicMock()
-    stream.__class__ = Out
-    stream.isatty.return_value = False
-    ipython = MagicMock()
-    ipykernel = MagicMock()
-    instance = MagicMock()
-    instance.__class__ = Shell
-    ipython.get_ipython.return_value = instance
-    ipykernel.zmqshell.ZMQInteractiveShell = Shell
-    ipykernel.iostream.OutStream = Out
-
-    monkeypatch.setitem(sys.modules, "IPython", ipython)
-    monkeypatch.setitem(sys.modules, "ipykernel", ipykernel)
-    monkeypatch.setattr(sys, "stdout", stream)
-
-    assert not stream.isatty()
-    assert loguru._colorama.should_colorize(stream)
-
-
-@pytest.mark.parametrize("stream", [None, Stream(False), Stream(None)])
-def test_jupyter_ignored(monkeypatch, stream):
-    class Shell:
-        pass
-
-    class Out:
-        pass
-
-    ipython = MagicMock()
-    ipykernel = MagicMock()
-    instance = MagicMock()
-    instance.__class__ = Shell
-    ipython.get_ipython.return_value = instance
-    ipykernel.zmqshell.ZMQInteractiveShell = Shell
-    ipykernel.iostream.OutStream = Out
-
-    monkeypatch.setitem(sys.modules, "IPython", ipython)
-    monkeypatch.setitem(sys.modules, "ipykernel", ipykernel)
-    monkeypatch.setattr(sys, "stdout", stream)
-
-    assert not loguru._colorama.should_colorize(stream)
-
-
-@pytest.mark.parametrize("stream", [sys.__stdout__, sys.__stderr__])
-def test_github_actions_fixed(monkeypatch, stream):
-    monkeypatch.setitem(os.environ, "CI", "1")
-    monkeypatch.setitem(os.environ, "GITHUB_ACTIONS", "1")
-    monkeypatch.setattr(stream, "isatty", lambda: False)
-    assert not stream.isatty()
-    assert loguru._colorama.should_colorize(stream)
-
-
-@pytest.mark.parametrize("stream", [None, Stream(False), Stream(None)])
-def test_github_actions_ignored(monkeypatch, stream):
-    monkeypatch.setitem(os.environ, "CI", "1")
-    monkeypatch.setitem(os.environ, "GITHUB_ACTIONS", "1")
-    assert not loguru._colorama.should_colorize(stream)
-
-
-@pytest.mark.parametrize("stream", [sys.__stdout__, sys.__stderr__])
-@pytest.mark.skipif(os.name != "nt", reason="The fix is applied only on Windows")
-def test_mintty_fixed_windows(monkeypatch, stream):
-    monkeypatch.setitem(os.environ, "TERM", "xterm")
-    monkeypatch.setattr(stream, "isatty", lambda: False)
-    assert not stream.isatty()
-    assert loguru._colorama.should_colorize(stream)
-
-
-@pytest.mark.parametrize("stream", [sys.__stdout__, sys.__stderr__])
-@pytest.mark.skipif(os.name == "nt", reason="The fix will be applied on Windows")
-def test_mintty_not_fixed_linux(monkeypatch, stream):
-    monkeypatch.setitem(os.environ, "TERM", "xterm")
-    monkeypatch.setattr(stream, "isatty", lambda: False)
-    assert not stream.isatty()
-    assert not loguru._colorama.should_colorize(stream)
+def test_automatic_detection_when_stream_is_not_a_tty():
+    stream = StreamIsattyFalse()
+    logger.add(stream, format="<blue>{message}</blue>", colorize=None)
+    logger.debug("Message")
+    assert stream.getvalue() == "Message\n"
 
 
-@pytest.mark.parametrize("stream", [None, Stream(False), Stream(None)])
-def test_mintty_ignored(monkeypatch, stream):
-    monkeypatch.setitem(os.environ, "TERM", "xterm")
-    assert not loguru._colorama.should_colorize(stream)
+def test_automatic_detection_when_stream_has_no_isatty():
+    stream = StreamIsattyException()
+    logger.add(stream, format="<blue>{message}</blue>", colorize=None)
+    logger.debug("Message")
+    assert stream.getvalue() == "Message\n"
diff --git a/tests/test_add_option_enqueue.py b/tests/test_add_option_enqueue.py
index 9810d34..c59d6fa 100644
--- a/tests/test_add_option_enqueue.py
+++ b/tests/test_add_option_enqueue.py
@@ -34,6 +34,14 @@ class NotUnpicklable:
         raise pickle.UnpicklingError("You shall not de-serialize me!")
 
 
+class NotUnpicklableTypeError:
+    def __getstate__(self):
+        return "..."
+
+    def __setstate__(self, state):
+        raise TypeError("You shall not de-serialize me!")
+
+
 class NotWritable:
     def write(self, message):
         if "fail" in message.record["extra"]:
@@ -97,24 +105,6 @@ def test_caught_exception_queue_put(writer, capsys):
     assert lines[-1] == "--- End of logging error ---"
 
 
-def test_caught_exception_queue_put_typerror(writer, capsys):
-    logger.add(writer, enqueue=True, catch=True, format="{message}")
-
-    logger.info("It's fine")
-    logger.bind(broken=NotPicklableTypeError()).info("Bye bye...")
-    logger.info("It's fine again")
-    logger.remove()
-
-    out, err = capsys.readouterr()
-    lines = err.strip().splitlines()
-    assert writer.read() == "It's fine\nIt's fine again\n"
-    assert out == ""
-    assert lines[0] == "--- Logging error in Loguru Handler #0 ---"
-    assert re.match(r"Record was: \{.*Bye bye.*\}", lines[1])
-    assert lines[-2].endswith("TypeError: You shall not serialize me!")
-    assert lines[-1] == "--- End of logging error ---"
-
-
 def test_caught_exception_queue_get(writer, capsys):
     logger.add(writer, enqueue=True, catch=True, format="{message}")
 
@@ -166,22 +156,6 @@ def test_not_caught_exception_queue_put(writer, capsys):
     assert err == ""
 
 
-def test_not_caught_exception_queue_put_typeerror(writer, capsys):
-    logger.add(writer, enqueue=True, catch=False, format="{message}")
-
-    logger.info("It's fine")
-
-    with pytest.raises(TypeError, match=r"You shall not serialize me!"):
-        logger.bind(broken=NotPicklableTypeError()).info("Bye bye...")
-
-    logger.remove()
-
-    out, err = capsys.readouterr()
-    assert writer.read() == "It's fine\n"
-    assert out == ""
-    assert err == ""
-
-
 def test_not_caught_exception_queue_get(writer, capsys):
     logger.add(writer, enqueue=True, catch=False, format="{message}")
 
@@ -274,7 +248,8 @@ def test_wait_for_all_messages_enqueued(capsys):
     assert err == "".join("%d\n" % i for i in range(10))
 
 
-def test_logging_not_picklable_exception():
+@pytest.mark.parametrize("exception_value", [NotPicklable(), NotPicklableTypeError()])
+def test_logging_not_picklable_exception(exception_value):
     exception = None
 
     def sink(message):
@@ -284,7 +259,7 @@ def test_logging_not_picklable_exception():
     logger.add(sink, enqueue=True, catch=False)
 
     try:
-        raise ValueError(NotPicklable())
+        raise ValueError(exception_value)
     except Exception:
         logger.exception("Oups")
 
@@ -296,8 +271,8 @@ def test_logging_not_picklable_exception():
     assert traceback_ is None
 
 
-@pytest.mark.skip(reason="No way to safely deserialize exception yet")
-def test_logging_not_unpicklable_exception():
+@pytest.mark.parametrize("exception_value", [NotUnpicklable(), NotUnpicklableTypeError()])
+def test_logging_not_unpicklable_exception(exception_value):
     exception = None
 
     def sink(message):
@@ -307,7 +282,7 @@ def test_logging_not_unpicklable_exception():
     logger.add(sink, enqueue=True, catch=False)
 
     try:
-        raise ValueError(NotUnpicklable())
+        raise ValueError(exception_value)
     except Exception:
         logger.exception("Oups")
 
diff --git a/tests/test_colorama.py b/tests/test_colorama.py
new file mode 100644
index 0000000..b6c966c
--- /dev/null
+++ b/tests/test_colorama.py
@@ -0,0 +1,265 @@
+import os
+import sys
+from unittest.mock import MagicMock
+
+import pytest
+
+from loguru import logger
+from loguru._colorama import should_colorize, should_wrap
+
+from .conftest import (
+    StreamFilenoException,
+    StreamIsattyException,
+    StreamIsattyFalse,
+    StreamIsattyTrue,
+)
+
+
+@pytest.fixture(autouse=True)
+def clear_environment():
+    env = os.environ.copy()
+    os.environ.clear()
+    yield
+    os.environ.update(env)
+
+
+@pytest.fixture
+def patch_colorama(monkeypatch):
+    ansi_to_win32_class = MagicMock()
+    winapi_test = MagicMock(return_value=True)
+    enable_vt_processing = MagicMock(return_value=False)
+    win32 = MagicMock(winapi_test=winapi_test)
+    winterm = MagicMock(enable_vt_processing=enable_vt_processing)
+    colorama = MagicMock(AnsiToWin32=ansi_to_win32_class, win32=win32, winterm=winterm)
+    monkeypatch.setitem(sys.modules, "colorama", colorama)
+    monkeypatch.setitem(sys.modules, "colorama.win32", win32)
+    monkeypatch.setitem(sys.modules, "colorama.winterm", winterm)
+    yield colorama
+
+
+@pytest.mark.parametrize("patched", ["__stdout__", "__stderr__"])
+@pytest.mark.skipif(os.name != "nt", reason="Only Windows requires Colorama")
+def test_stream_wrapped_on_windows_if_no_vt_support(patched, monkeypatch, patch_colorama):
+    stream = StreamIsattyTrue()
+    monkeypatch.setattr(sys, patched, stream, raising=False)
+    patch_colorama.win32.winapi_test.return_value = True
+    patch_colorama.winterm.enable_vt_processing.return_value = False
+    logger.add(stream, colorize=True)
+    assert patch_colorama.AnsiToWin32.called
+
+
+@pytest.mark.parametrize("patched", ["__stdout__", "__stderr__"])
+@pytest.mark.skipif(os.name != "nt", reason="Only Windows requires Colorama")
+def test_stream_not_wrapped_on_windows_if_vt_support(patched, monkeypatch, patch_colorama):
+    stream = StreamIsattyTrue()
+    monkeypatch.setattr(sys, patched, stream, raising=False)
+    patch_colorama.win32.winapi_test.return_value = True
+    patch_colorama.winterm.enable_vt_processing.return_value = True
+    logger.add(stream, colorize=True)
+    assert not patch_colorama.AnsiToWin32.called
+
+
+def test_stream_is_none():
+    assert not should_colorize(None)
+
+
+def test_is_a_tty():
+    assert should_colorize(StreamIsattyTrue())
+
+
+def test_is_not_a_tty():
+    assert not should_colorize(StreamIsattyFalse())
+
+
+def test_is_a_tty_exception():
+    assert not should_colorize(StreamIsattyException())
+
+
+@pytest.mark.parametrize(
+    "patched, expected",
+    [
+        ("__stdout__", True),
+        ("__stderr__", True),
+        ("stdout", False),
+        ("stderr", False),
+        ("", False),
+    ],
+)
+def test_pycharm_fixed(monkeypatch, patched, expected):
+    stream = StreamIsattyFalse()
+    monkeypatch.setattr(sys, patched, stream, raising=False)
+    monkeypatch.setitem(os.environ, "PYCHARM_HOSTED", "1")
+    assert should_colorize(stream) is expected
+
+
+@pytest.mark.parametrize(
+    "patched, expected",
+    [
+        ("__stdout__", True),
+        ("__stderr__", True),
+        ("stdout", False),
+        ("stderr", False),
+        ("", False),
+    ],
+)
+def test_github_actions_fixed(monkeypatch, patched, expected):
+    stream = StreamIsattyFalse()
+    monkeypatch.setitem(os.environ, "CI", "1")
+    monkeypatch.setitem(os.environ, "GITHUB_ACTIONS", "1")
+    monkeypatch.setattr(sys, patched, stream, raising=False)
+    assert should_colorize(stream) is expected
+
+
+@pytest.mark.parametrize(
+    "patched, expected",
+    [
+        ("__stdout__", True),
+        ("__stderr__", True),
+        ("stdout", False),
+        ("stderr", False),
+        ("", False),
+    ],
+)
+@pytest.mark.skipif(os.name != "nt", reason="The fix is applied only on Windows")
+def test_mintty_fixed_windows(monkeypatch, patched, expected):
+    stream = StreamIsattyFalse()
+    monkeypatch.setitem(os.environ, "TERM", "xterm")
+    monkeypatch.setattr(sys, patched, stream, raising=False)
+    assert should_colorize(stream) is expected
+
+
+@pytest.mark.parametrize(
+    "patched, expected",
+    [
+        ("__stdout__", False),
+        ("__stderr__", False),
+        ("stdout", False),
+        ("stderr", False),
+        ("", False),
+    ],
+)
+@pytest.mark.skipif(os.name == "nt", reason="The fix will be applied on Windows")
+def test_mintty_not_fixed_linux(monkeypatch, patched, expected):
+    stream = StreamIsattyFalse()
+    monkeypatch.setitem(os.environ, "TERM", "xterm")
+    monkeypatch.setattr(sys, patched, stream, raising=False)
+    assert should_colorize(stream) is expected
+
+
+@pytest.mark.parametrize(
+    "patched, out_class, expected",
+    [
+        ("stdout", StreamIsattyFalse, True),
+        ("stderr", StreamIsattyFalse, True),
+        ("__stdout__", StreamIsattyFalse, False),
+        ("__stderr__", StreamIsattyFalse, False),
+        ("stdout", StreamIsattyTrue, False),
+        ("stderr", StreamIsattyTrue, False),
+        ("", StreamIsattyFalse, False),
+    ],
+)
+def test_jupyter_fixed(monkeypatch, patched, out_class, expected):
+    stream = StreamIsattyFalse()
+
+    class Shell:
+        pass
+
+    ipython = MagicMock()
+    ipykernel = MagicMock()
+    instance = MagicMock()
+    instance.__class__ = Shell
+    ipython.get_ipython.return_value = instance
+    ipykernel.zmqshell.ZMQInteractiveShell = Shell
+    ipykernel.iostream.OutStream = out_class
+
+    monkeypatch.setitem(sys.modules, "IPython", ipython)
+    monkeypatch.setitem(sys.modules, "ipykernel", ipykernel)
+    monkeypatch.setattr(sys, patched, stream, raising=False)
+
+    assert should_colorize(stream) is expected
+
+
+@pytest.mark.parametrize("patched", ["__stdout__", "__stderr__"])
+@pytest.mark.skipif(os.name == "nt", reason="Colorama is required on Windows")
+def test_dont_wrap_on_linux(monkeypatch, patched, patch_colorama):
+    stream = StreamIsattyTrue()
+    monkeypatch.setattr(sys, patched, stream, raising=False)
+    assert not should_wrap(stream)
+    assert not patch_colorama.win32.winapi_test.called
+
+
+@pytest.mark.parametrize("patched", ["stdout", "stderr", ""])
+@pytest.mark.skipif(os.name != "nt", reason="Only Windows requires Colorama")
+def test_dont_wrap_if_not_original_stdout_or_stderr(monkeypatch, patched, patch_colorama):
+    stream = StreamIsattyTrue()
+    monkeypatch.setattr(sys, patched, stream, raising=False)
+    assert not should_wrap(stream)
+    assert not patch_colorama.win32.winapi_test.called
+
+
+@pytest.mark.parametrize("patched", ["__stdout__", "__stderr__"])
+@pytest.mark.skipif(os.name != "nt", reason="Only Windows requires Colorama")
+def test_dont_wrap_if_terminal_has_vt_support(monkeypatch, patched, patch_colorama):
+    stream = StreamIsattyTrue()
+    monkeypatch.setattr(sys, patched, stream, raising=False)
+    patch_colorama.win32.winapi_test.return_value = True
+    patch_colorama.winterm.enable_vt_processing.return_value = True
+    assert not should_wrap(stream)
+    assert patch_colorama.winterm.enable_vt_processing.called
+
+
+@pytest.mark.parametrize("patched", ["__stdout__", "__stderr__"])
+@pytest.mark.skipif(os.name != "nt", reason="Only Windows requires Colorama")
+def test_dont_wrap_if_winapi_false(monkeypatch, patched, patch_colorama):
+    stream = StreamIsattyTrue()
+    monkeypatch.setattr(sys, patched, stream, raising=False)
+    patch_colorama.win32.winapi_test.return_value = False
+    patch_colorama.winterm.enable_vt_processing.return_value = False
+    assert not should_wrap(stream)
+    assert patch_colorama.win32.winapi_test.called
+
+
+@pytest.mark.parametrize("patched", ["__stdout__", "__stderr__"])
+@pytest.mark.skipif(os.name != "nt", reason="Only Windows requires Colorama")
+def test_wrap_if_winapi_true_and_no_vt_support(monkeypatch, patched, patch_colorama):
+    stream = StreamIsattyTrue()
+    monkeypatch.setattr(sys, patched, stream, raising=False)
+    patch_colorama.win32.winapi_test.return_value = True
+    patch_colorama.winterm.enable_vt_processing.return_value = False
+    assert should_wrap(stream)
+    assert patch_colorama.winterm.enable_vt_processing.called
+    assert patch_colorama.win32.winapi_test.called
+
+
+@pytest.mark.parametrize("patched", ["__stdout__", "__stderr__"])
+@pytest.mark.skipif(os.name != "nt", reason="Only Windows requires Colorama")
+def test_wrap_if_winapi_true_and_vt_check_fails(monkeypatch, patched, patch_colorama):
+    stream = StreamIsattyTrue()
+    monkeypatch.setattr(sys, patched, stream, raising=False)
+    patch_colorama.win32.winapi_test.return_value = True
+    patch_colorama.winterm.enable_vt_processing.side_effect = RuntimeError
+    assert should_wrap(stream)
+    assert patch_colorama.winterm.enable_vt_processing.called
+    assert patch_colorama.win32.winapi_test.called
+
+
+@pytest.mark.parametrize("patched", ["__stdout__", "__stderr__"])
+@pytest.mark.skipif(os.name != "nt", reason="Only Windows requires Colorama")
+def test_wrap_if_winapi_true_and_stream_has_no_fileno(monkeypatch, patched, patch_colorama):
+    stream = StreamFilenoException()
+    monkeypatch.setattr(sys, patched, stream, raising=False)
+    patch_colorama.win32.winapi_test.return_value = True
+    assert should_wrap(stream)
+    assert not patch_colorama.winterm.enable_vt_processing.called
+    assert patch_colorama.win32.winapi_test.called
+
+
+@pytest.mark.parametrize("patched", ["__stdout__", "__stderr__"])
+@pytest.mark.skipif(os.name != "nt", reason="Only Windows requires Colorama")
+def test_wrap_if_winapi_true_and_old_colorama_version(monkeypatch, patched, patch_colorama):
+    stream = StreamIsattyTrue()
+    monkeypatch.setattr(sys, patched, stream, raising=False)
+    patch_colorama.win32.winapi_test.return_value = True
+    del patch_colorama.winterm.enable_vt_processing
+    assert should_wrap(stream)
+    assert patch_colorama.win32.winapi_test.called
diff --git a/tests/test_datetime.py b/tests/test_datetime.py
index 3e79f9a..ff7f6f9 100644
--- a/tests/test_datetime.py
+++ b/tests/test_datetime.py
@@ -115,6 +115,26 @@ def test_formatting(writer, freeze_time, time_format, date, timezone, expected):
         assert result == expected + "\n"
 
 
+@pytest.mark.parametrize(
+    "time_format, offset, expected",
+    [
+        ("%Y-%m-%d %H-%M-%S %f %Z %z", 7230.099, "2018-06-09 01-02-03 000000 ABC +020030.099000"),
+        ("YYYY-MM-DD HH-mm-ss zz Z ZZ", 6543, "2018-06-09 01-02-03 ABC +01:49:03 +014903"),
+        ("HH-mm-ss zz Z ZZ", -12345.06702, "01-02-03 ABC -03:26:45.067020 -032645.067020"),
+    ],
+)
+@pytest.mark.skipif(sys.version_info < (3, 7), reason="Offset must be a whole number of minutes")
+def test_formatting_timezone_offset_down_to_the_second(
+    writer, freeze_time, time_format, offset, expected
+):
+    date = datetime.datetime(2018, 6, 9, 1, 2, 3)
+    with freeze_time(date, ("ABC", offset)):
+        logger.add(writer, format="{time:%s}" % time_format)
+        logger.debug("Test")
+        result = writer.read()
+        assert result == expected + "\n"
+
+
 def test_locale_formatting(writer, freeze_time):
     dt = datetime.datetime(2011, 1, 1, 22, 22, 22, 0)
     with freeze_time(dt):
diff --git a/tests/test_filesink_rotation.py b/tests/test_filesink_rotation.py
index ef211ed..1bd09cf 100644
--- a/tests/test_filesink_rotation.py
+++ b/tests/test_filesink_rotation.py
@@ -605,6 +605,98 @@ def test_time_rotation_when_timezone_changes_backward_rename_file(freeze_time, t
     )
 
 
+@pytest.mark.parametrize(
+    "rotation",
+    [
+        "00:15",
+        datetime.time(0, 15, 0),
+        datetime.time(23, 15, 0, tzinfo=datetime.timezone.utc),
+        datetime.time(0, 15, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=+3600))),
+        datetime.time(22, 15, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=-3600))),
+    ],
+)
+def test_dont_rotate_earlier_when_utc_is_one_day_before(freeze_time, tmp_path, rotation):
+    with freeze_time("2018-10-24 00:30:00", ("CET", +3600)) as frozen:
+        logger.add(tmp_path / "test.log", format="{message}", rotation=rotation)
+        logger.info("First")
+        logger.remove()
+
+        frozen.tick(delta=datetime.timedelta(hours=1))
+        logger.add(tmp_path / "test.log", format="{message}", rotation=rotation)
+        logger.info("Second")
+        logger.remove()
+
+        frozen.tick(delta=datetime.timedelta(hours=23))
+        logger.add(tmp_path / "test.log", format="{message}", rotation=rotation)
+        logger.info("Third")
+        logger.remove()
+
+    check_dir(
+        tmp_path,
+        files=[
+            ("test.2018-10-24_00-30-00_000000.log", "First\nSecond\n"),
+            ("test.log", "Third\n"),
+        ],
+    )
+
+
+@pytest.mark.parametrize(
+    "rotation",
+    [
+        "23:45",
+        datetime.time(23, 45, 0),
+        datetime.time(0, 45, 0, tzinfo=datetime.timezone.utc),
+        datetime.time(1, 45, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=+3600))),
+        datetime.time(23, 45, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=-3600))),
+    ],
+)
+def test_dont_rotate_later_when_utc_is_one_day_after(freeze_time, tmp_path, rotation):
+    with freeze_time("2018-10-23 23:30:00", ("CET", -3600)) as frozen:
+        logger.add(tmp_path / "test.log", format="{message}", rotation=rotation)
+        logger.info("First")
+        logger.remove()
+
+        frozen.tick(delta=datetime.timedelta(hours=1))
+        logger.add(tmp_path / "test.log", format="{message}", rotation=rotation)
+        logger.info("Second")
+        logger.remove()
+
+        frozen.tick(delta=datetime.timedelta(hours=23))
+        logger.add(tmp_path / "test.log", format="{message}", rotation=rotation)
+        logger.info("Third")
+        logger.remove()
+
+    check_dir(
+        tmp_path,
+        files=[
+            ("test.2018-10-23_23-30-00_000000.log", "First\n"),
+            ("test.log", "Second\nThird\n"),
+        ],
+    )
+
+
+@pytest.mark.parametrize("timezone", [("CET", +3600), ("CET", -3600), ("UTC", 0)])
+def test_rotation_at_midnight_with_date_in_filename(freeze_time, tmp_path, timezone):
+    with freeze_time("2018-10-23 23:55:00", timezone) as frozen:
+        logger.add(tmp_path / "test.{time:YYYY-MM-DD}.log", format="{message}", rotation="00:00")
+        logger.info("First")
+        logger.remove()
+
+        frozen.tick(delta=datetime.timedelta(minutes=10))
+
+        logger.add(tmp_path / "test.{time:YYYY-MM-DD}.log", format="{message}", rotation="00:00")
+        logger.info("Second")
+        logger.remove()
+
+    check_dir(
+        tmp_path,
+        files=[
+            ("test.2018-10-23.log", "First\n"),
+            ("test.2018-10-24.log", "Second\n"),
+        ],
+    )
+
+
 @pytest.mark.parametrize("delay", [False, True])
 def test_time_rotation_reopening_native(tmp_path_local, delay):
     with tempfile.TemporaryDirectory(dir=str(tmp_path_local)) as test_dir:
diff --git a/tests/test_formatting.py b/tests/test_formatting.py
index 3a5ff10..9c77167 100644
--- a/tests/test_formatting.py
+++ b/tests/test_formatting.py
@@ -1,3 +1,4 @@
+import os
 import re
 
 import pytest
@@ -19,7 +20,7 @@ from loguru import logger
         ("{level.icon}", lambda r: r == "🐞"),
         ("{file}", lambda r: r == "test_formatting.py"),
         ("{file.name}", lambda r: r == "test_formatting.py"),
-        ("{file.path}", lambda r: r == __file__),
+        ("{file.path}", lambda r: os.path.normcase(r) == os.path.normcase(__file__)),
         ("{function}", lambda r: r == "test_log_formatters"),
         ("{module}", lambda r: r == "test_formatting"),
         ("{thread}", lambda r: re.fullmatch(r"\d+", r)),