Repository: kivy/kivy

Issue:
Logger does deepcopy of record which fails in some environments
**Software Versions**
* Python: 3.7
* OS: Win
* Kivy: 2.1.0.dev0

**Describe the bug**
Doing `logging.exception(e)` from within a particular script fails when there's an object in the stack that can't be copied. The following the is the error I get:

```
 --- Logging error ---
 Traceback (most recent call last):
   File "G:/Python/libs/ceed/ceed/analysis/merge_data.py", line 1665, in <module>
     align = alignment[experiment] = merger.get_alignment()
   File "G:/Python/libs/ceed/ceed/analysis/merge_data.py", line 1378, in get_alignment
     return self._get_alignment(True)
   File "G:/Python/libs/ceed/ceed/analysis/merge_data.py", line 1394, in _get_alignment
     'Cannot find any experiment in the MCS parsed data')
 AlignmentException: Cannot find any experiment in the MCS parsed data
 
 During handling of the above exception, another exception occurred:
 
 Traceback (most recent call last):
   File "C:\Users\Matthew Einhorn\AppData\Local\Programs\Python\Python37\Lib\logging\__init__.py", line 1034, in emit
     msg = self.format(record)
   File "C:\Users\Matthew Einhorn\AppData\Local\Programs\Python\Python37\Lib\logging\__init__.py", line 880, in format
     return fmt.format(record)
   File "g:\python\libs\kivy\kivy\logger.py", line 291, in format
     record = copy.deepcopy(record)
   File "E:\Python\Python37\lib\copy.py", line 180, in deepcopy
     y = _reconstruct(x, memo, *rv)
   File "E:\Python\Python37\lib\copy.py", line 280, in _reconstruct
     state = deepcopy(state, memo)
   File "E:\Python\Python37\lib\copy.py", line 150, in deepcopy
     y = copier(x, memo)
   File "E:\Python\Python37\lib\copy.py", line 240, in _deepcopy_dict
     y[deepcopy(key, memo)] = deepcopy(value, memo)
   File "E:\Python\Python37\lib\copy.py", line 150, in deepcopy
     y = copier(x, memo)
   File "E:\Python\Python37\lib\copy.py", line 220, in _deepcopy_tuple
     y = [deepcopy(a, memo) for a in x]
   File "E:\Python\Python37\lib\copy.py", line 220, in <listcomp>
     y = [deepcopy(a, memo) for a in x]
   File "E:\Python\Python37\lib\copy.py", line 169, in deepcopy
     rv = reductor(4)
 TypeError: can't pickle traceback objects
 Call stack:
   File "G:/Python/libs/ceed/ceed/analysis/merge_data.py", line 1673, in <module>
     logging.exception(str(e))
 Message: 'Cannot find any experiment in the MCS parsed data'
 Arguments: ()

Process finished with exit code 0
```

It seems to be because of this line in kivy/logger.py: `record = copy.deepcopy(record)`.


Discussion:
I think this line was introduced in #7062 ?
A while ago I looked into the related code, but came to the conclusion that things are very much complicated by so much code being run on module import. Imho, #7172 and the issues it refers to summarize this well.
I have some code that solves this in the pipeline. 

#7172 describes a much larger (and IMO, more important) issue, but it does not need to be solved in order to remove the `deepcopy()` code.

Resolving patch:
diff --git a/kivy/logger.py b/kivy/logger.py
--- a/kivy/logger.py
+++ b/kivy/logger.py
@@ -106,33 +106,9 @@
 
 Logger = None
 
-BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE = list(range(8))
-
-# These are the sequences need to get colored output
-RESET_SEQ = "\033[0m"
-COLOR_SEQ = "\033[1;%dm"
-BOLD_SEQ = "\033[1m"
-
 previous_stderr = sys.stderr
 
 
-def formatter_message(message, use_color=True):
-    if use_color:
-        message = message.replace("$RESET", RESET_SEQ)
-        message = message.replace("$BOLD", BOLD_SEQ)
-    else:
-        message = message.replace("$RESET", "").replace("$BOLD", "")
-    return message
-
-
-COLORS = {
-    'TRACE': MAGENTA,
-    'WARNING': YELLOW,
-    'INFO': GREEN,
-    'DEBUG': CYAN,
-    'CRITICAL': RED,
-    'ERROR': RED}
-
 logging.addLevelName(9, 'TRACE')
 logging.TRACE = 9
 LOG_LEVELS = {
@@ -280,30 +256,6 @@ def flush(self):
         self.clear_history()
 
 
-class ColoredFormatter(logging.Formatter):
-
-    def __init__(self, msg, use_color=True):
-        logging.Formatter.__init__(self, msg)
-        self.use_color = use_color
-
-    def format(self, record):
-        """Apply terminal color code to the record"""
-        # deepcopy so we do not mess up the record for other formatters
-        record = copy.deepcopy(record)
-        try:
-            msg = record.msg.split(':', 1)
-            if len(msg) == 2:
-                record.msg = '[%-12s]%s' % (msg[0], msg[1])
-        except:
-            pass
-        levelname = record.levelname
-        if self.use_color and levelname in COLORS:
-            levelname_color = (
-                COLOR_SEQ % (30 + COLORS[levelname]) + levelname + RESET_SEQ)
-            record.levelname = levelname_color
-        return logging.Formatter.format(self, record)
-
-
 class ConsoleHandler(logging.StreamHandler):
 
     def filter(self, record):
@@ -349,6 +301,133 @@ def logger_config_update(section, key, value):
     Logger.setLevel(level=LOG_LEVELS.get(value))
 
 
+class ColonSplittingLogRecord(logging.LogRecord):
+    """Clones an existing logRecord, but reformats the message field
+    if it contains a colon."""
+
+    def __init__(self, logrecord):
+        try:
+            parts = logrecord.msg.split(":", 1)
+            if len(parts) == 2:
+                new_msg = "[%-12s]%s" % (parts[0], parts[1])
+            else:
+                new_msg = parts[0]
+        except Exception:
+            new_msg = logrecord.msg
+
+        super().__init__(
+            name=logrecord.name,
+            level=logrecord.levelno,
+            pathname=logrecord.pathname,
+            lineno=logrecord.lineno,
+            msg=new_msg,
+            args=logrecord.args,
+            exc_info=logrecord.exc_info,
+            func=logrecord.funcName,
+            sinfo=logrecord.stack_info,
+        )
+
+
+class ColoredLogRecord(logging.LogRecord):
+    """Clones an existing logRecord, but reformats the levelname to add
+    color, and the message to add bolding (where indicated by $BOLD
+    and $RESET in the message)."""
+
+    BLACK = 0
+    RED = 1
+    GREEN = 2
+    YELLOW = 3
+    BLUE = 4
+    MAGENTA = 5
+    CYAN = 6
+    WHITE = 7
+    RESET_SEQ = "\033[0m"
+    COLOR_SEQ = "\033[1;%dm"
+    BOLD_SEQ = "\033[1m"
+
+    LEVEL_COLORS = {
+        "TRACE": MAGENTA,
+        "WARNING": YELLOW,
+        "INFO": GREEN,
+        "DEBUG": CYAN,
+        "CRITICAL": RED,
+        "ERROR": RED,
+    }
+
+    @classmethod
+    def _format_message(cls, message):
+        return message.replace(
+            "$RESET", cls.RESET_SEQ).replace("$BOLD", cls.BOLD_SEQ)
+
+    @classmethod
+    def _format_levelname(cls, levelname):
+        if levelname in cls.LEVEL_COLORS:
+            return (
+                cls.COLOR_SEQ % (30 + cls.LEVEL_COLORS[levelname])
+                + levelname
+                + cls.RESET_SEQ
+            )
+        return levelname
+
+    def __init__(self, logrecord):
+        super().__init__(
+            name=logrecord.name,
+            level=logrecord.levelno,
+            pathname=logrecord.pathname,
+            lineno=logrecord.lineno,
+            msg=logrecord.msg,
+            args=logrecord.args,
+            exc_info=logrecord.exc_info,
+            func=logrecord.funcName,
+            sinfo=logrecord.stack_info,
+        )
+        self.levelname = self._format_levelname(self.levelname)
+        self.msg = self._format_message(self.msg)
+
+
+# Included for backward compatibility only.
+# Could be used to override colors.
+COLORS = ColoredLogRecord.LEVEL_COLORS
+
+
+class UncoloredLogRecord(logging.LogRecord):
+    """Clones an existing logRecord, but reformats the message
+    to remove $BOLD/$RESET markup."""
+
+    @classmethod
+    def _format_message(cls, message):
+        return message.replace("$RESET", "").replace("$BOLD", "")
+
+    def __init__(self, logrecord):
+        super().__init__(
+            name=logrecord.name,
+            level=logrecord.levelno,
+            pathname=logrecord.pathname,
+            lineno=logrecord.lineno,
+            msg=logrecord.msg,
+            args=logrecord.args,
+            exc_info=logrecord.exc_info,
+            func=logrecord.funcName,
+            sinfo=logrecord.stack_info,
+        )
+        self.msg = self._format_message(self.msg)
+
+
+class KivyFormatter(logging.Formatter):
+    """Split out first field in message marked with a colon,
+    and either apply terminal color codes to the record, or strip
+    out color markup if colored logging is not available."""
+
+    def __init__(self, *args, use_color=True, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._coloring_cls = (
+            ColoredLogRecord if use_color else UncoloredLogRecord)
+
+    def format(self, record):
+        return super().format(
+            self._coloring_cls(ColonSplittingLogRecord(record)))
+
+
 #: Kivy default logger instance
 Logger = logging.getLogger('kivy')
 Logger.logfile_activated = None
@@ -387,14 +466,12 @@ def logger_config_update(section, key, value):
         if not use_color:
             # No additional control characters will be inserted inside the
             # levelname field, 7 chars will fit "WARNING"
-            color_fmt = formatter_message(
-                '[%(levelname)-7s] %(message)s', use_color)
+            fmt = "[%(levelname)-7s] %(message)s"
         else:
             # levelname field width need to take into account the length of the
             # color control codes (7+4 chars for bold+color, and reset)
-            color_fmt = formatter_message(
-                '[%(levelname)-18s] %(message)s', use_color)
-        formatter = ColoredFormatter(color_fmt, use_color=use_color)
+            fmt = "[%(levelname)-18s] %(message)s"
+        formatter = KivyFormatter(fmt, use_color=use_color)
         console = ConsoleHandler()
         console.setFormatter(formatter)
         Logger.addHandler(console)


Test patch:
diff --git a/kivy/tests/test_logger.py b/kivy/tests/test_logger.py
--- a/kivy/tests/test_logger.py
+++ b/kivy/tests/test_logger.py
@@ -3,6 +3,7 @@
 ============
 """
 
+import logging
 import pytest
 import pathlib
 import time
@@ -23,7 +24,7 @@ def file_handler():
         Config.set("kivy", "log_maxfiles", log_maxfiles)
 
 
-@pytest.mark.parametrize('n', [0, 1, 5])
+@pytest.mark.parametrize("n", [0, 1, 5])
 def test_purge_logs(tmp_path, file_handler, n):
     from kivy.config import Config
     from kivy.logger import FileHandler
@@ -36,13 +37,13 @@ def test_purge_logs(tmp_path, file_handler, n):
     handler._configure()
     open_file = pathlib.Path(handler.filename).name
     # wait a little so the timestamps are different for different files
-    time.sleep(.05)
+    time.sleep(0.05)
 
-    names = [f'log_{i}.txt' for i in range(n + 2)]
+    names = [f"log_{i}.txt" for i in range(n + 2)]
     for name in names:
         p = tmp_path / name
-        p.write_text('some data')
-        time.sleep(.05)
+        p.write_text("some data")
+        time.sleep(0.05)
 
     handler.purge_logs()
 
@@ -85,3 +86,203 @@ def test_trace_level_has_level_name():
     Logger.setLevel(9)
     Logger.trace("test: This is trace message 1")
     assert LoggerHistory.history[0].levelname == "TRACE"
+
+
+def test_logging_does_not_deep_copy():
+    # If the Logger does a deep copy of an uncopyable
+    # data structure, it will fail. See issues #7585 and #7528.
+
+    import threading
+    from kivy.logger import Logger
+
+    class UncopyableDatastructure:
+        def __init__(self, name):
+            self._lock = threading.Lock()
+            self._name = name
+
+        def __str__(self):
+            return "UncopyableDatastructure(name=%r)" % self._name
+
+    s = UncopyableDatastructure("Uncopyable")
+    Logger.error("The value of s is %s", s)
+
+
+def configured_string_logging(unique_code, formatter=None):
+    """
+    Helper function provides logger configured to write to log_output.
+    """
+    from io import StringIO
+
+    log_output = StringIO()
+
+    handler = logging.StreamHandler(stream=log_output)
+    if formatter:
+        handler.setFormatter(formatter)
+
+    logger = logging.getLogger("tests.%s" % unique_code)
+
+    # Do not escalate to root/Kivy loggers.
+    logger.setLevel(9)  # Catch everything
+    logger.propagate = False
+    assert not logger.hasHandlers(), "Must use unique code between tests."
+
+    logger.addHandler(handler)
+
+    return logger, log_output
+
+
+def test_colonsplittinglogrecord_with_colon():
+    from kivy.logger import ColonSplittingLogRecord
+
+    originallogrecord = logging.LogRecord(
+        name="kivy.test",
+        level=logging.DEBUG,
+        pathname="test.py",
+        lineno=1,
+        msg="Part1: Part2: Part 3",
+        args=("args",),
+        exc_info=None,
+        func="test_colon_splitting",
+        sinfo=None,
+    )
+    # Just making sure we know what it looks like before.
+    assert (
+        str(originallogrecord)
+        == '<LogRecord: kivy.test, 10, test.py, 1, "Part1: Part2: Part 3">'
+    )
+    shimmedlogrecord = ColonSplittingLogRecord(originallogrecord)
+    assert (
+        str(shimmedlogrecord)
+        == '<LogRecord: kivy.test, 10, test.py, 1, '
+           '"[Part1       ] Part2: Part 3">'
+    )
+
+
+def test_colonsplittinglogrecord_without_colon():
+    from kivy.logger import ColonSplittingLogRecord
+
+    originallogrecord = logging.LogRecord(
+        name="kivy.test",
+        level=logging.DEBUG,
+        pathname="test.py",
+        lineno=1,
+        msg="Part1 Part2 Part 3",
+        args=("args",),
+        exc_info=None,
+        func="test_colon_splitting",
+        sinfo=None,
+    )
+    shimmedlogrecord = ColonSplittingLogRecord(originallogrecord)
+    # No colons means no change.
+    assert str(originallogrecord) == str(shimmedlogrecord)
+
+
+def test_uncoloredlogrecord_without_markup():
+    from kivy.logger import UncoloredLogRecord
+
+    originallogrecord = logging.LogRecord(
+        name="kivy.test",
+        level=logging.DEBUG,
+        pathname="test.py",
+        lineno=1,
+        msg="Part1: Part2 Part 3",
+        args=("args",),
+        exc_info=None,
+        func="test_colon_splitting",
+        sinfo=None,
+    )
+    shimmedlogrecord = UncoloredLogRecord(originallogrecord)
+    # No markup means no change.
+    assert str(originallogrecord) == str(shimmedlogrecord)
+
+
+def test_uncoloredlogrecord_with_markup():
+    from kivy.logger import UncoloredLogRecord
+
+    originallogrecord = logging.LogRecord(
+        name="kivy.test",
+        level=logging.DEBUG,
+        pathname="test.py",
+        lineno=1,
+        msg="Part1: $BOLDPart2$RESET Part 3",
+        args=("args",),
+        exc_info=None,
+        func="test_colon_splitting",
+        sinfo=None,
+    )
+    shimmedlogrecord = UncoloredLogRecord(originallogrecord)
+    # No markup means no change.
+    assert (
+        str(shimmedlogrecord)
+        == '<LogRecord: kivy.test, 10, test.py, 1, "Part1: Part2 Part 3">'
+    )
+
+
+def test_coloredlogrecord_without_markup():
+    from kivy.logger import ColoredLogRecord
+
+    originallogrecord = logging.LogRecord(
+        name="kivy.test",
+        level=logging.DEBUG,
+        pathname="test.py",
+        lineno=1,
+        msg="Part1: Part2 Part 3",
+        args=("args",),
+        exc_info=None,
+        func="test_colon_splitting",
+        sinfo=None,
+    )
+    shimmedlogrecord = ColoredLogRecord(originallogrecord)
+    # The str() looks the same, because it doesn't include levelname.
+    assert str(originallogrecord) == str(shimmedlogrecord)
+    # But there is a change in the levelname
+    assert originallogrecord.levelname != shimmedlogrecord.levelname
+    assert shimmedlogrecord.levelname == "\x1b[1;36mDEBUG\x1b[0m"
+
+
+def test_coloredlogrecord_with_markup():
+    from kivy.logger import ColoredLogRecord
+
+    originallogrecord = logging.LogRecord(
+        name="kivy.test",
+        level=logging.INFO,
+        pathname="test.py",
+        lineno=1,
+        msg="Part1: $BOLDPart2$RESET Part 3",
+        args=("args",),
+        exc_info=None,
+        func="test_colon_splitting",
+        sinfo=None,
+    )
+    shimmedlogrecord = ColoredLogRecord(originallogrecord)
+    # Bolding has been added to message.
+    assert (
+        str(shimmedlogrecord)
+        == '<LogRecord: kivy.test, 20, test.py, 1, '
+           '"Part1: \x1b[1mPart2\x1b[0m Part 3">'
+    )
+    # And there is a change in the levelname
+    assert originallogrecord.levelname != shimmedlogrecord.levelname
+    assert shimmedlogrecord.levelname == "\x1b[1;32mINFO\x1b[0m"
+
+
+def test_kivyformatter_colon_no_color():
+    from kivy.logger import KivyFormatter
+
+    formatter = KivyFormatter("[%(levelname)-7s] %(message)s", use_color=False)
+    logger, log_output = configured_string_logging("1", formatter)
+    logger.info("Fancy: $BOLDmess$RESETage")
+    assert log_output.getvalue() == "[INFO   ] [Fancy       ] message\n"
+
+
+def test_kivyformatter_colon_color():
+    from kivy.logger import KivyFormatter
+
+    formatter = KivyFormatter("[%(levelname)-18s] %(message)s", use_color=True)
+
+    logger, log_output = configured_string_logging("2", formatter)
+    logger.info("Fancy: $BOLDmess$RESETage")
+    assert (
+        log_output.getvalue()
+        == "[\x1b[1;32mINFO\x1b[0m   ] [Fancy       ] \x1b[1mmess\x1b[0mage\n"
+    )