Repository: Delgan/loguru
Pull request #165 (2019-11-13 21:26:39)

make this a one-liner
Seems like this kind of code can be simplified quite a lot...

Delgan: Yeah, I agree the one-liner looks better, but the "ugly" `if` were actually used on purpose... :slightly_smiling_face: 

I'm not a big fan of using `getattr()` for accessing "static" attributes this way, while the set of valid values is finite and well-known. 

Consider `parse_frequency("__dict__")` and your function will return a `dict` object instead of `None`.
Delgan: Although I think it would be probably more "pythonic" to build a `dict` with the expected values, rather than chaining `if`:

```python
frequencies = {
    "hourly": Frequencies.hourly,
    ...
}
return frequencies.get(frequency)
```
interrogator: If you're legitimately worried about something else like `"__dict__"` getting in there, I suppose this PR should ideally fail a test. But yeah, one could also just add:

```python
if frequency not in {'weekly', 'yearly' ...}:
    return
```

I don't mind which style you'd like the most, but surely any of these proposals are nicer and better to maintain than the current branching code...
Delgan: > If you're legitimately worried about something else like `"__dict__"` getting in there, I suppose this PR should ideally fail a test. But yeah, one could also just add:

Yep, totally, that was my plan after seeing your PR. At the time of writing the tests, I did not not think of this alternative implementation, so I did not add a test to catch this particular edge case (although there is tests for invalid values).



> I don't mind which style you'd like the most, but surely any of these proposals are nicer and better to maintain than the current branching code...

I agree. That's basically a `switch` statement that we need here, and the common idiom for this in Python is to use a `dict`, I think.

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 6334c6e..f72cb0b 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,11 +1,13 @@
 `Unreleased`_
 =============
 
+- Add a way to filter logs using one level per module in the form of a ``dict`` passed to the ``filter`` argument (`#148 <https://github.com/Delgan/loguru/issues/148>`_).
 - Add type hints to annotate the public methods using a ``.pyi`` stub file (`#162 <https://github.com/Delgan/loguru/issues/162>`_).
 - Remove ``class`` objects from the list of supported sinks and restrict usage of ``**kwargs`` in ``.add()`` to file sink only. User is in charge of instantiating sink and wrapping additional keyword arguments if needed, before passing it to the ``.add()`` method.
 - Rename the ``logger.configure()`` keyword argument ``patch`` to ``patcher`` so it better matches the signature of ``logger.patch()``.
 - Fix incompatibility with ``multiprocessing`` on Windows by entirely refactoring the internal structure of the ``logger`` so it can be inherited by child processes along with added handlers (`#108 <https://github.com/Delgan/loguru/issues/108>`_).
 - Fix an error using a ``filter`` function "by name" while receiving a log with ``record["name"]`` equals to ``None``.
+- Fix incorrect record displayed while handling errors (if ``catch=True``) occurring because of non-picklable objects (if ``enqueue=True``).
 - Fix ``AttributeError`` while using a file sink on some distributions (like Alpine Linux) missing the ``os.getxattr`` and ``os.setxattr`` functions (`#158 <https://github.com/Delgan/loguru/pull/158>`_, thanks `@joshgordon <https://github.com/joshgordon>`_).
 - Fix values wrongly displayed for keyword arguments during exception formatting with ``diagnose=True`` (`#144 <https://github.com/Delgan/loguru/issues/144>`_).
 - Fix logging messages wrongly chopped off at the end while using standard ``logging.Handler`` sinks with ``.opt(raw=True)`` (`#136 <https://github.com/Delgan/loguru/issues/136>`_).
@@ -13,8 +15,10 @@
 - Prevent hypothetical ``ImportError`` if a Python installation is missing the built-in ``distutils`` module (`#118 <https://github.com/Delgan/loguru/issues/118>`_).
 - Add the possibility to convert ``datetime`` to UTC before formatting (in logs and filenames) by adding ``"!UTC"`` at the end of the time format specifier (`#128 <https://github.com/Delgan/loguru/issues/128>`_).
 - Add the level ``name`` as the first argument of namedtuple returned by the ``.level()`` method.
-- Replace ``ValueError`` with ``TypeError`` for exceptions raised when a ``logger`` method was called with argument of invalid type.
-- Remove inheritance of some record dict attributes to ``str`` (for ``"level"``, ``"file"``, ``"thread"`` and ``"process"``) and ``namedtuple`` (for ``"exception"``) in order to facilitate patching (but interface remains the same).
+- Raise ``TypeError`` instead of ``ValueError`` when a ``logger`` method is called with argument of invalid type.
+- Raise ``ValueError`` if the built-in ``format()`` and ``filter()`` functions are respectively used as ``format`` and ``filter`` arguments of the ``add()`` method. This helps the user to understand the problem, as such a mistake can quite easily occur (`#177 <https://github.com/Delgan/loguru/issues/177>`_).
+- Remove inheritance of some record dict attributes to ``str`` (for ``"level"``, ``"file"``, ``"thread"`` and ``"process"``).
+- Give a name to the worker thread used when ``enqueue=True`` (`#174 <https://github.com/Delgan/loguru/pull/174>`_, thanks `@t-mart <https://github.com/t-mart>`_).
 
 
 `0.3.2`_ (2019-07-21)
diff --git a/docs/resources/recipes.rst b/docs/resources/recipes.rst
index 000d2f4..2e88758 100644
--- a/docs/resources/recipes.rst
+++ b/docs/resources/recipes.rst
@@ -33,6 +33,9 @@ Code snippets and recipes for ``loguru``
 
 .. _`unicode`: https://docs.python.org/3/howto/unicode.html
 
+.. |stackprinter| replace:: ``stackprinter``
+.. _stackprinter: https://github.com/cknd/stackprinter
+
 .. _`GH#88`: https://github.com/Delgan/loguru/issues/88
 .. _`GH#132`: https://github.com/Delgan/loguru/issues/132
 
@@ -303,6 +306,83 @@ Others solutions are possible by using a formatting function or class. For examp
     logger.add(sys.stderr, format=formatter.format)
 
 
+Customizing the formatting of exceptions
+----------------------------------------
+
+Loguru will automatically add the traceback of occurring exception while using ``logger.exception()`` or ``logger.opt(exception=True)``::
+
+    def inverse(x):
+        try:
+            1 / x
+        except ZeroDivisionError:
+            logger.exception("Oups...")
+
+    if __name__ == "__main__":
+        inverse(0)
+
+.. code-block:: none
+
+    2019-11-15 10:01:13.703 | ERROR    | __main__:inverse:8 - Oups...
+    Traceback (most recent call last):
+    File "foo.py", line 6, in inverse
+        1 / x
+    ZeroDivisionError: division by zero
+
+If the handler is added with ``backtrace=True``, the traceback is extended to see where the exception came from:
+
+.. code-block:: none
+
+    2019-11-15 10:11:32.829 | ERROR    | __main__:inverse:8 - Oups...
+    Traceback (most recent call last):
+      File "foo.py", line 16, in <module>
+        inverse(0)
+    > File "foo.py", line 6, in inverse
+        1 / x
+    ZeroDivisionError: division by zero
+
+If the handler is added with ``diagnose=True``, then the traceback is annotated to see what caused the problem:
+
+.. code-block:: none
+
+    Traceback (most recent call last):
+
+    File "foo.py", line 6, in inverse
+        1 / x
+            └ 0
+
+    ZeroDivisionError: division by zero
+
+It is possible to further personalize the formatting of exception by adding an handler with a custom ``format`` function. For example, supposing you want to format errors using the |stackprinter|_ library::
+
+    import stackprinter
+
+    def format(record):
+        format_ = "{time} {message}\n"
+
+        if record["exception"] is not None:
+            record["extra"]["stack"] = stackprinter.format(record["exception"])
+            format_ += "{extra[stack]}\n"
+
+        return format_
+
+    logger.add(sys.stderr, format=format)
+
+.. code-block:: none
+
+    2019-11-15T10:46:18.059964+0100 Oups...
+    File foo.py, line 17, in inverse
+        15   def inverse(x):
+        16       try:
+    --> 17           1 / x
+        18       except ZeroDivisionError:
+        ..................................................
+        x = 0
+        ..................................................
+
+    ZeroDivisionError: division by zero
+
+
+
 Capturing standard ``stdout``, ``stderr`` and ``warnings``
 ----------------------------------------------------------
 
diff --git a/loguru/__init__.pyi b/loguru/__init__.pyi
index fd46ad5..403309d 100644
--- a/loguru/__init__.pyi
+++ b/loguru/__init__.pyi
@@ -64,7 +64,7 @@ class RecordProcess(_RecordAttribute):
     id: int
     name: str
 
-class RecordException(ExcInfo):
+class RecordException(NamedTuple):
     type: Optional[Type[BaseException]]
     value: Optional[BaseException]
     traceback: Optional[TracebackType]
@@ -79,7 +79,7 @@ class Record(TypedDict):
     line: int
     message: str
     module: str
-    name: str
+    name: Union[str, None]
     process: RecordProcess
     thread: RecordThread
     time: datetime
@@ -90,6 +90,7 @@ class Message(str):
 class Writable(Protocol):
     def write(self, message: Message) -> None: ...
 
+FilterDict = Dict[Union[str, None], Union[str, int, bool]]
 FilterFunction = Callable[[Record], bool]
 FormatFunction = Callable[[Record], str]
 PatcherFunction = Callable[[Record], None]
@@ -112,7 +113,7 @@ class _HandlerConfig(TypedDict, total=False):
     sink: Union[str, PathLike, TextIO, Writable, Callable[[Message], None], Handler]
     level: Union[str, int]
     format: Union[str, FormatFunction]
-    filter: Union[str, FilterFunction]
+    filter: Optional[Union[str, FilterFunction, FilterDict]]
     colorize: Optional[bool]
     serialize: bool
     backtrace: bool
@@ -136,7 +137,7 @@ class Logger:
         *,
         level: Union[str, int] = ...,
         format: Union[str, FormatFunction] = ...,
-        filter: Union[str, FilterFunction] = ...,
+        filter: Optional[Union[str, FilterFunction, FilterDict]] = ...,
         colorize: Optional[bool] = ...,
         serialize: bool = ...,
         backtrace: bool = ...,
@@ -151,7 +152,7 @@ class Logger:
         *,
         level: Union[str, int] = ...,
         format: Union[str, FormatFunction] = ...,
-        filter: Union[str, FilterFunction] = ...,
+        filter: Optional[Union[str, FilterFunction, FilterDict]] = ...,
         colorize: Optional[bool] = ...,
         serialize: bool = ...,
         backtrace: bool = ...,
diff --git a/loguru/_ansimarkup.py b/loguru/_ansimarkup.py
index d7b9255..b5f8d21 100644
--- a/loguru/_ansimarkup.py
+++ b/loguru/_ansimarkup.py
@@ -167,6 +167,15 @@ class AnsiMarkup:
         self._tags = []
         self._results = []
 
+    @staticmethod
+    def parse(color):
+        return AnsiMarkup(strip=False).feed(color.strip(), strict=False)
+
+    @staticmethod
+    def verify(format_, custom_markups_list):
+        custom_markups = {markup: "" for markup in custom_markups_list}
+        AnsiMarkup(custom_markups=custom_markups, strip=True).feed(format_, strict=True)
+
     def feed(self, text, *, strict=True):
         if strict:
             pre_tags = self._tags
diff --git a/loguru/_file_sink.py b/loguru/_file_sink.py
index 3999b55..cf1e461 100644
--- a/loguru/_file_sink.py
+++ b/loguru/_file_sink.py
@@ -1,4 +1,3 @@
-import abc
 import datetime as datetime_
 import decimal
 import glob
@@ -285,16 +284,19 @@ class FileSink:
 
                 compress = partial(Compression.add_compress, opener=tarfile.open, mode="w:")
             elif ext == "tar.gz":
-                import tarfile, gzip
+                import tarfile
+                import gzip
 
                 compress = partial(Compression.add_compress, opener=tarfile.open, mode="w:gz")
             elif ext == "tar.bz2":
-                import tarfile, bz2
+                import tarfile
+                import bz2
 
                 compress = partial(Compression.add_compress, opener=tarfile.open, mode="w:bz2")
 
             elif ext == "tar.xz":
-                import tarfile, lzma
+                import tarfile
+                import lzma
 
                 compress = partial(Compression.add_compress, opener=tarfile.open, mode="w:xz")
             elif ext == "zip":
diff --git a/loguru/_filters.py b/loguru/_filters.py
new file mode 100644
index 0000000..777425d
--- /dev/null
+++ b/loguru/_filters.py
@@ -0,0 +1,32 @@
+def filter_none(record):
+    return record["name"] is not None
+
+
+def filter_by_name(record, parent, length):
+    name = record["name"]
+    if name is None:
+        return False
+    return (name + ".")[:length] == parent
+
+
+def filter_by_level(record, level_per_module):
+    name = record["name"]
+
+    level = 0
+
+    if name in level_per_module:
+        level = level_per_module[name]
+    elif name is not None:
+        lookup = ""
+        if "" in level_per_module:
+            level = level_per_module[""]
+        for n in name.split("."):
+            lookup += n
+            if lookup in level_per_module:
+                level = level_per_module[lookup]
+            lookup += "."
+
+    if level is False:
+        return False
+
+    return record["level"].no >= level
diff --git a/loguru/_handler.py b/loguru/_handler.py
index f10f39f..d1814ba 100644
--- a/loguru/_handler.py
+++ b/loguru/_handler.py
@@ -65,7 +65,11 @@ class Handler:
         if self._enqueue:
             self._owner_process = multiprocessing.current_process()
             self._queue = multiprocessing.SimpleQueue()
-            self._thread = threading.Thread(target=self._queued_writer, daemon=True)
+            self._thread = threading.Thread(
+                target=self._queued_writer,
+                daemon=True,
+                name="loguru-writer-%d" % self._id
+            )
             self._thread.start()
 
     def __repr__(self):
@@ -139,15 +143,19 @@ class Handler:
                 if self._stopped:
                     return
                 if self._enqueue:
-                    self._queue.put(str_record)
+                    try:
+                        self._queue.put(str_record)
+                    except Exception:
+                        if not self._catch:
+                            raise
+                        self._handle_error()
                 else:
                     self._sink.write(str_record)
 
         except Exception:
-            if self._catch:
-                self._handle_error(record)
-            else:
+            if not self._catch:
                 raise
+            self._handle_error(record)
 
     def stop(self):
         with self._lock:
@@ -248,16 +256,22 @@ class Handler:
         while True:
             try:
                 message = queue.get()
-                if message is None:
-                    break
+            except Exception:
+                if not self._catch:
+                    raise
+                self._handle_error()
+                continue
+
+            if message is None:
+                break
+
+            try:
                 self._sink.write(message)
             except Exception:
-                if self._catch:
-                    if message and hasattr(message, "record"):
-                        message = message.record
-                    self._handle_error(message)
-                else:
+                if not self._catch:
                     raise
+                record = getattr(message, "record", None)
+                self._handle_error(record)
 
     def _handle_error(self, record=None):
         if not sys.stderr:
diff --git a/loguru/_logger.py b/loguru/_logger.py
index 89483d6..9bcf2c2 100644
--- a/loguru/_logger.py
+++ b/loguru/_logger.py
@@ -47,25 +47,27 @@
 
 .. |file-like object| replace:: ``file-like object``
 .. _file-like object: https://docs.python.org/3/glossary.html#term-file-object
-.. |function| replace:: ``function``
-.. _function: https://docs.python.org/3/glossary.html#function
+.. |callable object| replace:: ``callable object``
+.. _callable object: https://docs.python.org/3/library/functions.html#callable
 .. |re.Pattern| replace:: ``re.Pattern``
 .. _re.Pattern: https://docs.python.org/3/library/re.html#re-objects
 .. |re.Match| replace:: ``re.Match``
 .. _re.Match: https://docs.python.org/3/library/re.html#match-objects
 
+.. |better_exceptions| replace:: ``better_exceptions``
+.. _better_exceptions: https://github.com/Qix-/better-exceptions
+
 .. _Pendulum: https://pendulum.eustace.io/docs/#tokens
-.. _better-exceptions: https://github.com/Qix-/better-exceptions
 .. _@sdispater: https://github.com/sdispater
 .. _@Qix-: https://github.com/Qix-
 .. _Formatting directives: https://docs.python.org/3/library/string.html#format-string-syntax
 """
+import builtins
 import contextlib
 import functools
 import inspect
 import itertools
 import logging
-import pickle
 import re
 import sys
 import threading
@@ -78,6 +80,7 @@ from threading import current_thread
 
 from . import _colorama
 from . import _defaults
+from . import _filters
 from ._ansimarkup import AnsiMarkup
 from ._better_exceptions import ExceptionFormatter
 from ._datetime import aware_now
@@ -100,21 +103,6 @@ else:
     from contextvars import ContextVar
 
 
-def parse_ansi(color):
-    return AnsiMarkup(strip=False).feed(color.strip(), strict=False)
-
-
-def filter_module(record, parent, length):
-    name = record["name"]
-    if name is None:
-        return False
-    return (name + ".")[:length] == parent
-
-
-def filter_none(record):
-    return record["name"] is not None
-
-
 Level = namedtuple("Level", ["name", "no", "color", "icon"])
 
 start_time = aware_now()
@@ -170,7 +158,7 @@ class Core:
         ]
         self.levels = {level.name: level for level in levels}
         self.levels_ansi_codes = {
-            name: parse_ansi(level.color) for name, level in self.levels.items()
+            name: AnsiMarkup.parse(level.color) for name, level in self.levels.items()
         }
         self.levels_ansi_codes[None] = ""
 
@@ -243,14 +231,14 @@ class Logger:
 
         Parameters
         ----------
-        sink : |file-like object|_, |str|, |Path|, |function|_, or |Handler|
+        sink : |file-like object|_, |str|, |Path|, |callable object|_, or |Handler|
             An object in charge of receiving formatted logging messages and propagating them to an
             appropriate endpoint.
         level : |int| or |str|, optional
             The minimum severity level from which logged messages should be sent to the sink.
-        format : |str| or |function|_, optional
+        format : |str| or |callable object|_, optional
             The template used to format logged messages before being sent to the sink.
-        filter : |function|_ or |str|, optional
+        filter : |callable object|_, |str| or |dict|, optional
             A directive optionally used to decide for each logged message whether it should be sent
             to the sink or not.
         colorize : |bool|, optional
@@ -282,13 +270,13 @@ class Logger:
 
         Parameters
         ----------
-        rotation : |str|, |int|, |time|, |timedelta| or |function|_, optional
+        rotation : |str|, |int|, |time|, |timedelta| or |callable object|_, optional
             A condition indicating whenever the current logged file should be closed and a new one
             started.
-        retention : |str|, |int|, |timedelta| or |function|_, optional
+        retention : |str|, |int|, |timedelta| or |callable object|_, optional
             A directive filtering old files that should be removed during rotation or end of
             program.
-        compression : |str| or |function|_, optional
+        compression : |str| or |callable object|_, optional
             A compression or archive format to which log files should be converted at closure.
         delay : |bool|, optional
             Whether the file should be created as soon as the sink is configured, or delayed until
@@ -328,8 +316,8 @@ class Logger:
           method, it will be automatically called at sink termination.
         - A file path as |str| or |Path|. It can be parametrized with some additional parameters,
           see below.
-        - A simple |function|_ like ``lambda msg: print(msg)``. This allows for logging
-          procedure entirely defined by user preferences and needs.
+        - A |callable object|_ (such as a simple function) like ``lambda msg: print(msg)``. This
+          allows for logging procedure entirely defined by user preferences and needs.
         - A built-in |Handler| like ``logging.StreamHandler``. In such a case, the `Loguru` records
           are automatically converted to the structure expected by the |logging| module.
 
@@ -357,6 +345,13 @@ class Logger:
         sink and which one are ignored. A function can be used, accepting the record as an
         argument, and returning ``True`` if the message should be logged, ``False`` otherwise. If
         a string is used, only the records with the same ``name`` and its children will be allowed.
+        One can also pass a ``dict`` mapping module names to minimum required level. In such case,
+        each log record will search for it's closest parent in the ``dict`` and use the associated
+        level as the filter. The ``dict`` values can be ``int`` severity, ``str`` level name or
+        ``True`` and ``False`` to respectively authorize and discard all module logs
+        unconditionally. In order to set a default level, the `""` module name should be used as it
+        is the parent of all modules.
+
 
         .. _levels:
 
@@ -560,7 +555,7 @@ class Logger:
         - a |str| for human-friendly parametrization of one of the previously enumerated types.
           Examples: ``"100 MB"``, ``"0.5 GB"``, ``"1 month 2 weeks"``, ``"4 days"``, ``"10h"``,
           ``"monthly"``, ``"18:00"``, ``"sunday"``, ``"w0"``, ``"monday at 12:00"``, ...
-        - a |function|_ which will be called before logging. It should accept two
+        - a |callable object|_ which will be invoked before logging. It should accept two
           arguments: the logged message and the file object, and it should return ``True`` if
           the rotation should happen now, ``False`` otherwise.
 
@@ -572,9 +567,9 @@ class Logger:
         - a |timedelta| which specifies the maximum age of files to keep.
         - a |str| for human-friendly parametrization of the maximum age of files to keep.
           Examples: ``"1 week, 3 days"``, ``"2 months"``, ...
-        - a |function|_ which will be called before the retention process. It should accept the list
-          of log files as argument and process to whatever it wants (moving files, removing them,
-          etc.).
+        - a |callable object|_ which will be invoked before the retention process. It should accept
+          the list of log files as argument and process to whatever it wants (moving files, removing
+          them, etc.).
 
         The ``compression`` happens at rotation or at sink stop if rotation is ``None``. This
         parameter accepts:
@@ -582,8 +577,8 @@ class Logger:
         - a |str| which corresponds to the compressed or archived file extension. This can be one
           of: ``"gz"``, ``"bz2"``, ``"xz"``, ``"lzma"``, ``"tar"``, ``"tar.gz"``, ``"tar.bz2"``,
           ``"tar.xz"``, ``"zip"``.
-        - a |function|_ which will be called before file termination. It should accept the path
-          of the log file as argument and process to whatever it wants (custom compression,
+        - a |callable object|_ which will be invoked before file termination. It should accept the
+          path of the log file as argument and process to whatever it wants (custom compression,
           network sending, removing it, etc.).
 
         .. _color:
@@ -661,7 +656,8 @@ class Logger:
 
         The default levels' attributes can also be modified by setting the ``LOGURU_[LEVEL]_[ATTR]``
         environment variable. For example, on Windows: ``setx LOGURU_DEBUG_COLOR "<blue>"``
-        or ``setx LOGURU_TRACE_ICON "🚀"``.
+        or ``setx LOGURU_TRACE_ICON "🚀"``. If you use the ``set`` command, do not include quotes
+        but escape special symbol as needed, e.g. ``set LOGURU_DEBUG_COLOR=^<blue^>``.
 
         If you want to disable the pre-configured sink, you can set the ``LOGURU_AUTOINIT``
         variable to ``False``.
@@ -678,7 +674,7 @@ class Logger:
         >>> def debug_only(record):
         ...     return record["level"].name == "DEBUG"
         ...
-        >>> logger.add("debug.log", filter=debug_only)  # Other levels are filterd out
+        >>> logger.add("debug.log", filter=debug_only)  # Other levels are filtered out
 
         >>> def my_sink(message):
         ...     record = message.record
@@ -686,6 +682,13 @@ class Logger:
         ...
         >>> logger.add(my_sink)
 
+        >>> level_per_module = {
+        ...     "": "DEBUG",
+        ...     "third.lib": "WARNING",
+        ...     "anotherlib": False
+        ... }
+        >>> logger.add(lambda m: print(m, end=""), filter=level_per_module, level=0)
+
         >>> from logging import StreamHandler
         >>> logger.add(StreamHandler(sys.stderr), format="{message}")
 
@@ -705,7 +708,7 @@ class Logger:
 
         if isinstance(sink, (str, PathLike)):
             path = sink
-            name = str(path)
+            name = repr(str(path))
 
             if colorize is None:
                 colorize = False
@@ -716,7 +719,7 @@ class Logger:
             terminator = "\n"
             exception_prefix = ""
         elif hasattr(sink, "write") and callable(sink.write):
-            name = getattr(sink, "name", repr(sink))
+            name = getattr(sink, "name", None) or repr(sink)
 
             if colorize is None:
                 colorize = _colorama.should_colorize(sink)
@@ -741,7 +744,7 @@ class Logger:
             terminator = ""
             exception_prefix = "\n"
         elif callable(sink):
-            name = getattr(sink, "__name__", repr(sink))
+            name = getattr(sink, "__name__", None) or repr(sink)
 
             if colorize is None:
                 colorize = False
@@ -759,16 +762,59 @@ class Logger:
         if filter is None:
             filter_func = None
         elif filter == "":
-            filter_func = filter_none
+            filter_func = _filters.filter_none
         elif isinstance(filter, str):
             parent = filter + "."
             length = len(parent)
-            filter_func = functools.partial(filter_module, parent=parent, length=length)
+            filter_func = functools.partial(_filters.filter_by_name, parent=parent, length=length)
+        elif isinstance(filter, dict):
+            level_per_module = {}
+            for module, level_ in filter.items():
+                if module is not None and not isinstance(module, str):
+                    raise TypeError(
+                        "The filter dict contains an invalid module, "
+                        "it should be a string (or None), not: '%s'" % type(module).__name__
+                    )
+                if level_ is False:
+                    levelno_ = False
+                elif level_ is True:
+                    levelno_ = 0
+                elif isinstance(level_, str):
+                    try:
+                        levelno_ = self.level(level_).no
+                    except ValueError:
+                        raise ValueError(
+                            "The filter dict contains a module '%s' associated to a level name "
+                            "which does not exist: '%s'" % (module, level_)
+                        )
+                elif isinstance(level_, int):
+                    levelno_ = level_
+                else:
+                    raise TypeError(
+                        "The filter dict contains a module '%s' associated to an invalid level, "
+                        "it should be an integer, a string or a boolean, not: '%s'"
+                        % (module, type(level_).__name__)
+                    )
+                if levelno_ < 0:
+                    raise ValueError(
+                        "The filter dict contains a module '%s' associated to an invalid level, "
+                        "it should be a positive interger, not: '%d'" % (module, levelno_)
+                    )
+                level_per_module[module] = levelno_
+            filter_func = functools.partial(
+                _filters.filter_by_level, level_per_module=level_per_module
+            )
         elif callable(filter):
+            if filter == builtins.filter:
+                raise ValueError(
+                    "The built-in 'filter()' function cannot be used as a 'filter' parameter, "
+                    "this is most likely a mistake (please double-check the arguments passed "
+                    "to 'logger.add()')."
+                )
             filter_func = filter
         else:
             raise TypeError(
-                "Invalid filter, it should be a function or a string, not: '%s'"
+                "Invalid filter, it should be a function, a string or a dict, not: '%s'"
                 % type(filter).__name__
             )
 
@@ -789,8 +835,7 @@ class Logger:
 
         if isinstance(format, str):
             try:
-                markups = {"level": "", "lvl": ""}
-                AnsiMarkup(custom_markups=markups, strip=True).feed(format, strict=True)
+                AnsiMarkup.verify(format, ["level", "lvl"])
             except ValueError as e:
                 raise ValueError(
                     "Invalid format, color markups could not be parsed correctly"
@@ -798,6 +843,12 @@ class Logger:
             formatter = format + terminator + "{exception}"
             is_formatter_dynamic = False
         elif callable(format):
+            if format == builtins.format:
+                raise ValueError(
+                    "The built-in 'format()' function cannot be used as a 'format' parameter, "
+                    "this is most likely a mistake (please double-check the arguments passed "
+                    "to 'logger.add()')."
+                )
             formatter = format
             is_formatter_dynamic = True
         else:
@@ -912,7 +963,7 @@ class Logger:
         wrapped by this method. This is also very useful to decorate |Thread.run| methods while
         using threads to propagate errors to the main logger thread.
 
-        Note that the visibility of variables values (which uses the cool `better-exceptions`_
+        Note that the visibility of variables values (which uses the great |better_exceptions|_
         library from `@Qix-`_) depends on the ``diagnose`` option of each configured sink.
 
         The returned object can also be used as a context manager.
@@ -1182,7 +1233,7 @@ class Logger:
 
         Parameters
         ----------
-        patcher: |function|_
+        patcher: |callable object|_
             The function to which the record dict will be passed as the sole argument. This function
             is in charge of updating the record in-place, the function does not need to return any
             value, the modified record object will be re-used.
@@ -1304,7 +1355,7 @@ class Logger:
         if no < 0:
             raise ValueError("Invalid level no, it should be a positive integer, not: %d" % no)
 
-        ansi = parse_ansi(color)
+        ansi = AnsiMarkup.parse(color)
         level = Level(name, no, color, icon)
 
         with self._core.lock:
@@ -1383,7 +1434,7 @@ class Logger:
             A dict containing additional parameters bound to the core logger, useful to share
             common properties if you call |bind| in several of your files modules. If not ``None``,
             this will remove previously configured ``extra`` dict.
-        patcher : |function|_, optional
+        patcher : |callable object|_, optional
             A function that will be applied to the record dict of each logged messages across all
             modules using the logger. It should modify the dict in-place without returning anything.
             The function is executed prior to the one possibly added by the |patch| method. If not
@@ -1505,7 +1556,7 @@ class Logger:
         pattern : |str| or |re.Pattern|_
             The regex to use for logs parsing, it should contain named groups which will be included
             in the returned dict.
-        cast : |function|_ or |dict|, optional
+        cast : |callable object|_ or |dict|, optional
             A function that should convert in-place the regex groups parsed (a dict of string
             values) to more appropriate types. If a dict is passed, it should be a mapping between
             keys of parsed log dict and the function that should be used to convert the associated
diff --git a/loguru/_recattrs.py b/loguru/_recattrs.py
index 57cd5f1..ad38f25 100644
--- a/loguru/_recattrs.py
+++ b/loguru/_recattrs.py
@@ -1,3 +1,6 @@
+from collections import namedtuple
+
+
 class LevelRecattr:
     __slots__ = ("name", "no", "icon")
 
@@ -55,29 +58,10 @@ class ProcessRecattr:
         return self.id.__format__(spec)
 
 
-class ExceptionRecattr:
-    __slots__ = ("type", "value", "traceback")
-
-    def __init__(self, type_, value, traceback):
-        self.type = type_
-        self.value = value
-        self.traceback = traceback
-
+class ExceptionRecattr(namedtuple("ExceptionRecattr", ("type", "value", "traceback"))):
     def __repr__(self):
         return "(type=%r, value=%r, traceback=%r)" % (self.type, self.value, self.traceback)
 
-    def __iter__(self):
-        return iter((self.type, self.value, self.traceback))
-
-    def __getitem__(self, index):
-        return (self.type, self.value, self.traceback)[index]
-
     def __reduce__(self):
         exception = (self.type, self.value, None)  # tracebacks are not pickable
         return (ExceptionRecattr, exception)
-
-    def __eq__(self, other):
-        return (self.type, self.value, self.traceback) == other
-
-    def __len__(self):
-        return 3
diff --git a/loguru/_simple_sinks.py b/loguru/_simple_sinks.py
index a2c08a8..803640c 100644
--- a/loguru/_simple_sinks.py
+++ b/loguru/_simple_sinks.py
@@ -20,13 +20,12 @@ class StreamSink:
 class StandardSink:
     def __init__(self, handler):
         self._handler = handler
-        self._record_factory = logging.getLogRecordFactory()
 
     def write(self, message):
         record = message.record
         message = str(message)
         exc = record["exception"]
-        record = self._record_factory(
+        record = logging.getLogger().makeRecord(
             record["name"],
             record["level"].no,
             record["file"].path,
diff --git a/loguru/_string_parsers.py b/loguru/_string_parsers.py
index 459c359..453febd 100644
--- a/loguru/_string_parsers.py
+++ b/loguru/_string_parsers.py
@@ -94,35 +94,31 @@ def parse_duration(duration):
 
 
 def parse_frequency(frequency):
+    frequencies = {
+        "hourly": Frequencies.hourly,
+        "daily": Frequencies.daily,
+        "weekly": Frequencies.weekly,
+        "monthly": Frequencies.monthly,
+        "yearly": Frequencies.yearly,
+    }
     frequency = frequency.strip().lower()
-
-    if frequency == "hourly":
-
-        return Frequencies.hourly
-    elif frequency == "daily":
-
-        return Frequencies.daily
-    elif frequency == "weekly":
-
-        return Frequencies.weekly
-    elif frequency == "monthly":
-
-        return Frequencies.monthly
-    elif frequency == "yearly":
-
-        return Frequencies.yearly
-
-    return None
+    return frequencies.get(frequency, None)
 
 
 def parse_day(day):
+    days = {
+        "monday": 0,
+        "tuesday": 1,
+        "wednesday": 2,
+        "thursday": 3,
+        "friday": 4,
+        "saturday": 5,
+        "sunday": 6,
+    }
     day = day.strip().lower()
-    days = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
-    reg = re.compile(r"^w\d+$", flags=re.I)
-
     if day in days:
-        day = days.index(day)
-    elif reg.match(day):
+        return days[day]
+    elif day.startswith("w") and day[1:].isdigit():
         day = int(day[1:])
         if not 0 <= day < 7:
             raise ValueError("Invalid weekday value while parsing day (expected [0-6]): '%d'" % day)
diff --git a/tox.ini b/tox.ini
index 8188bdb..d77c2dd 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,6 +1,16 @@
 [tox]
 skip_missing_interpreters = True
-envlist = py{35,36,37,38},pypy
+envlist = py{35,36,37,38,py3}
+
+[travis]
+python =
+    3.5.2: py35
+    3.5: py35
+    3.6: py36
+    3.6: py36
+    3.7: py37
+    3.8: py38
+    pypy3: pypy3
 
 [testenv]
 setenv = PYTHONPATH = {toxinidir}


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 17feb52..78b0e6d 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -25,6 +25,7 @@ def reset_logger():
     def reset():
         loguru.logger.remove()
         loguru.logger.__init__(loguru._logger.Core(), None, 0, False, False, False, False, None, {})
+        loguru._logger.context.set({})
         logging.Logger.manager.loggerDict.clear()
         logging.root = logging.RootLogger("WARNING")
 
diff --git a/tests/test_add_option_enqueue.py b/tests/test_add_option_enqueue.py
index 1da7ffd..59a39be 100644
--- a/tests/test_add_option_enqueue.py
+++ b/tests/test_add_option_enqueue.py
@@ -1,7 +1,36 @@
 from loguru import logger
+import pytest
 import time
 
 
+class NotPicklable:
+    def __getstate__(self):
+        raise RuntimeError("You shall not serialize me!")
+
+    def __setstate__(self, state):
+        pass
+
+
+class NotUnpicklable:
+    def __getstate__(self):
+        return "..."
+
+    def __setstate__(self, state):
+        raise RuntimeError("You shall not de-serialize me!")
+
+
+class NotWritable:
+    def __init__(self, remove_record=False):
+        self._remove_record = remove_record
+
+    def write(self, message):
+        if "fail" in message.record["extra"]:
+            if self._remove_record:
+                del message.record
+            raise RuntimeError("You asked me to fail...")
+        print(message, end="")
+
+
 def test_enqueue():
     x = []
 
@@ -38,3 +67,122 @@ def test_enqueue_with_exception():
 
     assert lines[0] == "Error"
     assert lines[-1] == "ZeroDivisionError: division by zero"
+
+
+def test_caught_exception_without_record(capsys):
+    logger.add(NotWritable(True), enqueue=True, catch=True, format="{message}")
+
+    logger.info("It's fine")
+    logger.bind(fail=True).info("Bye bye...")
+    logger.info("It's fine again")
+    logger.remove()
+
+    out, err = capsys.readouterr()
+    lines = err.strip().splitlines()
+    assert out == "It's fine\nIt's fine again\n"
+    assert lines[0] == "--- Logging error in Loguru Handler #0 ---"
+    assert lines[1].startswith("Record was: None")
+    assert lines[-2] == "RuntimeError: You asked me to fail..."
+    assert lines[-1] == "--- End of logging error ---"
+
+
+def test_caught_exception_queue_put(writer, capsys):
+    logger.add(writer, enqueue=True, catch=True, format="{message}")
+
+    logger.info("It's fine")
+    logger.bind(broken=NotPicklable()).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 lines[1] == "Record was: None"
+    assert lines[-2] == "RuntimeError: 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}")
+
+    logger.info("It's fine")
+    logger.bind(broken=NotUnpicklable()).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 lines[1] == "Record was: None"
+    assert lines[-2] == "RuntimeError: You shall not de-serialize me!"
+    assert lines[-1] == "--- End of logging error ---"
+
+
+def test_caught_exception_sink_write(capsys):
+    logger.add(NotWritable(), enqueue=True, catch=True, format="{message}")
+
+    logger.info("It's fine")
+    logger.bind(fail=True).info("Bye bye...")
+    logger.info("It's fine again")
+    logger.remove()
+
+    out, err = capsys.readouterr()
+    lines = err.strip().splitlines()
+    assert out == "It's fine\nIt's fine again\n"
+    assert lines[0] == "--- Logging error in Loguru Handler #0 ---"
+    assert lines[1].startswith("Record was: {")
+    assert lines[1].endswith("}")
+    assert lines[-2] == "RuntimeError: You asked me to fail..."
+    assert lines[-1] == "--- End of logging error ---"
+
+
+def test_not_caught_exception_queue_put(writer, capsys):
+    logger.add(writer, enqueue=True, catch=False, format="{message}")
+
+    logger.info("It's fine")
+
+    with pytest.raises(RuntimeError, match=r"You shall not serialize me!"):
+        logger.bind(broken=NotPicklable()).info("Bye bye...")
+
+    logger.remove()
+
+    out, err = capsys.readouterr()
+    lines = err.strip().splitlines()
+    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}")
+
+    logger.info("It's fine")
+    logger.bind(broken=NotUnpicklable()).info("Bye bye...")
+    logger.info("It's not fine")
+    logger.remove()
+
+    out, err = capsys.readouterr()
+    lines = err.strip().splitlines()
+    assert writer.read() == "It's fine\n"
+    assert out == ""
+    assert lines[0].startswith("Exception")
+    assert lines[-1] == "RuntimeError: You shall not de-serialize me!"
+
+
+def test_not_caught_exception_sink_write(capsys):
+    logger.add(NotWritable(), enqueue=True, catch=False, format="{message}")
+
+    logger.info("It's fine")
+    logger.bind(fail=True).info("Bye bye...")
+    logger.info("It's not fine")
+    logger.remove()
+
+    out, err = capsys.readouterr()
+    lines = err.strip().splitlines()
+    assert out == "It's fine\n"
+    assert lines[0].startswith("Exception")
+    assert lines[-1] == "RuntimeError: You asked me to fail..."
diff --git a/tests/test_add_option_filter.py b/tests/test_add_option_filter.py
index ca7d412..3e19824 100644
--- a/tests/test_add_option_filter.py
+++ b/tests/test_add_option_filter.py
@@ -11,6 +11,15 @@ from loguru import logger
         "tests.test_add_option_filter",
         (lambda r: True),
         (lambda r: r["level"].name == "DEBUG"),
+        {},
+        {"": "DEBUG"},
+        {"tests": True},
+        {"tests.test_add_option_filter": 10},
+        {"": "WARNING", "tests": 0},
+        {"tests.test_add_option_filter": 5, "tests": False},
+        {"tests.test_add_option_filter.foobar": False},
+        {"tests.": False},
+        {"tests.test_add_option_filter.": False},
     ],
 )
 def test_filterd_in(filter, writer):
@@ -29,6 +38,12 @@ def test_filterd_in(filter, writer):
         ".",
         (lambda r: False),
         (lambda r: r["level"].no != 10),
+        {"": False},
+        {"": True, "tests": 50},
+        {"tests.test_add_option_filter": False},
+        {"tests": "WARNING"},
+        {"tests": 5, "tests.test_add_option_filter": 40},
+        {"": 100, "tests.test_add_option_filter.foobar": True},
     ],
 )
 def test_filtered_out(filter, writer):
@@ -37,21 +52,84 @@ def test_filtered_out(filter, writer):
     assert writer.read() == ""
 
 
-@pytest.mark.parametrize("filter", ["tests", "", lambda _: False])
-def test_filtered_out_f_globals_name_absent(writer, filter, f_globals_name_absent):
-    logger.add(writer, filter=filter, format="{message}", catch=False)
-    logger.info("It's not ok")
-    assert writer.read() == ""
-
-
-@pytest.mark.parametrize("filter", [None, lambda _: True])
+@pytest.mark.parametrize(
+    "filter",
+    [
+        None,
+        lambda _: True,
+        {},
+        {None: 0},
+        {"": False},
+        {"tests": False, None: True},
+        {"unrelated": 100},
+        {None: "INFO", "": "WARNING"},
+    ],
+)
 def test_filtered_in_f_globals_name_absent(writer, filter, f_globals_name_absent):
     logger.add(writer, filter=filter, format="{message}", catch=False)
     logger.info("It's ok")
     assert writer.read() == "It's ok\n"
 
 
+@pytest.mark.parametrize(
+    "filter",
+    [
+        "tests",
+        "",
+        lambda _: False,
+        {None: False},
+        {"": 0, None: "WARNING"},
+        {None: 100, "tests": True},
+    ],
+)
+def test_filtered_out_f_globals_name_absent(writer, filter, f_globals_name_absent):
+    logger.add(writer, filter=filter, format="{message}", catch=False)
+    logger.info("It's not ok")
+    assert writer.read() == ""
+
+
 @pytest.mark.parametrize("filter", [-1, 3.4, object()])
 def test_invalid_filter(writer, filter):
     with pytest.raises(TypeError):
         logger.add(writer, filter=filter)
+
+
+@pytest.mark.parametrize(
+    "filter",
+    [{1: "DEBUG"}, {object(): 10}, {"foo": None}, {"foo": 2.5}, {"a": "DEBUG", "b": object()}],
+)
+def test_invalid_filter_dict_types(writer, filter):
+    with pytest.raises(TypeError):
+        logger.add(writer, filter=filter)
+
+
+@pytest.mark.parametrize(
+    "filter", [{"foo": "UNKNOWN_LEVEL"}, {"tests": -1}, {"tests.test_add_option_filter": ""}]
+)
+def test_invalid_filter_dict_values(writer, filter):
+    with pytest.raises(ValueError):
+        logger.add(writer, filter=filter)
+
+
+def test_filter_dict_with_custom_level(writer):
+    logger.level("MY_LEVEL", 6, color="", icon="")
+    logger.add(writer, level=0, filter={"tests": "MY_LEVEL"}, format="{message}")
+    logger.log(3, "No")
+    logger.log(9, "Yes")
+    assert writer.read() == "Yes\n"
+
+
+def test_filter_dict_with_updated_level(writer):
+    logger.add(writer, level=0, filter={"tests.test_add_option_filter": "INFO"}, format="{message}")
+    logger.debug("No 1")
+    logger.info("Yes 1")
+    logger.level("INFO", no=5)
+    logger.level("DEBUG", no=50)
+    logger.debug("Yes 2")
+    logger.info("No 2")
+    assert writer.read() == "Yes 1\nYes 2\n"
+
+
+def test_invalid_filter_builtin(writer):
+    with pytest.raises(ValueError, match=r".* most likely a mistake"):
+        logger.add(writer, filter=filter)
diff --git a/tests/test_add_option_format.py b/tests/test_add_option_format.py
index 2f52712..ce26337 100644
--- a/tests/test_add_option_format.py
+++ b/tests/test_add_option_format.py
@@ -65,3 +65,8 @@ def test_invalid_format(writer, format):
 def test_invalid_markups(writer, format):
     with pytest.raises(ValueError, match=r"Invalid format"):
         logger.add(writer, format=format)
+
+
+def test_invalid_format_builtin(writer):
+    with pytest.raises(ValueError, match=r".* most likely a mistake"):
+        logger.add(writer, format=format)
diff --git a/tests/test_catch_exceptions.py b/tests/test_catch_exceptions.py
index 67c2a20..b0a42db 100644
--- a/tests/test_catch_exceptions.py
+++ b/tests/test_catch_exceptions.py
@@ -23,7 +23,9 @@ def normalize(exception):
         )
         exception = re.sub(r"(\r\n|\r|\n)", "\n", exception)
 
-    exception = re.sub(r'"[^"]*/somelib/__init__.py"', '"/usr/lib/python/somelib/__init__.py"', exception)
+    exception = re.sub(
+        r'"[^"]*/somelib/__init__.py"', '"/usr/lib/python/somelib/__init__.py"', exception
+    )
 
     exception = re.sub(r"\b0x[0-9a-fA-F]+\b", "0xDEADBEEF", exception)
 
@@ -465,6 +467,7 @@ def test_exception_is_tuple():
     t_3, v_3, tb_3 = exception[0], exception[1], exception[2]
     t_4, v_4, tb_4 = exception.type, exception.value, exception.traceback
 
+    assert isinstance(exception, tuple)
     assert len(exception) == 3
     assert exception == reference
     assert reference == exception
diff --git a/tests/test_configure.py b/tests/test_configure.py
index ad4ce02..4274a0d 100644
--- a/tests/test_configure.py
+++ b/tests/test_configure.py
@@ -162,6 +162,30 @@ def test_dont_reset_previous_levels(writer):
     assert writer.read() == "abc Test\n"
 
 
+def test_configure_handler_using_new_level(writer):
+    logger.configure(
+        levels=[{"name": "CONF_LVL", "no": 33, "icon": "", "color": ""}],
+        handlers=[
+            {"sink": writer, "level": "CONF_LVL", "format": "{level.name} {level.no} {message}"}
+        ],
+    )
+
+    logger.log("CONF_LVL", "Custom")
+    assert writer.read() == "CONF_LVL 33 Custom\n"
+
+
+def test_configure_filter_using_new_level(writer):
+    logger.configure(
+        levels=[{"name": "CONF_LVL_2", "no": 33, "icon": "", "color": ""}],
+        handlers=[
+            {"sink": writer, "level": 0, "filter": {"tests": "CONF_LVL_2"}, "format": "{message}"}
+        ],
+    )
+
+    logger.log("CONF_LVL_2", "Custom")
+    assert writer.read() == "Custom\n"
+
+
 def test_configure_before_bind(writer):
     logger.configure(extra={"a": "default_a", "b": "default_b"})
     logger.add(writer, format="{extra[a]} {extra[b]} {message}")
diff --git a/tests/test_filesink_compression.py b/tests/test_filesink_compression.py
index 7e3e896..ea94b43 100644
--- a/tests/test_filesink_compression.py
+++ b/tests/test_filesink_compression.py
@@ -86,7 +86,7 @@ def test_invalid_compression(compression):
         logger.add("test.log", compression=compression)
 
 
-@pytest.mark.parametrize("compression", ["rar", ".7z", "tar.zip"])
+@pytest.mark.parametrize("compression", ["rar", ".7z", "tar.zip", "__dict__"])
 def test_unknown_compression(compression):
     with pytest.raises(ValueError):
         logger.add("test.log", compression=compression)
diff --git a/tests/test_filesink_retention.py b/tests/test_filesink_retention.py
index 8693df0..fbf4ed7 100644
--- a/tests/test_filesink_retention.py
+++ b/tests/test_filesink_retention.py
@@ -183,7 +183,8 @@ def test_invalid_retention(retention):
 
 
 @pytest.mark.parametrize(
-    "retention", ["W5", "monday at 14:00", "sunday", "nope", "5 MB", "3 hours 2 dayz", "d", "H"]
+    "retention",
+    ["W5", "monday at 14:00", "sunday", "nope", "5 MB", "3 hours 2 dayz", "d", "H", "__dict__"],
 )
 def test_unkown_retention(retention):
     with pytest.raises(ValueError):
diff --git a/tests/test_filesink_rotation.py b/tests/test_filesink_rotation.py
index bbca4bc..b7defc5 100644
--- a/tests/test_filesink_rotation.py
+++ b/tests/test_filesink_rotation.py
@@ -483,6 +483,7 @@ def test_invalid_rotation(rotation):
         "foobar",
         "w5 at [not|a|time]",
         "[not|a|day] at 12:00",
+        "__dict__",
     ],
 )
 def test_unknown_rotation(rotation):
diff --git a/tests/test_pickling.py b/tests/test_pickling.py
index 3ca0c21..f8caf0e 100644
--- a/tests/test_pickling.py
+++ b/tests/test_pickling.py
@@ -108,6 +108,25 @@ def test_pickling_standard_handler():
     assert handler.written == "DEBUG - test_pickling_standard_handler - A message"
 
 
+def test_pickling_standard_handler_root_logger_not_picklable(monkeypatch, capsys):
+    def __reduce__():
+        raise TypeError("Not picklable")
+
+    monkeypatch.setattr(logging.getLogger(), "__reduce__", __reduce__, raising=False)
+
+    handler = StandardHandler(logging.NOTSET)
+    logger.add(handler, format="=> {message}", catch=False)
+
+    pickled = pickle.dumps(logger)
+    unpickled = pickle.loads(pickled)
+
+    logger.info("Ok")
+    out, err = capsys.readouterr()
+    assert out == ""
+    assert err == ""
+    assert handler.written == "=> Ok"
+
+
 def test_pickling_file_handler(tmpdir):
     file = tmpdir.join("test.log")
     logger.add(str(file), format="{level} - {function} - {message}", delay=True)
diff --git a/tests/test_recattr.py b/tests/test_recattr.py
index 3a98d4d..47e9a4b 100644
--- a/tests/test_recattr.py
+++ b/tests/test_recattr.py
@@ -38,7 +38,8 @@ def test_patch_record_process(writer):
 
 def test_patch_record_exception(writer):
     def patch(record):
-        record["exception"].traceback = None
+        type_, value, traceback = record["exception"]
+        record["exception"] = (type_, value, None)
 
     logger.add(writer, format="")
     try:
diff --git a/tests/test_repr.py b/tests/test_repr.py
index 7950d15..74b97d8 100644
--- a/tests/test_repr.py
+++ b/tests/test_repr.py
@@ -29,13 +29,13 @@ def test_file_object(tmpdir):
 def test_file_str(tmpdir):
     path = str(tmpdir.join("test.log"))
     logger.add(path)
-    assert repr(logger) == "<loguru.logger handlers=[(id=0, level=10, sink=%s)]>" % path
+    assert repr(logger) == "<loguru.logger handlers=[(id=0, level=10, sink='%s')]>" % path
 
 
 def test_file_pathlib(tmpdir):
     path = str(tmpdir.join("test.log"))
     logger.add(pathlib.Path(path))
-    assert repr(logger) == "<loguru.logger handlers=[(id=0, level=10, sink=%s)]>" % path
+    assert repr(logger) == "<loguru.logger handlers=[(id=0, level=10, sink='%s')]>" % path
 
 
 def test_stream_object():
@@ -65,6 +65,21 @@ def test_stream_object_without_name_attr():
     assert repr(logger) == "<loguru.logger handlers=[(id=0, level=10, sink=MyStream())]>"
 
 
+def test_stream_object_with_empty_name():
+    class MyStream2:
+        def __init__(self):
+            self.name = ""
+
+        def write(self, message):
+            pass
+
+        def __repr__(self):
+            return "MyStream2()"
+
+    logger.add(MyStream2())
+    assert repr(logger) == "<loguru.logger handlers=[(id=0, level=10, sink=MyStream2())]>"
+
+
 def test_function():
     def my_function(message):
         pass
@@ -91,6 +106,24 @@ def test_function_without_name():
     assert repr(logger) == "<loguru.logger handlers=[(id=0, level=10, sink=<Function>)]>"
 
 
+def test_function_with_empty_name():
+    class Function2:
+        def __call__(self, message):
+            pass
+
+        def __repr__(self):
+            return "<Function2>"
+
+        def __getattr__(self, name):
+            if name == "__name__":
+                return ""
+            return getattr(self.__class__, name)
+
+    function2 = Function2()
+    logger.add(function2)
+    assert repr(logger) == "<loguru.logger handlers=[(id=0, level=10, sink=<Function2>)]>"
+
+
 def test_standard_handler():
     handler = logging.StreamHandler(sys.__stderr__)
     logger.add(handler)
diff --git a/tests/test_standard_handler.py b/tests/test_standard_handler.py
index 8e27bbc..002c649 100644
--- a/tests/test_standard_handler.py
+++ b/tests/test_standard_handler.py
@@ -2,6 +2,7 @@ import pytest
 from loguru import logger
 import sys
 
+import logging
 from logging import StreamHandler, FileHandler, NullHandler, Handler
 from logging import Formatter
 
@@ -37,6 +38,17 @@ def test_null_handler(capsys):
     assert err == ""
 
 
+def test_extra_dict(capsys):
+    handler = StreamHandler(sys.stdout)
+    formatter = Formatter("[%(abc)s] %(message)s")
+    handler.setFormatter(formatter)
+    logger.add(handler, format=r"\<{extra[abc]}> {message}", catch=False)
+    logger.bind(abc=123).info("Extra!")
+    out, err = capsys.readouterr()
+    assert out == "[123] <123> Extra!\n"
+    assert err == ""
+
+
 def test_no_exception():
     result = None
 
@@ -94,11 +106,12 @@ def test_exception_formatting(tmpdir):
 
 @pytest.mark.parametrize("dynamic_format", [False, True])
 def test_standard_formatter(capsys, dynamic_format):
-    fmt = "{level.no} {message} [Not Chopped]"
-    if dynamic_format:
-        def format_(x): return fmt
-    else:
-        format_ = fmt
+    def format_(x):
+        return "{level.no} {message} [Not Chopped]"
+
+    if not dynamic_format:
+        format_ = format_(None)
+
     handler = StreamHandler(sys.stdout)
     formatter = Formatter("%(message)s %(levelname)s")
     handler.setFormatter(formatter)
@@ -111,11 +124,12 @@ def test_standard_formatter(capsys, dynamic_format):
 
 @pytest.mark.parametrize("dynamic_format", [False, True])
 def test_standard_formatter_with_new_line(capsys, dynamic_format):
-    fmt = "{level.no} {message}\n"
-    if dynamic_format:
-        def format_(x): return fmt
-    else:
-        format_ = fmt
+    def format_(x):
+        return "{level.no} {message}\n"
+
+    if not dynamic_format:
+        format_ = format_(None)
+
     handler = StreamHandler(sys.stdout)
     formatter = Formatter("%(message)s %(levelname)s")
     handler.setFormatter(formatter)
@@ -128,11 +142,12 @@ def test_standard_formatter_with_new_line(capsys, dynamic_format):
 
 @pytest.mark.parametrize("dynamic_format", [False, True])
 def test_raw_standard_formatter(capsys, dynamic_format):
-    fmt = "{level.no} {message} [Not Chopped]"
-    if dynamic_format:
-        def format_(x): return fmt
-    else:
-        format_ = fmt
+    def format_(x):
+        return "{level.no} {message} [Not Chopped]"
+
+    if not dynamic_format:
+        format_ = format_(None)
+
     handler = StreamHandler(sys.stdout)
     formatter = Formatter("%(message)s %(levelname)s")
     handler.setFormatter(formatter)
@@ -145,11 +160,12 @@ def test_raw_standard_formatter(capsys, dynamic_format):
 
 @pytest.mark.parametrize("dynamic_format", [False, True])
 def test_raw_standard_formatter_with_new_line(capsys, dynamic_format):
-    fmt = "{level.no} {message}\n"
-    if dynamic_format:
-        def format_(x): return fmt
-    else:
-        format_ = fmt
+    def format_(x):
+        return "{level.no} {message}\n"
+
+    if not dynamic_format:
+        format_ = format_(None)
+
     handler = StreamHandler(sys.stdout)
     formatter = Formatter("%(message)s %(levelname)s")
     handler.setFormatter(formatter)