Repository: hynek/structlog

Issue:
Public access to threadlocal._get_context()
Similarly to #266, I find myself needing to access the thread-local context (I need to re-estabish some bound variables after a `clean_threadlocal()`).

I tried to use `structlog.get_context()` but it's not returning the threadlocal context. If this would be accepted, I might even come up with a PR.

Comments:

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index fa6b7c3..edd47b8 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -32,6 +32,8 @@ Changes:
 - If the `better-exceptions <https://github.com/qix-/better-exceptions>`_ package is present, ``structlog.dev.ConsoleRenderer`` will now pretty-print exceptions using it.
   Pass ``pretty_exceptions=False`` to disable.
   This only works if ``format_exc_info`` is **absent** in the processor chain.
+- ``structlog.threadlocal.get_threadlocal()`` can now be used to get a copy of the current thread-local context that has been bound using ``structlog.threadlocal.bind_threadlocal()``.
+- ``structlog.threadlocal.get_merged_threadlocal(bl)`` does the same, but also merges the context from a bound logger *bl*.
 
 
 ----
diff --git a/docs/api.rst b/docs/api.rst
index b1307da..869ba83 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -109,11 +109,39 @@ API Reference
 
 .. automodule:: structlog.threadlocal
 
+
+Modern Approach
+~~~~~~~~~~~~~~~
+
+.. autofunction:: bind_threadlocal
+
+.. autofunction:: get_threadlocal
+
+   >>> from structlog.threadlocal import bind_threadlocal, get_threadlocal
+   >>> bind_threadlocal(x=1)
+   >>> get_threadlocal()
+   {'x': 1}
+
+.. autofunction:: get_merged_threadlocal
+
+   >>> from structlog import get_logger
+   >>> from structlog.threadlocal import bind_threadlocal, get_merged_threadlocal
+   >>> bind_threadlocal(x=1)
+   >>> log = get_logger()
+   >>> log = log.bind(y=2)
+   >>> get_merged_threadlocal(log)
+   {'x': 1, 'y': 2}
+
 .. autofunction:: merge_threadlocal
 
 .. autofunction:: clear_threadlocal
 
-.. autofunction:: bind_threadlocal
+
+Old Approach
+~~~~~~~~~~~~
+
+The following APIs use a different approach, that we discourage nowadays.
+Please see :doc:`thread-local` for details.
 
 .. autofunction:: wrap_dict
 
diff --git a/docs/thread-local.rst b/docs/thread-local.rst
index 0a08281..efe6993 100644
--- a/docs/thread-local.rst
+++ b/docs/thread-local.rst
@@ -19,16 +19,16 @@ Immutability
 
    You should call some functions with some arguments.
 
-   ---David Reid
+   --- David Reid
 
-The behavior of copying itself, adding new values, and returning the result is useful for applications that keep somehow their own context using classes or closures.
-Twisted is a `fine example <twisted-example>` for that.
-Another possible approach is passing wrapped loggers around or log only within your view where you gather errors and events using return codes and exceptions.
-If you are willing to do that, you should stick to it because `immutable state <https://en.wikipedia.org/wiki/Immutable_object>`_ is a very good thing\ [*]_.
-Sooner or later, global state and mutable data lead to unpleasant surprises.
+``structlog`` does its best to have as little global state as possible to achieve its goals.
+In an ideal world, you would just stick to its immutable\ [*]_ bound loggers and reap all the rewards of having purely `immutable state <https://en.wikipedia.org/wiki/Immutable_object>`_.
 
-However, in the case of conventional web development, we realize that passing loggers around seems rather cumbersome, intrusive, and generally against the mainstream culture.
-And since it's more important that people actually *use* ``structlog`` than to be pure and snobby, ``structlog`` ships with the `structlog.threadlocal` module and a couple of mechanisms to help here.
+However, we realize that passing loggers around is rather clunky and intrusive in practice.
+And since `practicality beats purity <https://www.python.org/dev/peps/pep-0020/>`_, ``structlog`` ships with the `structlog.threadlocal` module to help you to safely have global context storage.
+
+.. [*] In the spirit of Python's 'consenting adults', ``structlog`` doesn't enforce the immutability with technical means.
+   However, if you don't meddle with undocumented data, the objects can be safely considered immutable.
 
 
 The ``merge_threadlocal`` Processor
@@ -43,6 +43,7 @@ The general flow of using these functions is:
 - Call `structlog.threadlocal.bind_threadlocal` as an alternative to your bound logger's ``bind()`` when you want to bind a particular variable to the thread-local context.
 - Use ``structlog`` as normal.
   Loggers act as they always do, but the `structlog.threadlocal.merge_threadlocal` processor ensures that any thread-local binds get included in all of your log messages.
+- If you want to access the thread-local storage, you use `structlog.threadlocal.get_threadlocal` and `structlog.threadlocal.get_merged_threadlocal`.
 
 .. doctest::
 
@@ -184,7 +185,3 @@ In this case we feel like this is an acceptable trade-off.
 You can easily write deterministic tests using a call-capturing processor if you use the API properly (cf. warning above).
 
 This big red box is also what separates immutable local from mutable global data.
-
-
-.. [*] In the spirit of Python's 'consenting adults', ``structlog`` doesn't enforce the immutability with technical means.
-   However, if you don't meddle with undocumented data, the objects can be safely considered immutable.
diff --git a/src/structlog/threadlocal.py b/src/structlog/threadlocal.py
index ea221ac..31df92e 100644
--- a/src/structlog/threadlocal.py
+++ b/src/structlog/threadlocal.py
@@ -13,6 +13,8 @@ import uuid
 
 from typing import Any, Dict, Generator, Iterator, Type, TypeVar
 
+import structlog
+
 from ._config import BoundLoggerLazyProxy
 from .types import BindableLogger, Context, EventDict, WrappedLogger
 
@@ -169,6 +171,28 @@ class _ThreadLocalDictWrapper:
 _CONTEXT = threading.local()
 
 
+def get_threadlocal() -> Context:
+    """
+    Return a copy of the current thread-local context.
+
+    .. versionadded:: 21.2.0
+    """
+    return _get_context().copy()
+
+
+def get_merged_threadlocal(bound_logger: BindableLogger) -> Context:
+    """
+    Return a copy of the current thread-local context merged with the context
+    from *bound_logger*.
+
+    .. versionadded:: 21.2.0
+    """
+    ctx = _get_context().copy()
+    ctx.update(structlog.get_context(bound_logger))
+
+    return ctx
+
+
 def merge_threadlocal(
     logger: WrappedLogger, method_name: str, event_dict: EventDict
 ) -> EventDict:
@@ -181,7 +205,7 @@ def merge_threadlocal(
     .. versionadded:: 19.2.0
 
     .. versionchanged:: 20.1.0
-       This function used to be called ``merge_threalocal_context`` and that
+       This function used to be called ``merge_threadlocal_context`` and that
        name is still kept around for backward compatibility.
     """
     context = _get_context().copy()


Test patch:
diff --git a/tests/test_threadlocal.py b/tests/test_threadlocal.py
index 1675078..2cac457 100644
--- a/tests/test_threadlocal.py
+++ b/tests/test_threadlocal.py
@@ -7,15 +7,18 @@ import threading
 
 import pytest
 
+import structlog
+
 from structlog._base import BoundLoggerBase
 from structlog._config import wrap_logger
 from structlog.testing import ReturnLogger
 from structlog.threadlocal import (
     _CONTEXT,
-    _get_context,
     as_immutable,
     bind_threadlocal,
     clear_threadlocal,
+    get_merged_threadlocal,
+    get_threadlocal,
     merge_threadlocal,
     merge_threadlocal_context,
     tmp_bind,
@@ -326,19 +329,18 @@ class TestNewThreadLocal:
         Test that unbinding from threadlocal works for keys that exist
         and does not raise error when they do not exist.
         """
-
         clear_threadlocal()
         bind_threadlocal(a=234, b=34)
 
-        assert {"a": 234, "b": 34} == merge_threadlocal_context(None, None, {})
+        assert {"a": 234, "b": 34} == get_threadlocal()
 
         unbind_threadlocal("a")
 
-        assert {"b": 34} == merge_threadlocal_context(None, None, {})
+        assert {"b": 34} == get_threadlocal()
 
         unbind_threadlocal("non-existing-key")
 
-        assert {"b": 34} == merge_threadlocal_context(None, None, {})
+        assert {"b": 34} == get_threadlocal()
 
     def test_get_context_no_context(self):
         """
@@ -351,4 +353,16 @@ class TestNewThreadLocal:
         with pytest.raises(AttributeError):
             _CONTEXT.context
 
-        assert {} == _get_context()
+        assert {} == get_threadlocal()
+
+    def test_get_merged(self):
+        """
+        Returns a copy of the threadlocal context merged with the logger's
+        context.
+        """
+        clear_threadlocal()
+        bind_threadlocal(x=1)
+
+        log = structlog.get_logger().bind(y=2)
+
+        assert {"x": 1, "y": 2} == get_merged_threadlocal(log)