Repository: pallets/werkzeug
Pull request #2436 (2022-06-24 21:24:45)

improve use of `ContextVar` in `local`
After doing some more research on [`ContextVar`](https://docs.python.org/3/library/contextvars.html) recently, I identified some changes to make to Werkzeug's `local` module. The `contextvars` module is documented accurately but lacks a lot of good examples, and there are almost no blogs or other discussions about it.

---

Context vars should only be created at the global scope. If it's within a function, especially a class `__init__` method, it will cause reference cycles that cannot be garbage collected. For example, all the `test_local` tests created `Local` objects, which created `ContextVar` objects internally, and if I printed out `list(contextvars.copy_context().keys())` I got a huge list of objects.

`Local` and `LocalStack` only make sense defined globally (except in our own tests), so I think it's ok for them to continue creating a `ContextVar` internally. I wouldn't expect a local to be garbage collected anyway. I've added a `context_var` argument to both to allow passing in an externally created `ContextVar`.

---

`asyncio` uses `copy_context()` internally to pass a copy of the current context to child tasks. `asgiref` goes the other direction too and copies changes to that nested context back to the calling task when the child is finished. The copy is shallow, so if a context var had a mutable value, mutations to that value (rather than setting a new value) are _not_ context safe.

We already recognized this in the original refactor to use `ContextVar`, but I don't think we discussed exactly _why_ it was an issue before. I'm convinced that we should try to move away from `LocalStack` in Flask. Instead `RequestContext.push` should use `ContextVar.set`, store the token, then `RequestContext.pop` should do `ContextVar.reset(token)`. This makes it a single-linked list instead of a stack, and I think would remove the need for copying a stack if managed correctly. I'll have to look into this more.

I did remove some indirection by having `LocalStack` use a `ContextVar` directly instead of a `Local`. This also means that only the list needs to be copied instead of both the list and the `Local` dict.

---

Context vars are named, and share a single namespace, regardless of where or when they are created. Using the same name isn't an error, but makes it impossible to reference both when using the `Context` object returned by `contextvars.copy_context()`.

Therefore, the context vars created by `Local` and `LocalStack` should have unique names. I've used the pattern `werkzeug.ClassName<id(self)>.storage`.

I also replaced a `Local` used by the debugger console with two context vars, each with unique names indicating they're part of Werkzeug.

---

`LocalProxy` can now proxy a `ContextVar` or `LocalStack` directly. This makes working with `ContextVar` a lot easier. You still need to do `var.set(value)`, but never need to do `var.get(None)` to check if its bound. Instead, you just treat it like the object it will be set to. This is related to my discussion above about moving away from `LocalStack`.

```python
_user = ContextVar("user")
user = LocalProxy(_user)
_user.set(User(id))
```

`_get_current_object` is also replaced based on the type being proxied, so it does not require an `isinstance` check on every proxied call.

---

`LocalProxy` will use the `name` parameter for other objects besides `Local`, although it's only required for `Local`. It can also show a custom error message for unbound objects. This makes the pattern in Flask where we had to define a bunch of functions to access attributes on `RequestContext` unnecessary.

```python
_req_ctx_stack_var = ContextVar("flask.request_ctx_stack")
_req_ctx_stack = LocalStack(_req_ctx_stack_var)
_req_ctx = _req_ctx()  # no idea why Flask doesn't already do this instead of _stack.top everywhere

_no_req_msg = "This can only be used while handling a request."

request = _req_ctx_stack("request", unbound_message=_no_req_msg)
session = _req_ctx_stack("session", unbound_message=_no_req_msg)
```


Patch:
diff --git a/docs/local.rst b/docs/local.rst
index e4651a44..015b0e3e 100644
--- a/docs/local.rst
+++ b/docs/local.rst
@@ -1,77 +1,110 @@
-==============
 Context Locals
 ==============
 
 .. module:: werkzeug.local
 
-Sooner or later you have some things you want to have in every single view
-or helper function or whatever.  In PHP the way to go are global
-variables.  However, that isn't possible in WSGI applications without a
-major drawback:  As soon as you operate on the global namespace your
-application isn't thread-safe any longer.
+You may find that you have some data during each request that you want
+to use across functions. Instead of passing these as arguments between
+every function, you may want to access them as global data. However,
+using global variables in Python web applications is not thread safe;
+different workers might interfere with each others' data.
+
+Instead of storing common data during a request using global variables,
+you must use context-local variables instead. A context local is
+defined/imported globally, but the data it contains is specific to the
+current thread, asyncio task, or greenlet. You won't accidentally get
+or overwrite another worker's data.
+
+The current approach for storing per-context data in Python is the
+:class:`contextvars` module. Context vars store data per thread, async
+task, or greenlet. This replaces the older :class:`threading.local`
+which only handled threads.
 
-The Python standard library has a concept called "thread locals" (or thread-local
-data). A thread local is a global object in which you can put stuff in and get back
-later in a thread-safe and thread-specific way.  That means that whenever you set
-or get a value on a thread local object, the thread local object checks in which
-thread you are and retrieves the value corresponding to your thread (if one exists).
-So, you won't accidentally get another thread's data.
+Werkzeug provides wrappers around :class:`~contextvars.ContextVar` to
+make it easier to work with.
 
-This approach, however, has a few disadvantages.  For example, besides threads,
-there are other types of concurrency in Python.  A very popular one
-is greenlets.  Also, whether every request gets its own thread is not
-guaranteed in WSGI.  It could be that a request is reusing a thread from
-a previous request, and hence data is left over in the thread local object.
 
-Werkzeug provides its own implementation of local data storage called `werkzeug.local`.
-This approach provides a similar functionality to thread locals but also works with
-greenlets.
+Proxy Objects
+=============
 
-Here's a simple example of how one could use werkzeug.local::
+:class:`LocalProxy` allows treating a context var as an object directly
+instead of needing to use and check
+:meth:`ContextVar.get() <contextvars.ContextVar.get>`. If the context
+var is set, the local proxy will look and behave like the object the var
+is set to. If it's not set, a ``RuntimeError`` is raised for most
+operations.
 
-    from werkzeug.local import Local, LocalManager
+.. code-block:: python
 
-    local = Local()
-    local_manager = LocalManager([local])
+    from contextvars import ContextVar
+    from werkzeug.local import LocalProxy
 
-    def application(environ, start_response):
-        local.request = request = Request(environ)
+    _request_var = ContextVar("request")
+    request = LocalProxy(_request_var)
+
+    from werkzeug.wrappers import Request
+
+    @Request.application
+    def app(r):
+        _request_var.set(r)
+        check_auth()
         ...
 
-    application = local_manager.make_middleware(application)
+    from werkzeug.exceptions import Unauthorized
 
-This binds the request to `local.request`.  Every other piece of code executed
-after this assignment in the same context can safely access local.request and
-will get the same request object.  The `make_middleware` method on the local
-manager ensures that all references to the local objects are cleared up after
-the request.
+    def check_auth():
+        if request.form["username"] != "admin":
+            raise Unauthorized()
 
-The same context means the same greenlet (if you're using greenlets) in
-the same thread and same process.
+Accessing ``request`` will point to the specific request that each
+server worker is handling. You can treat ``request`` just like an actual
+``Request`` object.
 
-If a request object is not yet set on the local object and you try to
-access it, you will get an `AttributeError`.  You can use `getattr` to avoid
-that::
+``bool(proxy)`` will always return ``False`` if the var is not set. If
+you need access to the object directly instead of the proxy, you can get
+it with the :meth:`~LocalProxy._get_current_object` method.
 
-    def get_request():
-        return getattr(local, 'request', None)
+.. autoclass:: LocalProxy
+    :members: _get_current_object
 
-This will try to get the request or return `None` if the request is not
-(yet?) available.
 
-Note that local objects cannot manage themselves, for that you need a local
-manager.  You can pass a local manager multiple locals or add additionals
-later by appending them to `manager.locals` and every time the manager
-cleans up it will clean up all the data left in the locals for this
-context.
+Stacks and Namespaces
+=====================
 
-.. autofunction:: release_local
+:class:`~contextvars.ContextVar` stores one value at a time. You may
+find that you need to store a stack of items, or a namespace with
+multiple attributes. A list or dict can be used for these, but using
+them as context var values requires some extra care. Werkzeug provides
+:class:`LocalStack` which wraps a list, and :class:`Local` which wraps a
+dict.
 
-.. autoclass:: LocalManager
-   :members: cleanup, make_middleware, middleware
+There is some amount of performance penalty associated with these
+objects. Because lists and dicts are mutable, :class:`LocalStack` and
+:class:`Local` need to do extra work to ensure data isn't shared between
+nested contexts. If possible, design your application to use
+:class:`LocalProxy` around a context var directly.
 
 .. autoclass:: LocalStack
-   :members: push, pop, top
+    :members: push, pop, top, __call__
 
-.. autoclass:: LocalProxy
-    :members: _get_current_object
+.. autoclass:: Local
+    :members: __call__
+
+
+Releasing Data
+==============
+
+A previous implementation of ``Local`` used internal data structures
+which could not be cleaned up automatically when each context ended.
+Instead, the following utilities could be used to release the data.
+
+.. warning::
+
+    This should not be needed with the modern implementation, as the
+    data in context vars is automatically managed by Python. It is kept
+    for compatibility for now, but may be removed in the future.
+
+.. autoclass:: LocalManager
+   :members: cleanup, make_middleware, middleware
+
+.. autofunction:: release_local
diff --git a/src/werkzeug/debug/console.py b/src/werkzeug/debug/console.py
index f4a0e26a..2825fe02 100644
--- a/src/werkzeug/debug/console.py
+++ b/src/werkzeug/debug/console.py
@@ -1,10 +1,10 @@
 import code
 import sys
 import typing as t
+from contextvars import ContextVar
 from html import escape
 from types import CodeType
 
-from ..local import Local
 from .repr import debug_repr
 from .repr import dump
 from .repr import helper
@@ -12,7 +12,8 @@ from .repr import helper
 if t.TYPE_CHECKING:
     import codeop  # noqa: F401
 
-_local = Local()
+_stream: ContextVar["HTMLStringO"] = ContextVar("werkzeug.debug.console.stream")
+_ipy: ContextVar = ContextVar("werkzeug.debug.console.ipy")
 
 
 class HTMLStringO:
@@ -64,26 +65,29 @@ class ThreadedStream:
     def push() -> None:
         if not isinstance(sys.stdout, ThreadedStream):
             sys.stdout = t.cast(t.TextIO, ThreadedStream())
-        _local.stream = HTMLStringO()
+
+        _stream.set(HTMLStringO())
 
     @staticmethod
     def fetch() -> str:
         try:
-            stream = _local.stream
-        except AttributeError:
+            stream = _stream.get()
+        except LookupError:
             return ""
-        return stream.reset()  # type: ignore
+
+        return stream.reset()
 
     @staticmethod
     def displayhook(obj: object) -> None:
         try:
-            stream = _local.stream
-        except AttributeError:
+            stream = _stream.get()
+        except LookupError:
             return _displayhook(obj)  # type: ignore
+
         # stream._write bypasses escaping as debug_repr is
         # already generating HTML for us.
         if obj is not None:
-            _local._current_ipy.locals["_"] = obj
+            _ipy.get().locals["_"] = obj
             stream._write(debug_repr(obj))
 
     def __setattr__(self, name: str, value: t.Any) -> None:
@@ -94,9 +98,10 @@ class ThreadedStream:
 
     def __getattribute__(self, name: str) -> t.Any:
         try:
-            stream = _local.stream
-        except AttributeError:
-            stream = sys.__stdout__
+            stream = _stream.get()
+        except LookupError:
+            stream = sys.__stdout__  # type: ignore[assignment]
+
         return getattr(stream, name)
 
     def __repr__(self) -> str:
@@ -208,7 +213,7 @@ class Console:
         self._ipy = _InteractiveConsole(globals, locals)
 
     def eval(self, code: str) -> str:
-        _local._current_ipy = self._ipy
+        _ipy.set(self._ipy)
         old_sys_stdout = sys.stdout
         try:
             return self._ipy.runsource(code)
diff --git a/src/werkzeug/local.py b/src/werkzeug/local.py
index e297f383..16e3ce0d 100644
--- a/src/werkzeug/local.py
+++ b/src/werkzeug/local.py
@@ -5,6 +5,7 @@ import typing as t
 from contextvars import ContextVar
 from functools import partial
 from functools import update_wrapper
+from operator import attrgetter
 
 from .wsgi import ClosingIterator
 
@@ -13,26 +14,16 @@ if t.TYPE_CHECKING:
     from _typeshed.wsgi import WSGIApplication
     from _typeshed.wsgi import WSGIEnvironment
 
+T = t.TypeVar("T")
 F = t.TypeVar("F", bound=t.Callable[..., t.Any])
 
 
 def release_local(local: t.Union["Local", "LocalStack"]) -> None:
-    """Releases the contents of the local for the current context.
-    This makes it possible to use locals without a manager.
+    """Release the data for the current context in a :class:`Local` or
+    :class:`LocalStack` without using a :class:`LocalManager`.
 
-    Example::
-
-        >>> loc = Local()
-        >>> loc.foo = 42
-        >>> release_local(loc)
-        >>> hasattr(loc, 'foo')
-        False
-
-    With this function one can release :class:`Local` objects as well
-    as :class:`LocalStack` objects.  However it is not possible to
-    release data held by proxies that way, one always has to retain
-    a reference to the underlying local object in order to be able
-    to release it.
+    This should not be needed for modern use cases, and may be removed
+    in the future.
 
     .. versionadded:: 0.6.1
     """
@@ -40,154 +31,204 @@ def release_local(local: t.Union["Local", "LocalStack"]) -> None:
 
 
 class Local:
-    __slots__ = ("_storage",)
+    """Create a namespace of context-local data. This wraps a
+    :class:`ContextVar` containing a :class:`dict` value.
+
+    This may incur a performance penalty compared to using individual
+    context vars, as it has to copy data to avoid mutating the dict
+    between nested contexts.
+
+    :param context_var: The :class:`~contextvars.ContextVar` to use as
+        storage for this local. If not given, one will be created.
+        Context vars not created at the global scope may interfere with
+        garbage collection.
 
-    def __init__(self) -> None:
-        object.__setattr__(self, "_storage", ContextVar("local_storage"))
+    .. versionchanged:: 2.0
+        Uses ``ContextVar`` instead of a custom storage implementation.
+    """
 
-    def __iter__(self) -> t.Iterator[t.Tuple[int, t.Any]]:
-        return iter(self._storage.get({}).items())
+    __slots__ = ("__storage",)
 
-    def __call__(self, proxy: str) -> "LocalProxy":
-        """Create a proxy for a name."""
-        return LocalProxy(self, proxy)
+    def __init__(
+        self, context_var: t.Optional[ContextVar[t.Dict[str, t.Any]]] = None
+    ) -> None:
+        if context_var is None:
+            # A ContextVar not created at global scope interferes with
+            # Python's garbage collection. However, a local only makes
+            # sense defined at the global scope as well, in which case
+            # the GC issue doesn't seem relevant.
+            context_var = ContextVar(f"werkzeug.Local<{id(self)}>.storage")
+
+        object.__setattr__(self, "_Local__storage", context_var)
+
+    def __iter__(self) -> t.Iterator[t.Tuple[str, t.Any]]:
+        return iter(self.__storage.get({}).items())
+
+    def __call__(
+        self, name: str, *, unbound_message: t.Optional[str] = None
+    ) -> "LocalProxy":
+        """Create a :class:`LocalProxy` that access an attribute on this
+        local namespace.
+
+        :param name: Proxy this attribute.
+        :param unbound_message: The error message that the proxy will
+            show if the attribute isn't set.
+        """
+        return LocalProxy(self, name, unbound_message=unbound_message)
 
     def __release_local__(self) -> None:
-        self._storage.set({})
+        self.__storage.set({})
 
     def __getattr__(self, name: str) -> t.Any:
-        values = self._storage.get({})
-        try:
+        values = self.__storage.get({})
+
+        if name in values:
             return values[name]
-        except KeyError:
-            raise AttributeError(name) from None
+
+        raise AttributeError(name)
 
     def __setattr__(self, name: str, value: t.Any) -> None:
-        values = self._storage.get({}).copy()
+        values = self.__storage.get({}).copy()
         values[name] = value
-        self._storage.set(values)
+        self.__storage.set(values)
 
     def __delattr__(self, name: str) -> None:
-        values = self._storage.get({}).copy()
-        try:
+        values = self.__storage.get({})
+
+        if name in values:
+            values = values.copy()
             del values[name]
-            self._storage.set(values)
-        except KeyError:
-            raise AttributeError(name) from None
-
-
-class LocalStack:
-    """This class works similar to a :class:`Local` but keeps a stack
-    of objects instead.  This is best explained with an example::
-
-        >>> ls = LocalStack()
-        >>> ls.push(42)
-        >>> ls.top
-        42
-        >>> ls.push(23)
-        >>> ls.top
-        23
-        >>> ls.pop()
-        23
-        >>> ls.top
-        42
-
-    They can be force released by using a :class:`LocalManager` or with
-    the :func:`release_local` function but the correct way is to pop the
-    item from the stack after using.  When the stack is empty it will
-    no longer be bound to the current context (and as such released).
-
-    By calling the stack without arguments it returns a proxy that resolves to
-    the topmost item on the stack.
+            self.__storage.set(values)
+        else:
+            raise AttributeError(name)
+
+
+class LocalStack(t.Generic[T]):
+    """Create a stack of context-local data. This wraps a
+    :class:`ContextVar` containing a :class:`list` value.
+
+    This may incur a performance penalty compared to using individual
+    context vars, as it has to copy data to avoid mutating the list
+    between nested contexts.
+
+    :param context_var: The :class:`~contextvars.ContextVar` to use as
+        storage for this local. If not given, one will be created.
+        Context vars not created at the global scope may interfere with
+        garbage collection.
+
+    .. versionchanged:: 2.0
+        Uses ``ContextVar`` instead of a custom storage implementation.
 
     .. versionadded:: 0.6.1
     """
 
-    def __init__(self) -> None:
-        self._local = Local()
+    __slots__ = ("_storage",)
 
-    def __release_local__(self) -> None:
-        self._local.__release_local__()
-
-    def __call__(self) -> "LocalProxy":
-        def _lookup() -> t.Any:
-            rv = self.top
-            if rv is None:
-                raise RuntimeError("object unbound")
-            return rv
-
-        return LocalProxy(_lookup)
-
-    def push(self, obj: t.Any) -> t.List[t.Any]:
-        """Pushes a new item to the stack"""
-        rv = getattr(self._local, "stack", []).copy()
-        rv.append(obj)
-        self._local.stack = rv
-        return rv
+    def __init__(self, context_var: t.Optional[ContextVar[t.List[T]]] = None) -> None:
+        if context_var is None:
+            # A ContextVar not created at global scope interferes with
+            # Python's garbage collection. However, a local only makes
+            # sense defined at the global scope as well, in which case
+            # the GC issue doesn't seem relevant.
+            context_var = ContextVar(f"werkzeug.LocalStack<{id(self)}>.storage")
 
-    def pop(self) -> t.Any:
-        """Removes the topmost item from the stack, will return the
-        old value or `None` if the stack was already empty.
+        self._storage = context_var
+
+    def __release_local__(self) -> None:
+        self._storage.set([])
+
+    def push(self, obj: T) -> t.List[T]:
+        """Add a new item to the top of the stack."""
+        stack = self._storage.get([]).copy()
+        stack.append(obj)
+        self._storage.set(stack)
+        return stack
+
+    def pop(self) -> t.Optional[T]:
+        """Remove the top item from the stack and return it. If the
+        stack is empty, return ``None``.
         """
-        stack = getattr(self._local, "stack", None)
-        if stack is None:
+        stack = self._storage.get([])
+
+        if len(stack) == 0:
             return None
-        elif len(stack) == 1:
-            release_local(self._local)
-            return stack[-1]
-        else:
-            return stack.pop()
+
+        rv = stack[-1]
+        self._storage.set(stack[:-1])
+        return rv
 
     @property
-    def top(self) -> t.Any:
+    def top(self) -> t.Optional[T]:
         """The topmost item on the stack.  If the stack is empty,
         `None` is returned.
         """
-        try:
-            return self._local.stack[-1]
-        except (AttributeError, IndexError):
+        stack = self._storage.get([])
+
+        if len(stack) == 0:
             return None
 
+        return stack[-1]
+
+    def __call__(
+        self, name: t.Optional[str] = None, *, unbound_message: t.Optional[str] = None
+    ) -> "LocalProxy":
+        """Create a :class:`LocalProxy` that accesses the top of this
+        local stack.
+
+        :param name: If given, the proxy access this attribute of the
+            top item, rather than the item itself.
+        :param unbound_message: The error message that the proxy will
+            show if the stack is empty.
+        """
+        return LocalProxy(self, name, unbound_message=unbound_message)
+
 
 class LocalManager:
-    """Local objects cannot manage themselves. For that you need a local
-    manager. You can pass a local manager multiple locals or add them
-    later by appending them to `manager.locals`. Every time the manager
-    cleans up, it will clean up all the data left in the locals for this
-    context.
+    """Manage releasing the data for the current context in one or more
+    :class:`Local` and :class:`LocalStack` objects.
+
+    This should not be needed for modern use cases, and may be removed
+    in the future.
+
+    :param locals: A local or list of locals to manage.
 
     .. versionchanged:: 2.0
         ``ident_func`` is deprecated and will be removed in Werkzeug
          2.1.
 
+    .. versionchanged:: 0.7
+        The ``ident_func`` parameter was added.
+
     .. versionchanged:: 0.6.1
         The :func:`release_local` function can be used instead of a
         manager.
-
-    .. versionchanged:: 0.7
-        The ``ident_func`` parameter was added.
     """
 
+    __slots__ = ("locals",)
+
     def __init__(
-        self, locals: t.Optional[t.Iterable[t.Union[Local, LocalStack]]] = None
+        self,
+        locals: t.Optional[
+            t.Union[Local, LocalStack, t.Iterable[t.Union[Local, LocalStack]]]
+        ] = None,
     ) -> None:
         if locals is None:
             self.locals = []
         elif isinstance(locals, Local):
             self.locals = [locals]
         else:
-            self.locals = list(locals)
+            self.locals = list(locals)  # type: ignore[arg-type]
 
     def cleanup(self) -> None:
-        """Manually clean up the data in the locals for this context.  Call
-        this at the end of the request or use `make_middleware()`.
+        """Release the data in the locals for this context. Call this at
+        the end of each request or use :meth:`make_middleware`.
         """
         for local in self.locals:
             release_local(local)
 
     def make_middleware(self, app: "WSGIApplication") -> "WSGIApplication":
-        """Wrap a WSGI application so that cleaning up happens after
-        request end.
+        """Wrap a WSGI application so that local data is released
+        automatically after the response has been sent for a request.
         """
 
         def application(
@@ -198,17 +239,14 @@ class LocalManager:
         return application
 
     def middleware(self, func: "WSGIApplication") -> "WSGIApplication":
-        """Like `make_middleware` but for decorating functions.
+        """Like :meth:`make_middleware` but used as a decorator on the
+        WSGI application function.
 
-        Example usage::
+        .. code-block:: python
 
             @manager.middleware
             def application(environ, start_response):
                 ...
-
-        The difference to `make_middleware` is that the function passed
-        will have all the arguments copied from the inner application
-        (name, docstring, module).
         """
         return update_wrapper(self.make_middleware(func), func)
 
@@ -275,7 +313,7 @@ class _ProxyLookup:
             return self
 
         try:
-            obj = instance._get_current_object()
+            obj = instance._get_current_object()  # type: ignore[misc]
         except RuntimeError:
             if self.fallback is None:
                 raise
@@ -337,29 +375,56 @@ def _l_to_r_op(op: F) -> F:
     return t.cast(F, r_op)
 
 
-class LocalProxy:
-    """A proxy to the object bound to a :class:`Local`. All operations
-    on the proxy are forwarded to the bound object. If no object is
-    bound, a :exc:`RuntimeError` is raised.
+def _identity(o: T) -> T:
+    return o
+
+
+class LocalProxy(t.Generic[T]):
+    """A proxy to the object bound to a context-local object. All
+    operations on the proxy are forwarded to the bound object. If no
+    object is bound, a ``RuntimeError`` is raised.
+
+    :param local: The context-local object that provides the proxied
+        object.
+    :param name: Proxy this attribute from the proxied object.
+    :param unbound_message: The error message to show if the
+        context-local object is unbound.
+
+    Proxy a :class:`~contextvars.ContextVar` to make it easier to
+    access. Pass a name to proxy that attribute.
+
+    .. code-block:: python
+
+        _request_var = ContextVar("request")
+        request = LocalProxy(_request_var)
+        session = LocalProxy(_request_var, "session")
+
+    Proxy an attribute on a :class:`Local` namespace by calling the
+    local with the attribute name:
 
     .. code-block:: python
 
-        from werkzeug.local import Local
-        l = Local()
+        data = Local()
+        user = data("user")
+
+    Proxy the top item on a :class:`LocalStack` by calling the local.
+    Pass a name to proxy that attribute.
 
-        # a proxy to whatever l.user is set to
-        user = l("user")
+    .. code-block::
 
-        from werkzeug.local import LocalStack
-        _request_stack = LocalStack()
+        app_stack = LocalStack()
+        current_app = app_stack()
+        g = app_stack("g")
 
-        # a proxy to _request_stack.top
-        request = _request_stack()
+    Pass a function to proxy the return value from that function. This
+    was previously used to access attributes of local objects before
+    that was supported directly.
+
+    .. code-block:: python
 
-        # a proxy to the session attribute of the request proxy
         session = LocalProxy(lambda: request.session)
 
-    ``__repr__`` and ``__class__`` are forwarded, so ``repr(x)`` and
+    ``__repr__`` and ``__class__`` are proxied, so ``repr(x)`` and
     ``isinstance(x, cls)`` will look like the proxied object. Use
     ``issubclass(type(x), LocalProxy)`` to check if an object is a
     proxy.
@@ -370,10 +435,15 @@ class LocalProxy:
         isinstance(user, User)  # True
         issubclass(type(user), LocalProxy)  # True
 
-    :param local: The :class:`Local` or callable that provides the
-        proxied object.
-    :param name: The attribute name to look up on a :class:`Local`. Not
-        used if a callable is given.
+    .. versionchanged:: 2.2
+        Can proxy a ``ContextVar`` or ``LocalStack`` directly.
+
+    .. versionchanged:: 2.2
+        The ``name`` parameter can be used with any proxied object, not
+        only ``Local``.
+
+    .. versionchanged:: 2.2
+        Added the ``unbound_message`` parameter.
 
     .. versionchanged:: 2.0
         Updated proxied attributes and methods to reflect the current
@@ -383,34 +453,74 @@ class LocalProxy:
         The class can be instantiated with a callable.
     """
 
-    __slots__ = ("__local", "__name", "__wrapped__")
+    __slots__ = ("__wrapped__", "_get_current_object")
+
+    _get_current_object: t.Callable[[], T]
+    """Return the current object this proxy is bound to. If the proxy is
+    unbound, this raises a ``RuntimeError``.
+
+    This should be used if you need to pass the object to something that
+    doesn't understand the proxy. It can also be useful for performance
+    if you are accessing the object multiple times in a function, rather
+    than going through the proxy multiple times.
+    """
 
     def __init__(
         self,
-        local: t.Union["Local", t.Callable[[], t.Any]],
+        local: t.Union[ContextVar[T], Local, LocalStack[T], t.Callable[[], T]],
         name: t.Optional[str] = None,
+        *,
+        unbound_message: t.Optional[str] = None,
     ) -> None:
-        object.__setattr__(self, "_LocalProxy__local", local)
-        object.__setattr__(self, "_LocalProxy__name", name)
+        if name is None:
+            get_name = _identity
+        else:
+            get_name = attrgetter(name)  # type: ignore[assignment]
+
+        if unbound_message is None:
+            unbound_message = "object is not bound"
+
+        if isinstance(local, Local):
+            if name is None:
+                raise TypeError("'name' is required when proxying a 'Local' object.")
+
+            def _get_current_object() -> T:
+                try:
+                    return get_name(local)  # type: ignore[return-value]
+                except AttributeError:
+                    raise RuntimeError(unbound_message) from None
+
+        elif isinstance(local, LocalStack):
+
+            def _get_current_object() -> T:
+                obj = local.top  # type: ignore[union-attr]
+
+                if obj is None:
+                    raise RuntimeError(unbound_message)
+
+                return get_name(obj)
+
+        elif isinstance(local, ContextVar):
+
+            def _get_current_object() -> T:
+                try:
+                    obj = local.get()  # type: ignore[union-attr]
+                except LookupError:
+                    raise RuntimeError(unbound_message) from None
+
+                return get_name(obj)
+
+        elif callable(local):
+
+            def _get_current_object() -> T:
+                return get_name(local())  # type: ignore
 
-        if callable(local) and not hasattr(local, "__release_local__"):
-            # "local" is a callable that is not an instance of Local or
-            # LocalManager: mark it as a wrapped function.
             object.__setattr__(self, "__wrapped__", local)
 
-    def _get_current_object(self) -> t.Any:
-        """Return the current object.  This is useful if you want the real
-        object behind the proxy at a time for performance reasons or because
-        you want to pass the object into a different context.
-        """
-        if not hasattr(self.__local, "__release_local__"):  # type: ignore
-            return self.__local()  # type: ignore
+        else:
+            raise TypeError(f"Don't know how to proxy '{type(local)}'.")
 
-        try:
-            return getattr(self.__local, self.__name)  # type: ignore
-        except AttributeError:
-            name = self.__name  # type: ignore
-            raise RuntimeError(f"no object bound to {name}") from None
+        object.__setattr__(self, "_get_current_object", _get_current_object)
 
     __doc__ = _ProxyLookup(  # type: ignore
         class_value=__doc__, fallback=lambda self: type(self).__doc__, is_attr=True


Test patch:
diff --git a/tests/test_local.py b/tests/test_local.py
index 7402876d..ba66f566 100644
--- a/tests/test_local.py
+++ b/tests/test_local.py
@@ -3,6 +3,7 @@ import copy
 import math
 import operator
 import time
+from contextvars import ContextVar
 from functools import partial
 from threading import Thread
 
@@ -10,9 +11,23 @@ import pytest
 
 from werkzeug import local
 
+# Since the tests are creating local instances, use global context vars
+# to avoid accumulating anonymous context vars that can't be collected.
+_cv_ns = ContextVar("werkzeug.tests.ns")
+_cv_stack = ContextVar("werkzeug.tests.stack")
+
+
+@pytest.fixture(autouse=True)
+def reset_context_vars():
+    ns_token = _cv_ns.set({})
+    stack_token = _cv_stack.set([])
+    yield
+    _cv_ns.reset(ns_token)
+    _cv_stack.reset(stack_token)
+
 
 def test_basic_local():
-    ns = local.Local()
+    ns = local.Local(_cv_ns)
     ns.foo = 0
     values = []
 
@@ -40,7 +55,7 @@ def test_basic_local():
 
 
 def test_basic_local_asyncio():
-    ns = local.Local()
+    ns = local.Local(_cv_ns)
     ns.foo = 0
     values = []
 
@@ -68,19 +83,19 @@ def test_basic_local_asyncio():
 
 
 def test_local_release():
-    ns = local.Local()
+    ns = local.Local(_cv_ns)
     ns.foo = 42
     local.release_local(ns)
     assert not hasattr(ns, "foo")
 
-    ls = local.LocalStack()
+    ls = local.LocalStack(_cv_stack)
     ls.push(42)
     local.release_local(ls)
     assert ls.top is None
 
 
 def test_local_stack():
-    ls = local.LocalStack()
+    ls = local.LocalStack(_cv_stack)
     assert ls.top is None
     ls.push(42)
     assert ls.top == 42
@@ -104,12 +119,12 @@ def test_local_stack():
 
 
 def test_local_stack_asyncio():
-    ls = local.LocalStack()
+    ls = local.LocalStack(_cv_stack)
     ls.push(1)
 
     async def task():
         ls.push(1)
-        assert len(ls._local.stack) == 2
+        assert len(ls._storage.get()) == 2
 
     async def main():
         futures = [asyncio.ensure_future(task()) for _ in range(3)]
@@ -119,7 +134,7 @@ def test_local_stack_asyncio():
 
 
 def test_proxy_local():
-    ns = local.Local()
+    ns = local.Local(_cv_ns)
     ns.foo = []
     p = local.LocalProxy(ns, "foo")
     p.append(42)
@@ -160,7 +175,7 @@ def test_proxy_wrapped():
     partial_proxy = local.LocalProxy(partial_lookup_func)
     assert partial_proxy.__wrapped__ == partial_lookup_func
 
-    ns = local.Local()
+    ns = local.Local(_cv_ns)
     ns.foo = SomeClassWithWrapped()
     ns.bar = 42
 
@@ -178,7 +193,7 @@ def test_proxy_doc():
 
 
 def test_proxy_fallback():
-    local_stack = local.LocalStack()
+    local_stack = local.LocalStack(_cv_stack)
     local_proxy = local_stack()
 
     assert repr(local_proxy) == "<LocalProxy unbound>"
@@ -195,7 +210,7 @@ def test_proxy_fallback():
 
 
 def test_proxy_unbound():
-    ns = local.Local()
+    ns = local.Local(_cv_ns)
     p = ns("value")
     assert repr(p) == "<LocalProxy unbound>"
     assert not p
@@ -203,7 +218,7 @@ def test_proxy_unbound():
 
 
 def _make_proxy(value):
-    ns = local.Local()
+    ns = local.Local(_cv_ns)
     ns.value = value
     p = ns("value")
     return ns, p