Repository: Delgan/loguru
Pull request #157 (2019-10-20 14:33:44)

Fix multiprocessing on Windows and add possibility of using independent loggers
This required a whole refactoring of the internal structure of `logger`. Added handler were previously stored as class attributes. Making them instance attributes allow them to be inherited while creating child processes on Windows. 

The refactoring also removed all closure function causing the `logger` to not be picklable. This means the `logger` can now be deepcopied, resulting in seperate loggers to which handlers can be added independently if needed.


Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index fb8f14a..7b54d27 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,7 +1,10 @@
 Unreleased
 ==========
 
+- 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>`_).
+- Add support for ``copy.deepcopy()`` of the ``logger`` allowing multiple independent loggers with separate set of handlers (`#72 <https://github.com/Delgan/loguru/issues/72>`_).
 - Prevent hypothetical ``ImportError`` if a Python installation is missing the built-in ``distutils`` module (`#118 <https://github.com/Delgan/loguru/issues/118>`_).
+- Fix an error using a ``filter`` function "by name" while receiving a log with ``record["name"]`` equals to ``None``.
 
 
 0.3.2 (2019-07-21)
diff --git a/docs/resources/recipes.rst b/docs/resources/recipes.rst
index 1d4584a..7ffb2d7 100644
--- a/docs/resources/recipes.rst
+++ b/docs/resources/recipes.rst
@@ -9,6 +9,12 @@ Code snippets and recipes for ``loguru``
 .. |sys.stderr| replace:: :data:`sys.stderr`
 .. |warnings| replace:: :mod:`warnings`
 .. |warnings.showwarning| replace:: :func:`warnings.showwarning`
+.. |copy.deepcopy| replace:: :func:`copy.deepcopy`
+.. |os.fork| replace:: :func:`os.fork`
+.. |multiprocessing| replace:: :mod:`multiprocessing`
+.. |Pool| replace:: :class:`~multiprocessing.pool.Pool`
+.. |Pool.map| replace:: :meth:`~multiprocessing.pool.Pool.map`
+.. |Pool.apply| replace:: :meth:`~multiprocessing.pool.Pool.apply`
 
 .. |add| replace:: :meth:`~loguru._logger.Logger.add()`
 .. |remove| replace:: :meth:`~loguru._logger.Logger.remove()`
@@ -342,7 +348,7 @@ Trying to use the Loguru's ``logger`` during an iteration wrapped by the ``tqdm`
         time.sleep(0.1)
 
 
-You may encounter problems with colorization of your logs after importing ``tqdm`` using Spyder on Windows. This issue is discussed in `GH#132`_. You can easily circunvent the problem by calling ``colorama.deinit()`` right after your import.
+You may encounter problems with colorization of your logs after importing ``tqdm`` using Spyder on Windows. This issue is discussed in `GH#132`_. You can easily circumvent the problem by calling ``colorama.deinit()`` right after your import.
 
 
 Using Loguru's ``logger`` within a Cython module
@@ -360,3 +366,150 @@ This error happens when Loguru tries to access a stack frame which has been supp
     logger = logger.opt(depth=-1)
 
 Note that logged messages should be displayed correctly, but function name and other information will be incorrect. This issue is discussed in `GH#88`_.
+
+
+Creating independent loggers with separate set of handlers
+----------------------------------------------------------
+
+Loguru is fundamentally designed to be usable with exactly one global ``logger`` object dispatching logging messages to the configured handlers. In some circumstances, it may be useful to have specific messages logged to specific handlers.
+
+For example, supposing you want to split your logs in two files based on an arbitrary identifier, you can achieve that by combining |bind| and ``filter``::
+
+    from loguru import logger
+
+    def task_A():
+        logger_a = logger.bind(task="A")
+        logger_a.info("Starting task A")
+        do_something()
+        logger_a.success("End of task A")
+
+    def task_B():
+        logger_b = logger.bind(task="B")
+        logger_b.info("Starting task B")
+        do_something_else()
+        logger_b.success("End of task B")
+
+    logger.add("file_A.log", filter=lambda record: record["extra"]["task"] == "A")
+    logger.add("file_B.log", filter=lambda record: record["extra"]["task"] == "B")
+
+    task_A()
+    task_B()
+
+That way, ``"file_A.log"`` and ``"file_B.log"`` will only contains logs from respectively the ``task_A()`` and ``task_B()`` function.
+
+Now, supposing that you have a lot of these tasks. It may be a bit cumbersome to configure every handlers like this. Most importantly, it may unnecessarily slow down your application as each log will need to be checked by the ``filter`` function of each handler. In such case, it is recommended to rely on the |copy.deepcopy| built-in method that will create an independent ``logger`` object. If you add a handler to a deep copied ``logger``, it will not be shared with others functions using the original ``logger``::
+
+    import copy
+    from loguru import logger
+
+    def task(task_id, logger):
+        logger.info("Starting task {}", task_id)
+        do_something(task_id)
+        logger.success("End of task {}", task_id)
+
+    logger.remove()
+
+    for task_id in ["A", "B", "C", "D", "E"]:
+        logger_ = copy.deepcopy(logger)
+        logger_.add("file_%s.log" % task_id)
+        task(task_id, logger_)
+
+Note that you may encounter errors if you try to copy a ``logger`` to which non-picklable handlers have been added. For this reason, it is generally advised to remove all handlers before calling ``copy.deepcopy(logger)``.
+
+
+Compatibility with ``multiprocessing`` using ``enqueue`` argument
+-----------------------------------------------------------------
+
+On Linux, thanks to |os.fork| there is no pitfall while using the ``logger`` inside another process started by the |multiprocessing| module. The child process will automatically inherit added handlers, the ``enqueue=True`` parameter is optional but is recommended as it would avoid concurrent access of your sink::
+
+    # Linux implementation
+    import multiprocessing
+    from loguru import logger
+
+    def my_process():
+        logger.info("Executing function in child process")
+
+    if __name__ == "__main__":
+        logger.add("file.log", enqueue=True)
+
+        process = multiprocessing.Process(target=my_process)
+        process.start()
+        process.join()
+
+        logger.info("Done")
+
+Things get a little more complicated on Windows. Indeed, this operating system does not support forking, so Python has to use an alternative method to create sub-processes called "spawning". This procedure requires the whole file where the child process is created to be reloaded from scratch. This does not interoperate very well with Loguru, causing handlers to be added twice without any synchronization or, on the contrary, not being added at all (depending on ``add()`` and ``remove()`` being called inside or outside the ``__main__`` branch). For this reason, the ``logger`` object need to be explicitly passed as an initializer argument of your child process::
+
+    # Windows implementation
+    import multiprocessing
+    from loguru import logger
+
+    def my_process(logger_):
+        logger_.info("Executing function in child process")
+
+    if __name__ == "__main__":
+        logger.remove()  # Default "sys.stderr" sink is not picklable
+        logger.add("file.log", enqueue=True)
+
+        process = multiprocessing.Process(target=my_process, args=(logger, ))
+        process.start()
+        process.join()
+
+        logger.info("Done")
+
+Windows requires the added sinks to be picklable or otherwise will raise an error while creating the child process. Many stream objects like standard output and file descriptors are not picklable. In such case, the ``enqueue=True`` argument is required as it will allow the child process to only inherit the ``Queue`` where logs are sent.
+
+The |multiprocessing| library is also commonly used to start a pool of workers using for example |Pool.map| or |Pool.apply|. Again, it will work flawlessly on Linux, but it will require some tinkering on Windows. You will probably not be able to pass the ``logger`` as an argument for your worker functions because it needs to be picklable, but altough handlers added using ``enqueue=True`` are "inheritable", they are not "picklable". Instead, you will need to make use of the ``initializer`` and ``initargs`` parameters while creating the |Pool| object in a way allowing your workers to access the shared ``logger``. You can either assign it to a class attribute or override the global logger of your child processes:
+
+.. code::
+
+    # workers_a.py
+    class Worker:
+
+        _logger = None
+
+        @staticmethod
+        def set_logger(logger_):
+            Worker._logger = logger_
+
+        def work(self, x):
+            self._logger.info("Square rooting {}", x)
+            return x**0.5
+
+
+.. code::
+
+    # workers_b.py
+    from loguru import logger
+
+    def set_logger(logger_):
+        global logger
+        logger = logger_
+
+    def work(x):
+        logger.info("Square rooting {}", x)
+        return x**0.5
+
+
+.. code::
+
+    # main.py
+    from multiprocessing import Pool
+    from loguru import logger
+    import workers_a
+    import workers_b
+
+    if __name__ == "__main__":
+        logger.remove()
+        logger.add("file.log", enqueue=True)
+
+        worker = workers_a.Worker()
+        with Pool(4, initializer=worker.set_logger, initargs=(logger, )) as pool:
+            resuts = pool.map(worker.work, [1, 10, 100])
+
+        with Pool(4, initializer=workers_b.set_logger, initargs=(logger, )) as pool:
+            results = pool.map(workers_b.work, [1, 10, 100])
+
+        logger.info("Done")
+
+Independently of the operating system, note that the process in which a handler is added with ``enqueue=True`` is in charge of the ``Queue`` internally used. This means that you should avoid to ``.remove()`` such handler from the parent process is any child is likely to continue using it.
diff --git a/loguru/__init__.py b/loguru/__init__.py
index 3acf856..f4a453b 100644
--- a/loguru/__init__.py
+++ b/loguru/__init__.py
@@ -7,13 +7,13 @@ import atexit as _atexit
 import sys as _sys
 
 from . import _defaults
-from ._logger import Logger as _Logger
+from ._logger import Logger as _Logger, Core as _Core
 
 __version__ = "0.3.2"
 
 __all__ = ["logger"]
 
-logger = _Logger(None, 0, False, False, False, False, None, {})
+logger = _Logger(_Core(), None, 0, False, False, False, False, None, {})
 
 if _defaults.LOGURU_AUTOINIT and _sys.stderr:
     logger.add(_sys.stderr)
diff --git a/loguru/_ctime_functions.py b/loguru/_ctime_functions.py
new file mode 100644
index 0000000..bcdbc49
--- /dev/null
+++ b/loguru/_ctime_functions.py
@@ -0,0 +1,35 @@
+import os
+
+if os.name == "nt":
+    import win32_setctime
+
+    def get_ctime(filepath):
+        return os.stat(filepath).st_ctime
+
+    def set_ctime(filepath, timestamp):
+        if win32_setctime.SUPPORTED:
+            win32_setctime.setctime(filepath, timestamp)
+
+
+elif hasattr(os.stat_result, "st_birthtime"):
+
+    def get_ctime(filepath):
+        return os.stat(filepath).st_birthtime
+
+    def set_ctime(filepath, timestamp):
+        pass
+
+
+else:
+
+    def get_ctime(filepath):
+        try:
+            return float(os.getxattr(filepath, b"user.loguru_crtime"))
+        except OSError:
+            return os.stat(filepath).st_mtime
+
+    def set_ctime(filepath, timestamp):
+        try:
+            os.setxattr(filepath, b"user.loguru_crtime", str(timestamp).encode("ascii"))
+        except OSError:
+            pass
diff --git a/loguru/_file_sink.py b/loguru/_file_sink.py
index 2d93d28..c4ce6c7 100644
--- a/loguru/_file_sink.py
+++ b/loguru/_file_sink.py
@@ -1,3 +1,4 @@
+import abc
 import datetime as datetime_
 import decimal
 import glob
@@ -6,8 +7,10 @@ import numbers
 import os
 import shutil
 import string
+from functools import partial
 
 from . import _string_parsers as string_parsers
+from ._ctime_functions import get_ctime, set_ctime
 from ._datetime import aware_now, datetime
 
 
@@ -21,6 +24,106 @@ class FileDateFormatter:
         return self.datetime.__format__(spec)
 
 
+class Compression:
+    @staticmethod
+    def add_compress(path_in, path_out, opener, **kwargs):
+        with opener(path_out, **kwargs) as f_comp:
+            f_comp.add(path_in, os.path.basename(path_in))
+
+    @staticmethod
+    def write_compress(path_in, path_out, opener, **kwargs):
+        with opener(path_out, **kwargs) as f_comp:
+            f_comp.write(path_in, os.path.basename(path_in))
+
+    @staticmethod
+    def copy_compress(path_in, path_out, opener, **kwargs):
+        with open(path_in, "rb") as f_in:
+            with opener(path_out, **kwargs) as f_out:
+                shutil.copyfileobj(f_in, f_out)
+
+    @staticmethod
+    def compression(path_in, ext, compress_function):
+        path_out = "{}.{}".format(path_in, ext)
+        if os.path.isfile(path_out):
+            creation_time = get_ctime(path_out)
+            creation_datetime = datetime.fromtimestamp(creation_time)
+            date = FileDateFormatter(creation_datetime)
+            root, ext_before = os.path.splitext(path_in)
+            renamed_path = "{}.{}{}.{}".format(root, date, ext_before, ext)
+            os.rename(path_out, renamed_path)
+        compress_function(path_in, path_out)
+        os.remove(path_in)
+
+
+class Retention:
+    @staticmethod
+    def retention_count(logs, number):
+        def key_log(log):
+            return (-os.stat(log).st_mtime, log)
+
+        for log in sorted(logs, key=key_log)[number:]:
+            os.remove(log)
+
+    @staticmethod
+    def retention_age(logs, seconds):
+        t = datetime.now().timestamp()
+        for log in logs:
+            if os.stat(log).st_mtime <= t - seconds:
+                os.remove(log)
+
+
+class Rotation:
+    @staticmethod
+    def forward_day(t):
+        return t + datetime_.timedelta(days=1)
+
+    @staticmethod
+    def forward_weekday(t, weekday):
+        while True:
+            t += datetime_.timedelta(days=1)
+            if t.weekday() == weekday:
+                return t
+
+    @staticmethod
+    def forward_interval(t, interval):
+        return t + interval
+
+    @staticmethod
+    def rotation_size(message, file, size_limit):
+        file.seek(0, 2)
+        return file.tell() + len(message) > size_limit
+
+    class RotationTime:
+        def __init__(self, step_forward, time_init=None):
+            self._step_forward = step_forward
+            self._time_init = time_init
+            self._limit = None
+
+        def __call__(self, message, file):
+            if self._limit is None:
+                filepath = os.path.realpath(file.name)
+                creation_time = get_ctime(filepath)
+                set_ctime(filepath, creation_time)
+                start_time = limit = datetime.fromtimestamp(creation_time)
+                if self._time_init is not None:
+                    limit = limit.replace(
+                        hour=self._time_init.hour,
+                        minute=self._time_init.minute,
+                        second=self._time_init.second,
+                        microsecond=self._time_init.microsecond,
+                    )
+                if limit <= start_time:
+                    limit = self._step_forward(limit)
+                self._limit = limit
+
+            record_time = message.record["time"].replace(tzinfo=None)
+            if record_time >= self._limit:
+                while self._limit <= record_time:
+                    self._limit = self._step_forward(self._limit)
+                return True
+            return False
+
+
 class FileSink:
     def __init__(
         self,
@@ -36,37 +139,29 @@ class FileSink:
         **kwargs
     ):
         self.encoding = locale.getpreferredencoding(False) if encoding is None else encoding
-        self.name = str(path)
 
         self._kwargs = {**kwargs, "mode": mode, "buffering": buffering, "encoding": self.encoding}
         self._path = str(path)
 
-        self._rotation_function, self._init_rotation = self._make_rotation_function(rotation)
+        self._glob_pattern = self._make_glob_pattern(self._path)
+        self._rotation_function = self._make_rotation_function(rotation)
         self._retention_function = self._make_retention_function(retention)
         self._compression_function = self._make_compression_function(compression)
-        self._glob_pattern = self._make_glob_pattern(self._path)
-        self._get_creation_time = self._make_get_creation_time_function()
-        self._set_creation_time = self._make_set_creation_time_function()
 
         self._file = None
         self._file_path = None
 
         if not delay:
             self._initialize_file(rename_existing=False)
-            if self._init_rotation is not None:
-                self._init_rotation(self._file_path)
 
     def write(self, message):
         if self._file is None:
             self._initialize_file(rename_existing=False)
-            if self._init_rotation is not None:
-                self._init_rotation(self._file_path)
 
         if self._rotation_function is not None and self._rotation_function(message, self._file):
             self._terminate(teardown=True)
             self._initialize_file(rename_existing=True)
-            if self._set_creation_time is not None:
-                self._set_creation_time(self._file_path, datetime.now().timestamp())
+            set_ctime(self._file_path, datetime.now().timestamp())
 
         self._file.write(message)
 
@@ -77,7 +172,7 @@ class FileSink:
         os.makedirs(new_dir, exist_ok=True)
 
         if rename_existing and os.path.isfile(new_path):
-            creation_time = self._get_creation_time(new_path)
+            creation_time = get_ctime(new_path)
             creation_datetime = datetime.fromtimestamp(creation_time)
             date = FileDateFormatter(creation_datetime)
             root, ext = os.path.splitext(new_path)
@@ -99,166 +194,56 @@ class FileSink:
         return pattern
 
     @staticmethod
-    def _make_get_creation_time_function():
-        def get_creation_time_windows(filepath):
-            return os.stat(filepath).st_ctime
-
-        def get_creation_time_darwin(filepath):
-            return os.stat(filepath).st_birthtime
-
-        def get_creation_time_linux(filepath):
-            try:
-                return float(os.getxattr(filepath, b"user.loguru_crtime"))
-            except OSError:
-                return os.stat(filepath).st_mtime
-
-        if os.name == "nt":
-            return get_creation_time_windows
-        elif hasattr(os.stat_result, "st_birthtime"):
-            return get_creation_time_darwin
-        else:
-            return get_creation_time_linux
-
-    @staticmethod
-    def _make_set_creation_time_function():
-        def set_creation_time_darwin(filepath, timestamp):
-            try:
-                os.setxattr(filepath, b"user.loguru_crtime", str(timestamp).encode("ascii"))
-            except OSError:
-                pass
-
-        if os.name == "nt":
-            import win32_setctime
-
-            if win32_setctime.SUPPORTED:
-                return win32_setctime.setctime
-            else:
-                return None
-        elif hasattr(os.stat_result, "st_birthtime"):
-            return None
-        else:
-            return set_creation_time_darwin
-
-    def _make_rotation_function(self, rotation):
-        def make_from_size(size_limit):
-            def rotation_function(message, file):
-                file.seek(0, 2)
-                return file.tell() + len(message) >= size_limit
-
-            return rotation_function, None
-
-        def make_from_time(step_forward, time_init=None):
-
-            time_limit = None
-
-            def init_time_rotation(filepath):
-                nonlocal time_limit
-                creation_time = self._get_creation_time(filepath)
-                if self._set_creation_time is not None:
-                    self._set_creation_time(filepath, creation_time)
-                start_time = time_limit = datetime.fromtimestamp(creation_time)
-                if time_init is not None:
-                    time_limit = time_limit.replace(
-                        hour=time_init.hour,
-                        minute=time_init.minute,
-                        second=time_init.second,
-                        microsecond=time_init.microsecond,
-                    )
-                if time_limit <= start_time:
-                    time_limit = step_forward(time_limit)
-
-            def rotation_function(message, file):
-                nonlocal time_limit
-                record_time = message.record["time"].replace(tzinfo=None)
-                if record_time >= time_limit:
-                    while time_limit <= record_time:
-                        time_limit = step_forward(time_limit)
-                    return True
-                return False
-
-            return rotation_function, init_time_rotation
-
+    def _make_rotation_function(rotation):
         if rotation is None:
-            return None, None
+            return None
         elif isinstance(rotation, str):
             size = string_parsers.parse_size(rotation)
             if size is not None:
-                return self._make_rotation_function(size)
+                return FileSink._make_rotation_function(size)
             interval = string_parsers.parse_duration(rotation)
             if interval is not None:
-                return self._make_rotation_function(interval)
+                return FileSink._make_rotation_function(interval)
             frequency = string_parsers.parse_frequency(rotation)
             if frequency is not None:
-                return make_from_time(frequency)
+                return Rotation.RotationTime(frequency)
             daytime = string_parsers.parse_daytime(rotation)
             if daytime is not None:
                 day, time = daytime
                 if day is None:
-                    return self._make_rotation_function(time)
+                    return FileSink._make_rotation_function(time)
                 if time is None:
                     time = datetime_.time(0, 0, 0)
-
-                def next_day(t):
-                    while True:
-                        t += datetime_.timedelta(days=1)
-                        if t.weekday() == day:
-                            return t
-
-                return make_from_time(next_day, time_init=time)
+                step_forward = partial(Rotation.forward_weekday, weekday=day)
+                return Rotation.RotationTime(step_forward, time)
             raise ValueError("Cannot parse rotation from: '%s'" % rotation)
         elif isinstance(rotation, (numbers.Real, decimal.Decimal)):
-            return make_from_size(rotation)
+            return partial(Rotation.rotation_size, size_limit=rotation)
         elif isinstance(rotation, datetime_.time):
-
-            def next_day(t):
-                return t + datetime_.timedelta(days=1)
-
-            return make_from_time(next_day, time_init=rotation)
+            return Rotation.RotationTime(Rotation.forward_day, rotation)
         elif isinstance(rotation, datetime_.timedelta):
-
-            def add_interval(t):
-                return t + rotation
-
-            return make_from_time(add_interval)
+            step_forward = partial(Rotation.forward_interval, interval=rotation)
+            return Rotation.RotationTime(step_forward)
         elif callable(rotation):
-            return rotation, None
+            return rotation
         else:
             raise ValueError(
                 "Cannot infer rotation for objects of type: '%s'" % type(rotation).__name__
             )
 
-    def _make_retention_function(self, retention):
-        def make_from_filter(filter_logs):
-            def retention_function(logs):
-                for log in filter_logs(logs):
-                    os.remove(log)
-
-            return retention_function
-
+    @staticmethod
+    def _make_retention_function(retention):
         if retention is None:
             return None
         elif isinstance(retention, str):
             interval = string_parsers.parse_duration(retention)
             if interval is None:
                 raise ValueError("Cannot parse retention from: '%s'" % retention)
-            return self._make_retention_function(interval)
+            return FileSink._make_retention_function(interval)
         elif isinstance(retention, int):
-
-            def key_log(log):
-                return (-os.stat(log).st_mtime, log)
-
-            def filter_logs(logs):
-                return sorted(logs, key=key_log)[retention:]
-
-            return make_from_filter(filter_logs)
+            return partial(Retention.retention_count, number=retention)
         elif isinstance(retention, datetime_.timedelta):
-            seconds = retention.total_seconds()
-
-            def filter_logs(logs):
-                t = datetime.now().timestamp()
-                return [log for log in logs if os.stat(log).st_mtime <= t - seconds]
-
-            return make_from_filter(filter_logs)
+            return partial(Retention.retention_age, seconds=retention.total_seconds())
         elif callable(retention):
             return retention
         else:
@@ -266,33 +251,8 @@ class FileSink:
                 "Cannot infer retention for objects of type: '%s'" % type(retention).__name__
             )
 
-    def _make_compression_function(self, compression):
-        def make_compress_generic(opener, **kwargs):
-            def compress(path_in, path_out):
-                with open(path_in, "rb") as f_in:
-                    with opener(path_out, "wb", **kwargs) as f_out:
-                        shutil.copyfileobj(f_in, f_out)
-
-            return compress
-
-        def make_compress_archive(mode):
-            import tarfile
-
-            def compress(path_in, path_out):
-                with tarfile.open(path_out, "w:" + mode) as f_comp:
-                    f_comp.add(path_in, os.path.basename(path_in))
-
-            return compress
-
-        def make_compress_zipped():
-            import zipfile
-
-            def compress(path_in, path_out):
-                with zipfile.ZipFile(path_out, "w", compression=zipfile.ZIP_DEFLATED) as f_comp:
-                    f_comp.write(path_in, os.path.basename(path_in))
-
-            return compress
-
+    @staticmethod
+    def _make_compression_function(compression):
         if compression is None:
             return None
         elif isinstance(compression, str):
@@ -301,51 +261,55 @@ class FileSink:
             if ext == "gz":
                 import gzip
 
-                compress = make_compress_generic(gzip.open)
+                compress = partial(Compression.copy_compress, opener=gzip.open, mode="wb")
             elif ext == "bz2":
                 import bz2
 
-                compress = make_compress_generic(bz2.open)
+                compress = partial(Compression.copy_compress, opener=bz2.open, mode="wb")
+
             elif ext == "xz":
                 import lzma
 
-                compress = make_compress_generic(lzma.open, format=lzma.FORMAT_XZ)
+                compress = partial(
+                    Compression.copy_compress, opener=lzma.open, mode="wb", format=lzma.FORMAT_XZ
+                )
+
             elif ext == "lzma":
                 import lzma
 
-                compress = make_compress_generic(lzma.open, format=lzma.FORMAT_ALONE)
+                compress = partial(
+                    Compression.copy_compress, opener=lzma.open, mode="wb", format=lzma.FORMAT_ALONE
+                )
             elif ext == "tar":
-                compress = make_compress_archive("")
+                import tarfile
+
+                compress = partial(Compression.add_compress, opener=tarfile.open, mode="w:")
             elif ext == "tar.gz":
-                import gzip
+                import tarfile, gzip
 
-                compress = make_compress_archive("gz")
+                compress = partial(Compression.add_compress, opener=tarfile.open, mode="w:gz")
             elif ext == "tar.bz2":
-                import bz2
+                import tarfile, bz2
+
+                compress = partial(Compression.add_compress, opener=tarfile.open, mode="w:bz2")
 
-                compress = make_compress_archive("bz2")
             elif ext == "tar.xz":
-                import lzma
+                import tarfile, lzma
 
-                compress = make_compress_archive("xz")
+                compress = partial(Compression.add_compress, opener=tarfile.open, mode="w:xz")
             elif ext == "zip":
-                compress = make_compress_zipped()
+                import zipfile
+
+                compress = partial(
+                    Compression.write_compress,
+                    opener=zipfile.ZipFile,
+                    mode="w",
+                    compression=zipfile.ZIP_DEFLATED,
+                )
             else:
                 raise ValueError("Invalid compression format: '%s'" % ext)
 
-            def compression_function(path_in):
-                path_out = "{}.{}".format(path_in, ext)
-                if os.path.isfile(path_out):
-                    creation_time = self._get_creation_time(path_out)
-                    creation_datetime = datetime.fromtimestamp(creation_time)
-                    date = FileDateFormatter(creation_datetime)
-                    root, ext_before = os.path.splitext(path_in)
-                    renamed_path = "{}.{}{}.{}".format(root, date, ext_before, ext)
-                    os.rename(path_out, renamed_path)
-                compress(path_in, path_out)
-                os.remove(path_in)
-
-            return compression_function
+            return partial(Compression.compression, ext=ext, compress_function=compress)
         elif callable(compression):
             return compression
         else:
diff --git a/loguru/_handler.py b/loguru/_handler.py
index 99c4a12..c9bfd36 100644
--- a/loguru/_handler.py
+++ b/loguru/_handler.py
@@ -17,9 +17,8 @@ class Handler:
     def __init__(
         self,
         *,
+        sink,
         name,
-        writer,
-        stopper,
         levelno,
         formatter,
         is_formatter_dynamic,
@@ -33,8 +32,7 @@ class Handler:
         levels_ansi_codes
     ):
         self._name = name
-        self._writer = writer
-        self._stopper = stopper
+        self._sink = sink
         self._levelno = levelno
         self._formatter = formatter
         self._is_formatter_dynamic = is_formatter_dynamic
@@ -45,7 +43,7 @@ class Handler:
         self._enqueue = enqueue
         self._exception_formatter = exception_formatter
         self._id = id_
-        self._levels_ansi_codes = levels_ansi_codes
+        self._levels_ansi_codes = levels_ansi_codes  # Warning, reference shared among handlers
 
         self._static_format = None
         self._decolorized_format = None
@@ -55,6 +53,7 @@ class Handler:
         self._queue = None
         self._thread = None
         self._stopped = False
+        self._owner_process = None
 
         if not self._is_formatter_dynamic:
             self._static_format = self._formatter
@@ -64,6 +63,7 @@ class Handler:
                 self.update_format(level_name)
 
         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.start()
@@ -141,7 +141,7 @@ class Handler:
                 if self._enqueue:
                     self._queue.put(str_record)
                 else:
-                    self._writer(str_record)
+                    self._sink.write(str_record)
 
         except Exception:
             if self._catch:
@@ -152,10 +152,10 @@ class Handler:
     def stop(self):
         with self._lock:
             self._stopped = True
-            if self._enqueue:
+            if self._enqueue and self._owner_process == multiprocessing.current_process():
                 self._queue.put(None)
                 self._thread.join()
-            self._stopper()
+            self._sink.stop()
 
     def update_format(self, level_id):
         if not self._colorize or self._is_formatter_dynamic:
@@ -250,7 +250,7 @@ class Handler:
                 message = queue.get()
                 if message is None:
                     break
-                self._writer(message)
+                self._sink.write(message)
             except Exception:
                 if self._catch:
                     if message and hasattr(message, "record"):
@@ -278,3 +278,16 @@ class Handler:
             pass
         finally:
             del ex_type, ex, tb
+
+    def __getstate__(self):
+        state = self.__dict__.copy()
+        state["_lock"] = None
+        if self._enqueue:
+            state["_sink"] = None
+            state["_thread"] = None
+            state["_owner_process"] = None
+        return state
+
+    def __setstate__(self, state):
+        self.__dict__.update(state)
+        self._lock = threading.Lock()
diff --git a/loguru/_logger.py b/loguru/_logger.py
index 7cbfb1b..732bd97 100644
--- a/loguru/_logger.py
+++ b/loguru/_logger.py
@@ -2,6 +2,7 @@ import functools
 import inspect
 import itertools
 import logging
+import pickle
 import re
 import sys
 import threading
@@ -21,6 +22,7 @@ from ._file_sink import FileSink
 from ._get_frame import get_frame
 from ._handler import Handler
 from ._recattrs import ExceptionRecattr, FileRecattr, LevelRecattr, ProcessRecattr, ThreadRecattr
+from ._simple_sinks import StreamSink, StandardSink, CallableSink
 
 try:
     from os import PathLike
@@ -32,11 +34,81 @@ 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", ["no", "color", "icon"])
 
 start_time = aware_now()
 
 
+class Core:
+    def __init__(self):
+        self.levels = {
+            "TRACE": Level(
+                _defaults.LOGURU_TRACE_NO, _defaults.LOGURU_TRACE_COLOR, _defaults.LOGURU_TRACE_ICON
+            ),
+            "DEBUG": Level(
+                _defaults.LOGURU_DEBUG_NO, _defaults.LOGURU_DEBUG_COLOR, _defaults.LOGURU_DEBUG_ICON
+            ),
+            "INFO": Level(
+                _defaults.LOGURU_INFO_NO, _defaults.LOGURU_INFO_COLOR, _defaults.LOGURU_INFO_ICON
+            ),
+            "SUCCESS": Level(
+                _defaults.LOGURU_SUCCESS_NO,
+                _defaults.LOGURU_SUCCESS_COLOR,
+                _defaults.LOGURU_SUCCESS_ICON,
+            ),
+            "WARNING": Level(
+                _defaults.LOGURU_WARNING_NO,
+                _defaults.LOGURU_WARNING_COLOR,
+                _defaults.LOGURU_WARNING_ICON,
+            ),
+            "ERROR": Level(
+                _defaults.LOGURU_ERROR_NO, _defaults.LOGURU_ERROR_COLOR, _defaults.LOGURU_ERROR_ICON
+            ),
+            "CRITICAL": Level(
+                _defaults.LOGURU_CRITICAL_NO,
+                _defaults.LOGURU_CRITICAL_COLOR,
+                _defaults.LOGURU_CRITICAL_ICON,
+            ),
+        }
+        self.levels_ansi_codes = {
+            name: parse_ansi(level.color) for name, level in self.levels.items()
+        }
+        self.levels_ansi_codes[None] = ""
+
+        self.handlers_count = itertools.count()
+        self.handlers = {}
+
+        self.extra = {}
+        self.patcher = None
+
+        self.min_level = float("inf")
+        self.enabled = {}
+        self.activation_list = []
+        self.activation_none = True
+
+        self.lock = threading.Lock()
+
+    def __getstate__(self):
+        state = self.__dict__.copy()
+        state["lock"] = None
+        return state
+
+    def __setstate__(self, state):
+        self.__dict__.update(state)
+        self.lock = threading.Lock()
+
+
 class Logger:
     """An object to dispatch logging messages to configured handlers.
 
@@ -118,52 +190,8 @@ class Logger:
     .. _Formatting directives: https://docs.python.org/3/library/string.html#format-string-syntax
     """
 
-    _levels = {
-        "TRACE": Level(
-            _defaults.LOGURU_TRACE_NO, _defaults.LOGURU_TRACE_COLOR, _defaults.LOGURU_TRACE_ICON
-        ),
-        "DEBUG": Level(
-            _defaults.LOGURU_DEBUG_NO, _defaults.LOGURU_DEBUG_COLOR, _defaults.LOGURU_DEBUG_ICON
-        ),
-        "INFO": Level(
-            _defaults.LOGURU_INFO_NO, _defaults.LOGURU_INFO_COLOR, _defaults.LOGURU_INFO_ICON
-        ),
-        "SUCCESS": Level(
-            _defaults.LOGURU_SUCCESS_NO,
-            _defaults.LOGURU_SUCCESS_COLOR,
-            _defaults.LOGURU_SUCCESS_ICON,
-        ),
-        "WARNING": Level(
-            _defaults.LOGURU_WARNING_NO,
-            _defaults.LOGURU_WARNING_COLOR,
-            _defaults.LOGURU_WARNING_ICON,
-        ),
-        "ERROR": Level(
-            _defaults.LOGURU_ERROR_NO, _defaults.LOGURU_ERROR_COLOR, _defaults.LOGURU_ERROR_ICON
-        ),
-        "CRITICAL": Level(
-            _defaults.LOGURU_CRITICAL_NO,
-            _defaults.LOGURU_CRITICAL_COLOR,
-            _defaults.LOGURU_CRITICAL_ICON,
-        ),
-    }
-    _levels_ansi_codes = {name: parse_ansi(level.color) for name, level in _levels.items()}
-    _levels_ansi_codes[None] = ""
-
-    _handlers_count = itertools.count()
-    _handlers = {}
-
-    _extra_class = {}
-    _patcher_class = None
-
-    _min_level = float("inf")
-    _enabled = {}
-    _activation_list = []
-    _activation_none = True
-
-    _lock = threading.Lock()
-
-    def __init__(self, exception, depth, record, lazy, ansi, raw, patcher, extra):
+    def __init__(self, core, exception, depth, record, lazy, ansi, raw, patcher, extra):
+        self._core = core
         self._options = (exception, depth, record, lazy, ansi, raw, patcher, extra)
 
     def add(
@@ -640,6 +668,18 @@ class Logger:
         if colorize is None and serialize:
             colorize = False
 
+        if isinstance(format, str):
+            formatter = format + "\n{exception}"
+            is_formatter_dynamic = False
+        elif callable(format):
+            formatter = format
+            is_formatter_dynamic = True
+        else:
+            raise ValueError(
+                "Invalid format, it should be a string or a function, not: '%s'"
+                % type(format).__name__
+            )
+
         if isclass(sink):
             sink = sink(**kwargs)
             return self.add(
@@ -656,19 +696,13 @@ class Logger:
             )
         elif isinstance(sink, (str, PathLike)):
             path = sink
-            sink = FileSink(path, **kwargs)
-            return self.add(
-                sink,
-                level=level,
-                format=format,
-                filter=filter,
-                colorize=colorize,
-                serialize=serialize,
-                backtrace=backtrace,
-                diagnose=diagnose,
-                enqueue=enqueue,
-                catch=catch,
-            )
+            name = str(path)
+
+            if colorize is None:
+                colorize = False
+
+            wrapped_sink = FileSink(path, **kwargs)
+            encoding = wrapped_sink.encoding
         elif hasattr(sink, "write") and callable(sink.write):
             name = getattr(sink, "name", repr(sink))
 
@@ -680,93 +714,35 @@ class Logger:
             else:
                 stream = sink
 
-            stream_write = stream.write
-            if kwargs:
-
-                def write(m):
-                    return stream_write(m, **kwargs)
-
-            else:
-                write = stream_write
-
-            if hasattr(stream, "flush") and callable(stream.flush):
-                stream_flush = stream.flush
-
-                def writer(m):
-                    write(m)
-                    stream_flush()
-
-            else:
-                writer = write
-
-            if hasattr(stream, "stop") and callable(stream.stop):
-                stopper = stream.stop
-            else:
-
-                def stopper():
-                    return None
-
+            wrapped_sink = StreamSink(stream, kwargs)
+            encoding = getattr(sink, "encoding", None)
         elif isinstance(sink, logging.Handler):
             name = repr(sink)
 
-            def writer(m):
-                message = str(m)
-                r = m.record
-                exc = r["exception"]
-                if not is_formatter_dynamic:
-                    message = message[:-1]
-                record = logging.root.makeRecord(
-                    r["name"],
-                    r["level"].no,
-                    r["file"].path,
-                    r["line"],
-                    message,
-                    (),
-                    (exc.type, exc.value, exc.traceback) if exc else None,
-                    r["function"],
-                    r["extra"],
-                    **kwargs
-                )
-                if exc:
-                    record.exc_text = "\n"
-                sink.handle(record)
-
-            stopper = sink.close
             if colorize is None:
                 colorize = False
+
+            wrapped_sink = StandardSink(sink, kwargs, is_formatter_dynamic)
+            encoding = getattr(sink, "encoding", None)
         elif callable(sink):
             name = getattr(sink, "__name__", repr(sink))
 
-            if kwargs:
-
-                def writer(m):
-                    return sink(m, **kwargs)
-
-            else:
-                writer = sink
-
-            def stopper():
-                return None
-
             if colorize is None:
                 colorize = False
+
+            wrapped_sink = CallableSink(sink, kwargs)
+            encoding = "utf8"
         else:
             raise ValueError("Cannot log to objects of type '%s'." % type(sink).__name__)
 
         if filter is None:
             filter_func = None
         elif filter == "":
-
-            def filter_func(record):
-                return record["name"] is not None
-
+            filter_func = filter_none
         elif isinstance(filter, str):
             parent = filter + "."
             length = len(parent)
-
-            def filter_func(record):
-                return (record["name"] + ".")[:length] == parent
-
+            filter_func = functools.partial(filter_module, parent=parent, length=length)
         elif callable(filter):
             filter_func = filter
         else:
@@ -790,28 +766,11 @@ class Logger:
                 "Invalid level value, it should be a positive integer, not: %d" % levelno
             )
 
-        if isinstance(format, str):
-            formatter = format + "\n{exception}"
-            is_formatter_dynamic = False
-        elif callable(format):
-            formatter = format
-            is_formatter_dynamic = True
-        else:
-            raise ValueError(
-                "Invalid format, it should be a string or a function, not: '%s'"
-                % type(format).__name__
-            )
-
-        try:
-            encoding = sink.encoding
-        except AttributeError:
-            encoding = None
-
         if encoding is None:
             encoding = "ascii"
 
-        with Logger._lock:
-            handler_id = next(Logger._handlers_count)
+        with self._core.lock:
+            handler_id = next(self._core.handlers_count)
 
             exception_formatter = ExceptionFormatter(
                 colorize=colorize,
@@ -823,8 +782,7 @@ class Logger:
 
             handler = Handler(
                 name=name,
-                writer=writer,
-                stopper=stopper,
+                sink=wrapped_sink,
                 levelno=levelno,
                 formatter=formatter,
                 is_formatter_dynamic=is_formatter_dynamic,
@@ -835,19 +793,19 @@ class Logger:
                 enqueue=enqueue,
                 id_=handler_id,
                 exception_formatter=exception_formatter,
-                levels_ansi_codes=Logger._levels_ansi_codes,
+                levels_ansi_codes=self._core.levels_ansi_codes,
             )
 
-            handlers = Logger._handlers.copy()
+            handlers = self._core.handlers.copy()
             handlers[handler_id] = handler
 
-            Logger._min_level = min(Logger._min_level, levelno)
-            Logger._handlers = handlers
+            self._core.min_level = min(self._core.min_level, levelno)
+            self._core.handlers = handlers
 
         return handler_id
 
     def __repr__(self):
-        return "<loguru.logger handlers=%r>" % list(Logger._handlers.values())
+        return "<loguru.logger handlers=%r>" % list(self._core.handlers.values())
 
     def remove(self, handler_id=None):
         """Remove a previously added handler and stop sending logs to its sink.
@@ -877,8 +835,8 @@ class Logger:
                 "by the 'add()' method (or None), not: '%s'" % type(handler_id).__name__
             )
 
-        with Logger._lock:
-            handlers = Logger._handlers.copy()
+        with self._core.lock:
+            handlers = self._core.handlers.copy()
 
             if handler_id is None:
                 for handler in handlers.values():
@@ -894,8 +852,8 @@ class Logger:
                 handler.stop()
 
             levelnos = (h.levelno for h in handlers.values())
-            Logger._min_level = min(levelnos, default=float("inf"))
-            Logger._handlers = handlers
+            self._core.min_level = min(levelnos, default=float("inf"))
+            self._core.handlers = handlers
 
     def catch(
         self,
@@ -1088,7 +1046,7 @@ class Logger:
         >>> func()
         [18:11:54] DEBUG in 'func' - Get parent context
         """
-        return Logger(exception, depth, record, lazy, ansi, raw, *self._options[-2:])
+        return Logger(self._core, exception, depth, record, lazy, ansi, raw, *self._options[-2:])
 
     def bind(_self, **kwargs):
         """Bind attributes to the ``extra`` dict of each logged message record.
@@ -1125,7 +1083,7 @@ class Logger:
         127.0.0.1 - Second instance
         """
         *options, extra = _self._options
-        return Logger(*options, {**extra, **kwargs})
+        return Logger(_self._core, *options, {**extra, **kwargs})
 
     def patch(self, patcher):
         """Attach a function to modify the record dict created by each logging call.
@@ -1171,7 +1129,7 @@ class Logger:
         ...     logger.patch(lambda r: r.update(record)).log(level, message)
         """
         *options, _, extra = self._options
-        return Logger(*options, patcher, extra)
+        return Logger(self._core, *options, patcher, extra)
 
     def level(self, name, no=None, color=None, icon=None):
         """Add, update or retrieve a logging level.
@@ -1222,11 +1180,11 @@ class Logger:
 
         if no is color is icon is None:
             try:
-                return Logger._levels[name]
+                return self._core.levels[name]
             except KeyError:
                 raise ValueError("Level '%s' does not exist" % name) from None
 
-        if name not in Logger._levels:
+        if name not in self._core.levels:
             if no is None:
                 raise ValueError(
                     "Level '%s' does not exist, you have to create it by specifying a level no"
@@ -1257,10 +1215,10 @@ class Logger:
         ansi = parse_ansi(color)
         level = Level(no, color, icon)
 
-        with Logger._lock:
-            Logger._levels[name] = level
-            Logger._levels_ansi_codes[name] = ansi
-            for handler in Logger._handlers.values():
+        with self._core.lock:
+            self._core.levels[name] = level
+            self._core.levels_ansi_codes[name] = ansi
+            for handler in self._core.handlers.values():
                 handler.update_format(name)
 
         return level
@@ -1383,13 +1341,13 @@ class Logger:
                 self.level(**params)
 
         if patch is not None:
-            with Logger._lock:
-                Logger._patcher_class = patch
+            with self._core.lock:
+                self._core.patcher = patch
 
         if extra is not None:
-            with Logger._lock:
-                Logger._extra_class.clear()
-                Logger._extra_class.update(extra)
+            with self._core.lock:
+                self._core.extra.clear()
+                self._core.extra.update(extra)
 
         if activation is not None:
             for name, state in activation:
@@ -1406,21 +1364,23 @@ class Logger:
                 "Invalid name, it should be a string (or None), not: '%s'" % type(name).__name__
             )
 
-        with Logger._lock:
-            enabled = Logger._enabled.copy()
+        with self._core.lock:
+            enabled = self._core.enabled.copy()
 
             if name is None:
                 for n in enabled:
                     if n is None:
                         enabled[n] = status
-                Logger._activation_none = status
-                Logger._enabled = enabled
+                self._core.activation_none = status
+                self._core.enabled = enabled
                 return
 
             if name != "":
                 name += "."
 
-            activation_list = [(n, s) for n, s in Logger._activation_list if n[: len(name)] != name]
+            activation_list = [
+                (n, s) for n, s in self._core.activation_list if n[: len(name)] != name
+            ]
 
             parent_status = next((s for n, s in activation_list if name[: len(n)] == n), None)
             if parent_status != status and not (name == "" and status is True):
@@ -1435,8 +1395,8 @@ class Logger:
                 if n is not None and (n + ".")[: len(name)] == name:
                     enabled[n] = status
 
-            Logger._activation_list = activation_list
-            Logger._enabled = enabled
+            self._core.activation_list = activation_list
+            self._core.enabled = enabled
 
     @staticmethod
     def parse(file, pattern, *, cast={}, chunk=2 ** 16):
@@ -1550,9 +1510,10 @@ class Logger:
                 buffer = buffer[end:]
                 yield from matches[:-1]
 
-    @staticmethod
-    def _log(level_id, static_level_no, from_decorator, options, message, args, kwargs):
-        if not Logger._handlers:
+    def _log(self, level_id, static_level_no, from_decorator, options, message, args, kwargs):
+        core = self._core
+
+        if not core.handlers:
             return
 
         (exception, depth, record, lazy, ansi, raw, patcher, extra) = options
@@ -1565,18 +1526,18 @@ class Logger:
             name = None
 
         try:
-            if not Logger._enabled[name]:
+            if not core.enabled[name]:
                 return
         except KeyError:
-            enabled = Logger._enabled
+            enabled = core.enabled
             if name is None:
-                status = Logger._activation_none
+                status = core.activation_none
                 enabled[name] = status
                 if not status:
                     return
             else:
                 dotted_name = name + "."
-                for dotted_module_name, status in Logger._activation_list:
+                for dotted_module_name, status in core.activation_list:
                     if dotted_name[: len(dotted_module_name)] == dotted_module_name:
                         if status:
                             break
@@ -1592,12 +1553,12 @@ class Logger:
             level_name = "Level %d" % level_no
         else:
             try:
-                level_no, _, level_icon = Logger._levels[level_id]
+                level_no, _, level_icon = core.levels[level_id]
                 level_name = level_id
             except KeyError:
                 raise ValueError("Level '%s' does not exist" % level_id) from None
 
-        if level_no < Logger._min_level:
+        if level_no < core.min_level:
             return
 
         code = frame.f_code
@@ -1639,7 +1600,7 @@ class Logger:
         log_record = {
             "elapsed": elapsed,
             "exception": exception,
-            "extra": {**Logger._extra_class, **extra},
+            "extra": {**core.extra, **extra},
             "file": file_recattr,
             "function": code.co_name,
             "level": level_recattr,
@@ -1661,13 +1622,13 @@ class Logger:
         elif args or kwargs:
             log_record["message"] = message.format(*args, **kwargs)
 
-        if Logger._patcher_class:
-            Logger._patcher_class(log_record)
+        if core.patcher:
+            core.patcher(log_record)
 
         if patcher:
             patcher(log_record)
 
-        for handler in Logger._handlers.values():
+        for handler in core.handlers.values():
             handler.emit(log_record, level_id, from_decorator, ansi, raw)
 
     def trace(_self, _message, *args, **kwargs):
diff --git a/loguru/_simple_sinks.py b/loguru/_simple_sinks.py
new file mode 100644
index 0000000..35c8839
--- /dev/null
+++ b/loguru/_simple_sinks.py
@@ -0,0 +1,62 @@
+import logging
+
+
+class StreamSink:
+    def __init__(self, stream, kwargs):
+        self._stream = stream
+        self._kwargs = kwargs
+        self._flushable = hasattr(stream, "flush") and callable(stream.flush)
+        self._stoppable = hasattr(stream, "stop") and callable(stream.stop)
+
+    def write(self, message):
+        self._stream.write(message, **self._kwargs)
+        if self._flushable:
+            self._stream.flush()
+
+    def stop(self):
+        if self._stoppable:
+            self._stream.stop()
+
+
+class StandardSink:
+    def __init__(self, handler, kwargs, is_formatter_dynamic):
+        self._handler = handler
+        self._kwargs = kwargs
+        self._is_formatter_dynamic = is_formatter_dynamic
+
+    def write(self, message):
+        record = message.record
+        message = str(message)
+        exc = record["exception"]
+        if not self._is_formatter_dynamic:
+            message = message[:-1]
+        record = logging.root.makeRecord(
+            record["name"],
+            record["level"].no,
+            record["file"].path,
+            record["line"],
+            message,
+            (),
+            (exc.type, exc.value, exc.traceback) if exc else None,
+            record["function"],
+            record["extra"],
+            **self._kwargs
+        )
+        if exc:
+            record.exc_text = "\n"
+        self._handler.handle(record)
+
+    def stop(self):
+        self._handler.close()
+
+
+class CallableSink:
+    def __init__(self, function, kwargs):
+        self._function = function
+        self._kwargs = kwargs
+
+    def write(self, message):
+        self._function(message, **self._kwargs)
+
+    def stop(self):
+        pass
diff --git a/loguru/_string_parsers.py b/loguru/_string_parsers.py
index bd6fe86..459c359 100644
--- a/loguru/_string_parsers.py
+++ b/loguru/_string_parsers.py
@@ -2,6 +2,36 @@ import datetime
 import re
 
 
+class Frequencies:
+    @staticmethod
+    def hourly(t):
+        dt = t + datetime.timedelta(hours=1)
+        return dt.replace(minute=0, second=0, microsecond=0)
+
+    @staticmethod
+    def daily(t):
+        dt = t + datetime.timedelta(days=1)
+        return dt.replace(hour=0, minute=0, second=0, microsecond=0)
+
+    @staticmethod
+    def weekly(t):
+        dt = t + datetime.timedelta(days=7 - t.weekday())
+        return dt.replace(hour=0, minute=0, second=0, microsecond=0)
+
+    @staticmethod
+    def monthly(t):
+        if t.month == 12:
+            y, m = t.year + 1, 1
+        else:
+            y, m = t.year, t.month + 1
+        return t.replace(year=y, month=m, day=1, hour=0, minute=0, second=0, microsecond=0)
+
+    @staticmethod
+    def yearly(t):
+        y = t.year + 1
+        return t.replace(year=y, month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
+
+
 def parse_size(size):
     size = size.strip()
     reg = re.compile(r"([e\+\-\.\d]+)\s*([kmgtpezy])?(i)?(b)", flags=re.I)
@@ -68,42 +98,19 @@ def parse_frequency(frequency):
 
     if frequency == "hourly":
 
-        def hourly(t):
-            dt = t + datetime.timedelta(hours=1)
-            return dt.replace(minute=0, second=0, microsecond=0)
-
-        return hourly
+        return Frequencies.hourly
     elif frequency == "daily":
 
-        def daily(t):
-            dt = t + datetime.timedelta(days=1)
-            return dt.replace(hour=0, minute=0, second=0, microsecond=0)
-
-        return daily
+        return Frequencies.daily
     elif frequency == "weekly":
 
-        def weekly(t):
-            dt = t + datetime.timedelta(days=7 - t.weekday())
-            return dt.replace(hour=0, minute=0, second=0, microsecond=0)
-
-        return weekly
+        return Frequencies.weekly
     elif frequency == "monthly":
 
-        def monthly(t):
-            if t.month == 12:
-                y, m = t.year + 1, 1
-            else:
-                y, m = t.year, t.month + 1
-            return t.replace(year=y, month=m, day=1, hour=0, minute=0, second=0, microsecond=0)
-
-        return monthly
+        return Frequencies.monthly
     elif frequency == "yearly":
 
-        def yearly(t):
-            y = t.year + 1
-            return t.replace(year=y, month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
-
-        return yearly
+        return Frequencies.yearly
 
     return None
 


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 128d6ab..5b99172 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -22,22 +22,9 @@ def check_env_variables():
 
 @pytest.fixture(autouse=True)
 def reset_logger():
-    default_levels = loguru._logger.Logger._levels.copy()
-    default_levels_ansi_codes = loguru._logger.Logger._levels_ansi_codes.copy()
-
     def reset():
         loguru.logger.remove()
-        loguru.logger.__init__(None, 0, False, False, False, False, None, {})
-        loguru._logger.Logger._levels = default_levels.copy()
-        loguru._logger.Logger._levels_ansi_codes = default_levels_ansi_codes.copy()
-        loguru._logger.Logger._min_level = float("inf")
-        loguru._logger.Logger._extra_class = {}
-        loguru._logger.Logger._patcher_class = None
-        loguru._logger.Logger._handlers = {}
-        loguru._logger.Logger._handlers_count = itertools.count()
-        loguru._logger.Logger._enabled = {}
-        loguru._logger.Logger._activation_list = []
-        loguru._logger.Logger._activation_none = True
+        loguru.logger.__init__(loguru._logger.Core(), None, 0, False, False, False, False, None, {})
         logging.Logger.manager.loggerDict.clear()
         logging.root = logging.RootLogger(logging.WARNING)
 
diff --git a/tests/test_activation.py b/tests/test_activation.py
index b582a2d..20acdd6 100644
--- a/tests/test_activation.py
+++ b/tests/test_activation.py
@@ -76,7 +76,7 @@ def test_log_before_disable(writer):
 
 
 def test_multiple_activations():
-    n = lambda: len(logger._activation_list)
+    n = lambda: len(logger._core.activation_list)
 
     assert n() == 0
     logger.enable("")
diff --git a/tests/test_add_option_filter.py b/tests/test_add_option_filter.py
index 8d4dedf..c7a9761 100644
--- a/tests/test_add_option_filter.py
+++ b/tests/test_add_option_filter.py
@@ -39,14 +39,14 @@ def test_filtered_out(filter, writer):
 
 @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}")
+    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])
 def test_filtered_in_f_globals_name_absent(writer, filter, f_globals_name_absent):
-    logger.add(writer, filter=filter, format="{message}")
+    logger.add(writer, filter=filter, format="{message}", catch=False)
     logger.info("It's ok")
     assert writer.read() == "It's ok\n"
 
diff --git a/tests/test_add_sinks.py b/tests/test_add_sinks.py
index 4014168..beef288 100644
--- a/tests/test_add_sinks.py
+++ b/tests/test_add_sinks.py
@@ -123,6 +123,24 @@ def test_flush(rep):
     assert flushed == [expected] * rep
 
 
+def test_file_sink_ascii_encoding(tmpdir):
+    file = tmpdir.join("test.log")
+    logger.add(
+        str(file), encoding="ascii", format="{message}", errors="backslashreplace", catch=False
+    )
+    logger.info("天")
+    logger.remove()
+    assert file.read() == "\\u5929\n"
+
+
+def test_file_sink_utf8_encoding(tmpdir):
+    file = tmpdir.join("test.log")
+    logger.add(str(file), encoding="utf8", format="{message}", errors="strict", catch=False)
+    logger.info("天")
+    logger.remove()
+    assert file.read() == "天\n"
+
+
 def test_disabled_logger_in_sink(sink_with_logger):
     sink = sink_with_logger(logger)
     logger.disable("tests.conftest")
diff --git a/tests/test_catch_exceptions.py b/tests/test_catch_exceptions.py
index 3acc55c..53e0da0 100644
--- a/tests/test_catch_exceptions.py
+++ b/tests/test_catch_exceptions.py
@@ -237,6 +237,38 @@ def test_sink_encoding(writer, encoding):
     assert writer.read().endswith("ZeroDivisionError: division by zero\n")
 
 
+def test_file_sink_ascii_encoding(tmpdir):
+    file = tmpdir.join("test.log")
+    logger.add(str(file), format="", encoding="ascii", errors="backslashreplace", catch=False)
+    a = "天"
+
+    try:
+        "天" * a
+    except Exception:
+        logger.exception("")
+
+    logger.remove()
+    result = file.read()
+    assert result.count('"\\u5929" * a') == 1
+    assert result.count("-> '\\u5929'") == 1
+
+
+def test_file_sink_utf8_encoding(tmpdir):
+    file = tmpdir.join("test.log")
+    logger.add(str(file), format="", encoding="utf8", errors="strict", catch=False)
+    a = "天"
+
+    try:
+        "天" * a
+    except Exception:
+        logger.exception("")
+
+    logger.remove()
+    result = file.read()
+    assert result.count('"天" * a') == 1
+    assert result.count("└ '天'") == 1
+
+
 def test_has_sys_real_prefix(writer, monkeypatch):
     monkeypatch.setattr(sys, "real_prefix", "/foo/bar/baz", raising=False)
     logger.add(writer, backtrace=False, diagnose=True, colorize=False, format="")
@@ -376,6 +408,7 @@ def test_distutils_not_installed(writer, monkeypatch):
 
     assert writer.read().endswith("ZeroDivisionError: division by zero\n")
 
+
 def test_no_exception(writer):
     logger.add(writer, backtrace=False, diagnose=False, colorize=False, format="{message}")
 
diff --git a/tests/test_deepcopy.py b/tests/test_deepcopy.py
new file mode 100644
index 0000000..6f56cf3
--- /dev/null
+++ b/tests/test_deepcopy.py
@@ -0,0 +1,56 @@
+from loguru import logger
+import copy
+
+
+def test_add_sink_after_deepcopy(capsys):
+    logger_ = copy.deepcopy(logger)
+
+    logger_.add(print, format="{message}", end="", catch=False)
+
+    logger_.info("A")
+    logger.info("B")
+
+    out, err = capsys.readouterr()
+    assert out == "A\n"
+    assert err == ""
+
+
+def test_add_sink_before_deepcopy(capsys):
+    logger.add(print, format="{message}", end="", catch=False)
+
+    logger_ = copy.deepcopy(logger)
+
+    logger_.info("A")
+    logger.info("B")
+
+    out, err = capsys.readouterr()
+    assert out == "A\nB\n"
+    assert err == ""
+
+
+def test_remove_from_original(capsys):
+    logger.add(print, format="{message}", end="", catch=False)
+
+    logger_ = copy.deepcopy(logger)
+    logger.remove()
+
+    logger_.info("A")
+    logger.info("B")
+
+    out, err = capsys.readouterr()
+    assert out == "A\n"
+    assert err == ""
+
+
+def test_remove_from_copy(capsys):
+    logger.add(print, format="{message}", end="", catch=False)
+
+    logger_ = copy.deepcopy(logger)
+    logger_.remove()
+
+    logger_.info("A")
+    logger.info("B")
+
+    out, err = capsys.readouterr()
+    assert out == "B\n"
+    assert err == ""
diff --git a/tests/test_filesink_compression.py b/tests/test_filesink_compression.py
index 8de67cc..475730c 100644
--- a/tests/test_filesink_compression.py
+++ b/tests/test_filesink_compression.py
@@ -70,8 +70,8 @@ def test_rename_existing_with_creation_time(monkeypatch, tmpdir):
     j = logger.add(str(tmpdir.join("test.log")), compression="tar.gz")
     logger.debug("test")
 
-    filesink = next(iter(logger._handlers.values()))._writer.__self__
-    monkeypatch.setattr(filesink, "_get_creation_time", creation_time)
+    filesink = next(iter(logger._core.handlers.values()))._sink
+    monkeypatch.setattr(loguru._file_sink, "get_ctime", creation_time)
 
     logger.remove(j)
 
diff --git a/tests/test_filesink_rotation.py b/tests/test_filesink_rotation.py
index 725f6c0..d1c9909 100644
--- a/tests/test_filesink_rotation.py
+++ b/tests/test_filesink_rotation.py
@@ -1,5 +1,6 @@
 import pytest
 import datetime
+import importlib
 import os
 import re
 import sys
@@ -76,6 +77,10 @@ def monkeypatch_filesystem(monkeypatch):
             win32_setctime = MagicMock(SUPPORTED=True, setctime=patched_setctime)
             monkeypatch.setitem(sys.modules, "win32_setctime", win32_setctime)
 
+        ctime_functions = importlib.reload(loguru._ctime_functions)
+        monkeypatch.setattr(loguru._file_sink, "get_ctime", ctime_functions.get_ctime)
+        monkeypatch.setattr(loguru._file_sink, "set_ctime", ctime_functions.set_ctime)
+
     return monkeypatch_filesystem
 
 
@@ -421,8 +426,8 @@ def test_rename_existing_with_creation_time(monkeypatch, tmpdir):
     i = logger.add(str(tmpdir.join("test.log")), rotation=10, format="{message}")
     logger.debug("X")
 
-    filesink = next(iter(logger._handlers.values()))._writer.__self__
-    monkeypatch.setattr(filesink, "_get_creation_time", creation_time)
+    filesink = next(iter(logger._core.handlers.values()))._sink
+    monkeypatch.setattr(loguru._file_sink, "get_ctime", creation_time)
 
     logger.debug("Y" * 20)
 
diff --git a/tests/test_multiprocessing.py b/tests/test_multiprocessing.py
new file mode 100644
index 0000000..0faef84
--- /dev/null
+++ b/tests/test_multiprocessing.py
@@ -0,0 +1,409 @@
+import os
+import sys
+import multiprocessing
+import loguru
+from loguru import logger
+import time
+import pytest
+
+
+def do_something(i):
+    logger.info("#{}", i)
+
+
+def set_logger(logger_):
+    global logger
+    logger = logger_
+
+
+def subworker(logger_):
+    logger_.info("Child")
+
+
+def subworker_inheritance():
+    logger.info("Child")
+
+
+def subworker_remove(logger_):
+    logger_.info("Child")
+    logger_.remove()
+    logger_.info("Nope")
+
+
+def subworker_remove_inheritance():
+    logger.info("Child")
+    logger.remove()
+    logger.info("Nope")
+
+
+def subworker_barrier(logger_, barrier):
+    logger_.info("Child")
+    barrier.wait()
+    time.sleep(0.5)
+    logger_.info("Nope")
+
+
+def subworker_barrier_inheritance(barrier):
+    logger.info("Child")
+    barrier.wait()
+    time.sleep(0.5)
+    logger.info("Nope")
+
+
+class Writer:
+    def __init__(self):
+        self._output = ""
+
+    def write(self, message):
+        self._output += message
+
+    def read(self):
+        return self._output
+
+
+def test_apply_spawn(monkeypatch):
+    ctx = multiprocessing.get_context("spawn")
+    monkeypatch.setattr(loguru._handler, "multiprocessing", ctx)
+
+    writer = Writer()
+
+    logger.add(writer, format="{message}", enqueue=True)
+
+    with ctx.Pool(1, set_logger, [logger]) as pool:
+        for i in range(3):
+            pool.apply(do_something, (i,))
+        pool.close()
+        pool.join()
+
+    logger.info("Done!")
+    logger.remove()
+
+    assert writer.read() == "#0\n#1\n#2\nDone!\n"
+
+
+@pytest.mark.skipif(os.name == "nt", reason="Windows does not support forking")
+def test_apply_fork():
+    writer = Writer()
+
+    logger.add(writer, format="{message}", enqueue=True)
+
+    with multiprocessing.Pool(1, set_logger, [logger]) as pool:
+        for i in range(3):
+            pool.apply(do_something, (i,))
+        pool.close()
+        pool.join()
+
+    logger.info("Done!")
+    logger.remove()
+
+    assert writer.read() == "#0\n#1\n#2\nDone!\n"
+
+
+@pytest.mark.skipif(os.name == "nt", reason="Windows does not support forking")
+def test_apply_inheritance():
+    writer = Writer()
+
+    logger.add(writer, format="{message}", enqueue=True)
+
+    with multiprocessing.Pool(1) as pool:
+        for i in range(3):
+            pool.apply(do_something, (i,))
+        pool.close()
+        pool.join()
+
+    logger.info("Done!")
+    logger.remove()
+
+    assert writer.read() == "#0\n#1\n#2\nDone!\n"
+
+
+def test_apply_async_spawn(monkeypatch):
+    ctx = multiprocessing.get_context("spawn")
+    monkeypatch.setattr(loguru._handler, "multiprocessing", ctx)
+
+    writer = Writer()
+
+    logger.add(writer, format="{message}", enqueue=True)
+
+    with ctx.Pool(1, set_logger, [logger]) as pool:
+        for i in range(3):
+            result = pool.apply_async(do_something, (i,))
+            result.get()
+        pool.close()
+        pool.join()
+
+    logger.info("Done!")
+    logger.remove()
+
+    assert writer.read() == "#0\n#1\n#2\nDone!\n"
+
+
+@pytest.mark.skipif(os.name == "nt", reason="Windows does not support forking")
+def test_apply_async_fork():
+    writer = Writer()
+
+    logger.add(writer, format="{message}", enqueue=True)
+
+    with multiprocessing.Pool(1, set_logger, [logger]) as pool:
+        for i in range(3):
+            result = pool.apply_async(do_something, (i,))
+            result.get()
+        pool.close()
+        pool.join()
+
+    logger.info("Done!")
+    logger.remove()
+
+    assert writer.read() == "#0\n#1\n#2\nDone!\n"
+
+
+@pytest.mark.skipif(os.name == "nt", reason="Windows does not support forking")
+def test_apply_async_inheritance():
+    writer = Writer()
+
+    logger.add(writer, format="{message}", enqueue=True)
+
+    with multiprocessing.Pool(1) as pool:
+        for i in range(3):
+            result = pool.apply_async(do_something, (i,))
+            result.get()
+        pool.close()
+        pool.join()
+
+    logger.info("Done!")
+    logger.remove()
+
+    assert writer.read() == "#0\n#1\n#2\nDone!\n"
+
+
+def test_process_spawn(monkeypatch):
+    ctx = multiprocessing.get_context("spawn")
+    monkeypatch.setattr(loguru._handler, "multiprocessing", ctx)
+
+    writer = Writer()
+
+    logger.add(writer, format="{message}", enqueue=True)
+
+    process = ctx.Process(target=subworker, args=(logger,))
+    process.start()
+    process.join()
+
+    logger.info("Main")
+    logger.remove()
+
+    assert writer.read() == "Child\nMain\n"
+
+
+@pytest.mark.skipif(os.name == "nt", reason="Windows does not support forking")
+def test_process_fork():
+    writer = Writer()
+
+    logger.add(writer, format="{message}", enqueue=True)
+
+    process = multiprocessing.Process(target=subworker, args=(logger,))
+    process.start()
+    process.join()
+
+    logger.info("Main")
+    logger.remove()
+
+    assert writer.read() == "Child\nMain\n"
+
+
+@pytest.mark.skipif(os.name == "nt", reason="Windows does not support forking")
+def test_process_inheritance():
+    writer = Writer()
+
+    logger.add(writer, format="{message}", enqueue=True)
+
+    process = multiprocessing.Process(target=subworker_inheritance)
+    process.start()
+    process.join()
+
+    logger.info("Main")
+    logger.remove()
+
+    assert writer.read() == "Child\nMain\n"
+
+
+def test_remove_in_child_process_spawn(monkeypatch):
+    ctx = multiprocessing.get_context("spawn")
+    monkeypatch.setattr(loguru._handler, "multiprocessing", ctx)
+
+    writer = Writer()
+
+    logger.add(writer, format="{message}", enqueue=True)
+
+    process = ctx.Process(target=subworker_remove, args=(logger,))
+    process.start()
+    process.join()
+
+    logger.info("Main")
+    logger.remove()
+
+    assert writer.read() == "Child\nMain\n"
+
+
+@pytest.mark.skipif(os.name == "nt", reason="Windows does not support forking")
+def test_remove_in_child_process_fork():
+    writer = Writer()
+
+    logger.add(writer, format="{message}", enqueue=True)
+
+    process = multiprocessing.Process(target=subworker_remove, args=(logger,))
+    process.start()
+    process.join()
+
+    logger.info("Main")
+    logger.remove()
+
+    assert writer.read() == "Child\nMain\n"
+
+
+@pytest.mark.skipif(os.name == "nt", reason="Windows does not support forking")
+def test_remove_in_child_process_inheritance():
+    writer = Writer()
+
+    logger.add(writer, format="{message}", enqueue=True)
+
+    process = multiprocessing.Process(target=subworker_remove_inheritance)
+    process.start()
+    process.join()
+
+    logger.info("Main")
+    logger.remove()
+
+    assert writer.read() == "Child\nMain\n"
+
+
+def test_remove_in_main_process_spawn(monkeypatch):
+    # Actually, this test may fail if sleep time in main process is too small (and no barrier used)
+    # In such situation, it seems the child process has not enough time to initialize itself
+    # It may fail with an "EOFError" during unpickling of the (garbage collected / closed) Queue
+    ctx = multiprocessing.get_context("spawn")
+    monkeypatch.setattr(loguru._handler, "multiprocessing", ctx)
+
+    writer = Writer()
+    barrier = ctx.Barrier(2)
+
+    logger.add(writer, format="{message}", enqueue=True)
+
+    process = ctx.Process(target=subworker_barrier, args=(logger, barrier))
+    process.start()
+    barrier.wait()
+    logger.info("Main")
+    logger.remove()
+    process.join()
+
+    assert writer.read() == "Child\nMain\n"
+
+
+@pytest.mark.skipif(os.name == "nt", reason="Windows does not support forking")
+def test_remove_in_main_process_fork():
+    writer = Writer()
+    barrier = multiprocessing.Barrier(2)
+
+    logger.add(writer, format="{message}", enqueue=True)
+
+    process = multiprocessing.Process(target=subworker_barrier, args=(logger, barrier))
+    process.start()
+    barrier.wait()
+    logger.info("Main")
+    logger.remove()
+    process.join()
+
+    assert writer.read() == "Child\nMain\n"
+
+
+@pytest.mark.skipif(os.name == "nt", reason="Windows does not support forking")
+def test_remove_in_main_process_inheritance():
+    writer = Writer()
+    barrier = multiprocessing.Barrier(2)
+
+    logger.add(writer, format="{message}", enqueue=True)
+
+    process = multiprocessing.Process(target=subworker_barrier_inheritance, args=(barrier,))
+    process.start()
+    barrier.wait()
+    logger.info("Main")
+    logger.remove()
+    process.join()
+
+    assert writer.read() == "Child\nMain\n"
+
+
+def test_not_picklable_sinks_spawn(monkeypatch, tmpdir, capsys):
+    ctx = multiprocessing.get_context("spawn")
+    monkeypatch.setattr(loguru._handler, "multiprocessing", ctx)
+
+    filepath = tmpdir.join("test.log")
+    stream = sys.stderr
+    output = []
+
+    logger.add(str(filepath), format="{message}", enqueue=True)
+    logger.add(stream, format="{message}", enqueue=True)
+    logger.add(lambda m: output.append(m), format="{message}", enqueue=True)
+
+    process = ctx.Process(target=subworker, args=[logger])
+    process.start()
+    process.join()
+
+    logger.info("Main")
+    logger.remove()
+
+    out, err = capsys.readouterr()
+
+    assert filepath.read() == "Child\nMain\n"
+    assert out == ""
+    assert err == "Child\nMain\n"
+    assert output == ["Child\n", "Main\n"]
+
+
+@pytest.mark.skipif(os.name == "nt", reason="Windows does not support forking")
+def test_not_picklable_sinks_fork(capsys, tmpdir):
+    filepath = tmpdir.join("test.log")
+    stream = sys.stderr
+    output = []
+
+    logger.add(str(filepath), format="{message}", enqueue=True)
+    logger.add(stream, format="{message}", enqueue=True)
+    logger.add(lambda m: output.append(m), format="{message}", enqueue=True)
+
+    process = multiprocessing.Process(target=subworker, args=[logger])
+    process.start()
+    process.join()
+
+    logger.info("Main")
+    logger.remove()
+
+    out, err = capsys.readouterr()
+
+    assert filepath.read() == "Child\nMain\n"
+    assert out == ""
+    assert err == "Child\nMain\n"
+    assert output == ["Child\n", "Main\n"]
+
+
+@pytest.mark.skipif(os.name == "nt", reason="Windows does not support forking")
+def test_not_picklable_sinks_inheritance(capsys, tmpdir):
+    filepath = tmpdir.join("test.log")
+    stream = sys.stderr
+    output = []
+
+    logger.add(str(filepath), format="{message}", enqueue=True)
+    logger.add(stream, format="{message}", enqueue=True)
+    logger.add(lambda m: output.append(m), format="{message}", enqueue=True)
+
+    process = multiprocessing.Process(target=subworker_inheritance)
+    process.start()
+    process.join()
+
+    logger.info("Main")
+    logger.remove()
+
+    out, err = capsys.readouterr()
+
+    assert filepath.read() == "Child\nMain\n"
+    assert out == ""
+    assert err == "Child\nMain\n"
+    assert output == ["Child\n", "Main\n"]
diff --git a/tests/test_pickling.py b/tests/test_pickling.py
index a03437f..14af636 100644
--- a/tests/test_pickling.py
+++ b/tests/test_pickling.py
@@ -1,31 +1,281 @@
-from loguru import logger
+import logging
 import pickle
 import sys
+import datetime
+
 import pytest
 
+from loguru import logger
+
+
+class StreamHandler:
+    def __init__(self, flushable=False, stoppable=False):
+        if flushable:
+            self.flush = self._flush
+        if stoppable:
+            self.stop = self._stop
+
+        self.wrote = ""
+        self.flushed = False
+        self.stopped = False
+
+    def write(self, message):
+        self.wrote += message
+
+    def _flush(self):
+        self.flushed = True
+
+    def _stop(self):
+        self.stopped = True
+
+
+class StandardHandler(logging.Handler):
+    def __init__(self, level):
+        super().__init__(level)
+        self.written = ""
+        self.lock = None
+
+    def emit(self, record):
+        self.written += record.getMessage()
+
+    def acquire(self):
+        pass
+
+    def createLock(self):
+        return None
+
+
+def format_function(record):
+    return "-> {message}"
+
+
+def filter_function(record):
+    return "[PASS]" in record["message"]
+
+
+def patch_function(record):
+    record["extra"]["foo"] = "bar"
+
+
+def rotation_function(message, file):
+    pass
+
+
+def retention_function(files):
+    pass
+
 
-def test_pickling_logging_method(writer):
-    logger.add(writer, format="{level} - {function} - {message}")
+def compression_function(path):
+    pass
+
+
+def test_pickling_function_handler(capsys):
+    logger.add(print, format="{level} - {function} - {message}", end="")
+    pickled = pickle.dumps(logger)
+    unpikcled = pickle.loads(pickled)
+    unpikcled.debug("A message")
+    out, err = capsys.readouterr()
+    assert out == "DEBUG - test_pickling_function_handler - A message\n"
+    assert err == ""
+
+
+@pytest.mark.parametrize("flushable", [True, False])
+@pytest.mark.parametrize("stoppable", [True, False])
+def test_pickling_stream_handler(flushable, stoppable):
+    stream = StreamHandler(flushable, stoppable)
+    i = logger.add(stream, format="{level} - {function} - {message}")
+    pickled = pickle.dumps(logger)
+    unpickled = pickle.loads(pickled)
+    unpickled.debug("A message")
+    stream = next(iter(unpickled._core.handlers.values()))._sink._stream
+    unpickled.remove(i)
+    assert stream.wrote == "DEBUG - test_pickling_stream_handler - A message\n"
+    assert stream.flushed == flushable
+    assert stream.stopped == stoppable
+
+
+def test_pickling_standard_handler():
+    handler = StandardHandler(logging.NOTSET)
+    logger.add(handler, format="{level} - {function} - {message}")
+    pickled = pickle.dumps(logger)
+    unpickled = pickle.loads(pickled)
+    unpickled.debug("A message")
+    handler = next(iter(unpickled._core.handlers.values()))._sink._handler
+    assert handler.written == "DEBUG - test_pickling_standard_handler - A message"
+
+
+def test_pickling_class_handler():
+    logger.add(StreamHandler, format="{level} - {function} - {message}")
+    pickled = pickle.dumps(logger)
+    unpickled = pickle.loads(pickled)
+    unpickled.debug("A message")
+    stream = next(iter(unpickled._core.handlers.values()))._sink._stream
+    assert stream.wrote == "DEBUG - test_pickling_class_handler - A message\n"
+
+
+def test_pickling_file_handler(tmpdir):
+    file = tmpdir.join("test.log")
+    logger.add(str(file), format="{level} - {function} - {message}", delay=True)
+    pickled = pickle.dumps(logger)
+    unpickled = pickle.loads(pickled)
+    unpickled.debug("A message")
+    assert file.read() == "DEBUG - test_pickling_file_handler - A message\n"
+
+
+@pytest.mark.parametrize(
+    "rotation",
+    [
+        1000,
+        "daily",
+        datetime.timedelta(minutes=60),
+        datetime.time(hour=12, minute=00, second=00),
+        "200 MB",
+        "10:00",
+        "5 hours",
+        rotation_function,
+    ],
+)
+def test_pickling_file_handler_rotation(tmpdir, rotation):
+    file = tmpdir.join("test.log")
+    logger.add(str(file), format="{level} - {function} - {message}", delay=True, rotation=rotation)
+    pickled = pickle.dumps(logger)
+    unpickled = pickle.loads(pickled)
+    unpickled.debug("A message")
+    assert file.read() == "DEBUG - test_pickling_file_handler_rotation - A message\n"
+
+
+@pytest.mark.parametrize(
+    "retention", [1000, datetime.timedelta(hours=13), "10 days", retention_function]
+)
+def test_pickling_file_handler_retention(tmpdir, retention):
+    file = tmpdir.join("test.log")
+    logger.add(
+        str(file), format="{level} - {function} - {message}", delay=True, retention=retention
+    )
+    pickled = pickle.dumps(logger)
+    unpickled = pickle.loads(pickled)
+    unpickled.debug("A message")
+    assert file.read() == "DEBUG - test_pickling_file_handler_retention - A message\n"
+
+
+@pytest.mark.parametrize("compression", ["zip", "gz", "tar", compression_function])
+def test_pickling_file_handler_compression(tmpdir, compression):
+    file = tmpdir.join("test.log")
+    logger.add(
+        str(file), format="{level} - {function} - {message}", delay=True, compression=compression
+    )
+    pickled = pickle.dumps(logger)
+    unpickled = pickle.loads(pickled)
+    unpickled.debug("A message")
+    assert file.read() == "DEBUG - test_pickling_file_handler_compression - A message\n"
+
+
+def test_pickling_no_handler(writer):
+    pickled = pickle.dumps(logger)
+    unpickled = pickle.loads(pickled)
+    unpickled.add(writer, format="{level} - {function} - {message}")
+    unpickled.debug("A message")
+    assert writer.read() == "DEBUG - test_pickling_no_handler - A message\n"
+
+
+def test_pickling_handler_not_serializable():
+    logger.add(lambda m: None)
+    with pytest.raises((pickle.PicklingError, AttributeError), match="Can't (pickle|get local)"):
+        pickle.dumps(logger)
+
+
+def test_pickling_filter_function(capsys):
+    logger.add(print, format="{message}", filter=filter_function, end="")
+    pickled = pickle.dumps(logger)
+    unpickled = pickle.loads(pickled)
+    unpickled.info("Nope")
+    unpickled.info("[PASS] Yes")
+    out, err = capsys.readouterr()
+    assert out == "[PASS] Yes\n"
+    assert err == ""
+
+
+@pytest.mark.parametrize("filter", ["", "tests"])
+def test_pickling_filter_name(capsys, filter):
+    logger.add(print, format="{message}", filter=filter, end="")
+    pickled = pickle.dumps(logger)
+    unpickled = pickle.loads(pickled)
+    unpickled.info("A message")
+    out, err = capsys.readouterr()
+    assert out == "A message\n"
+    assert err == ""
+
+
+def test_pickling_format_function(capsys):
+    logger.add(print, format=format_function, end="")
+    pickled = pickle.dumps(logger)
+    unpickled = pickle.loads(pickled)
+    unpickled.info("The message")
+    out, err = capsys.readouterr()
+    assert out == "-> The message"
+    assert err == ""
+
+
+def test_pickling_filter_function_not_serializable():
+    logger.add(print, filter=lambda r: True)
+    with pytest.raises((pickle.PicklingError, AttributeError), match="Can't (pickle|get local)"):
+        pickle.dumps(logger)
+
+
+def test_pickling_format_function_not_serializable():
+    logger.add(print, format=lambda r: "{message}")
+    with pytest.raises((pickle.PicklingError, AttributeError), match="Can't (pickle|get local)"):
+        pickle.dumps(logger)
+
+
+def test_pickling_bound_logger(writer):
+    bound_logger = logger.bind(foo="bar")
+    pickled = pickle.dumps(bound_logger)
+    unpickled = pickle.loads(pickled)
+    unpickled.add(writer, format="{extra[foo]}")
+    unpickled.info("Test")
+    assert writer.read() == "bar\n"
+
+
+def test_pickling_patched_logger(writer):
+    patched_logger = logger.patch(patch_function)
+    pickled = pickle.dumps(patched_logger)
+    unpickled = pickle.loads(pickled)
+    unpickled.add(writer, format="{extra[foo]}")
+    unpickled.info("Test")
+    assert writer.read() == "bar\n"
+
+
+def test_remove_after_pickling(capsys):
+    i = logger.add(print, end="", format="{message}")
+    logger.info("A")
+    pickled = pickle.dumps(logger)
+    unpickled = pickle.loads(pickled)
+    unpickled.remove(i)
+    unpickled.info("B")
+    out, err = capsys.readouterr()
+    assert out == "A\n"
+    assert err == ""
+
+
+def test_pickling_logging_method(capsys):
+    logger.add(print, format="{level} - {function} - {message}", end="")
     pickled = pickle.dumps(logger.critical)
     func = pickle.loads(pickled)
     func("A message")
-    assert writer.read() == "CRITICAL - test_pickling_logging_method - A message\n"
+    out, err = capsys.readouterr()
+    assert out == "CRITICAL - test_pickling_logging_method - A message\n"
+    assert err == ""
 
 
-def test_pickling_log_method(writer):
-    logger.add(writer, format="{level} - {function} - {message}")
+def test_pickling_log_method(capsys):
+    logger.add(print, format="{level} - {function} - {message}", end="")
     pickled = pickle.dumps(logger.log)
     func = pickle.loads(pickled)
     func(19, "A message")
-    assert writer.read() == "Level 19 - test_pickling_log_method - A message\n"
-
-
-def test_pickling_logger(writer):
-    pickled = pickle.dumps(logger)
-    inst = pickle.loads(pickled)
-    inst.add(writer, format="{level} - {function} - {message}")
-    inst.debug("A message")
-    assert writer.read() == "DEBUG - test_pickling_logger - A message\n"
+    out, err = capsys.readouterr()
+    assert out == "Level 19 - test_pickling_log_method - A message\n"
+    assert err == ""
 
 
 @pytest.mark.parametrize(