Repository: hynek/structlog

Issue:
Support bytearray for final processor output
Use case: For performance in concatenating bytes, one might want their final processor to output a `bytearray` instead of `bytes`. This [blog post](https://www.guyrutenberg.com/2020/04/04/fast-bytes-concatenation-in-python/) illustrates that using a `bytearray` is the most performant way to concatenate bytes in Python (with all the caveats of sharing results from a blog post that haven't been verified locally, and the experiment can't exactly be considered comprehensive or rigorous). 

Use of `bytearray` output is currently unsupported, as can be seen in [_base.py](https://github.com/hynek/structlog/blob/4c19f0c10077492212b9b6aa0a9e68b6a644675e/src/structlog/_base.py#L157):

```python
...
        if isinstance(event_dict, (str, bytes)):
            return (event_dict,), {}
        elif isinstance(event_dict, tuple):
            # In this case we assume that the last processor returned a tuple
            # of ``(args, kwargs)`` and pass it right through.
            return event_dict  # type: ignore
        elif isinstance(event_dict, dict):
            return (), event_dict
        else:
            raise ValueError(
                "Last processor didn't return an appropriate value.  Valid "
                "return values are a dict, a tuple of (args, kwargs), bytes, "
                "or a str."
            )
...
```

I'll be honest in that I'm not an expert in byte handling in Python, but it seems like `bytearray` quacks like `bytes` according to the docs:
- https://docs.python.org/3/library/functions.html#func-bytearray
- https://docs.python.org/3/library/stdtypes.html#bytearray

In a very crude and naive local test, I modified this line in `structlog` from:

`if isinstance(event_dict, (str, bytes)):`

to:

`if isinstance(event_dict, (str, bytes, bytearray)):`

and my stdout bytes logging still worked (configured identically to [this example](https://www.structlog.org/en/stable/performance.html#example) in the Performance section of the `structlog` docs).

Comments:

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 2b4d49b..4332c70 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -39,6 +39,8 @@ Changes:
 - All use of ``colorama`` on non-Windows systems has been excised.
   Thus, colors are now enabled by default in ``structlog.dev.ConsoleRenderer`` on non-Windows systems.
   You can keep using ``colorama`` to customize colors, of course.
+- The final processor can now return a ``bytearray`` (additionally to ``str`` and ``bytes``).
+  `#344 <https://github.com/hynek/structlog/issues/344>`_
 
 
 ----
diff --git a/docs/processors.rst b/docs/processors.rst
index 40cca26..427c63c 100644
--- a/docs/processors.rst
+++ b/docs/processors.rst
@@ -101,7 +101,7 @@ With that, it's also the *only* processor that needs to know anything about the
 
 It can return one of three types:
 
-- An Unicode string or a bytes string (i.e. `str` or `bytes`) that is passed as the first (and only) positional argument to the underlying logger.
+- An Unicode string (`str`), a bytes string (`bytes`), or a `bytearray` that is passed as the first (and only) positional argument to the underlying logger.
 - A tuple of ``(args, kwargs)`` that are passed as ``log_method(*args, **kwargs)``.
 - A dictionary which is passed as ``log_method(**kwargs)``.
 
diff --git a/src/structlog/_base.py b/src/structlog/_base.py
index ec6a2a8..8c97ac9 100644
--- a/src/structlog/_base.py
+++ b/src/structlog/_base.py
@@ -131,7 +131,7 @@ class BoundLoggerBase:
 
         :raises: `structlog.DropEvent` if log entry should be dropped.
         :raises: `ValueError` if the final processor doesn't return a
-            str, bytes, tuple, or a dict.
+            str, bytes, bytearray, tuple, or a dict.
 
         :returns: `tuple` of ``(*args, **kw)``
 
@@ -143,6 +143,10 @@ class BoundLoggerBase:
 
         .. versionchanged:: 14.0.0
             Allow final processor to return a `dict`.
+        .. versionchanged:: 20.2.0
+            Allow final processor to return `bytes`.
+        .. versionchanged:: 21.2.0
+            Allow final processor to return a `bytearray`.
         """
         # We're typing it as Any, because processors can return more than an
         # EventDict.
@@ -154,7 +158,7 @@ class BoundLoggerBase:
         for proc in self._processors:
             event_dict = proc(self._logger, method_name, event_dict)
 
-        if isinstance(event_dict, (str, bytes)):
+        if isinstance(event_dict, (str, bytes, bytearray)):
             return (event_dict,), {}
         elif isinstance(event_dict, tuple):
             # In this case we assume that the last processor returned a tuple
diff --git a/src/structlog/types.py b/src/structlog/types.py
index 199a706..c4a0dd4 100644
--- a/src/structlog/types.py
+++ b/src/structlog/types.py
@@ -64,7 +64,7 @@ copy itself.
 
 Processor = Callable[
     [WrappedLogger, str, EventDict],
-    Union[Mapping[str, Any], str, bytes, Tuple[Any, ...]],
+    Union[Mapping[str, Any], str, bytes, bytearray, Tuple[Any, ...]],
 ]
 """
 A callable that is part of the processor chain.


Test patch:
diff --git a/tests/test_base.py b/tests/test_base.py
index 4021a3e..6299461 100644
--- a/tests/test_base.py
+++ b/tests/test_base.py
@@ -186,6 +186,16 @@ class TestProcessing:
 
         assert ((b"foo",), {}) == b._process_event(None, "name", {})
 
+    def test_last_processor_returns_bytearray(self):
+        """
+        If the final processor returns a bytearray, ``(the_array,), {}`` is
+        returned.
+        """
+        logger = stub(msg=lambda *args, **kw: (args, kw))
+        b = build_bl(logger, processors=[lambda *_: bytearray(b"foo")])
+
+        assert ((bytearray(b"foo"),), {}) == b._process_event(None, "name", {})
+
     def test_last_processor_returns_tuple(self):
         """
         If the final processor returns a tuple, it is just passed through.