Repository: pallets/werkzeug (python)

Issue:
LocalProxy breaking isinstance() for abc.ABC
When tested with isinstance() against an ABC, LocalProxy instances raise an exception rather than returning False.

```
import abc
from werkzeug import local

ls=local.LocalStack()
x=ls()

class C(abc.ABC):
	pass

isinstance(x, C)

```
```
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/lib/python3.9/abc.py", line 119, in __instancecheck__
    return _abc_instancecheck(cls, instance)
  File "/usr/lib/python3.9/abc.py", line 123, in __subclasscheck__
    return _abc_subclasscheck(cls, subclass)
TypeError: issubclass() arg 1 must be a class
```

The call to isinstance() should have returned False rather than an exception.

It seems to be related to the fact that `x.__class__` is not a class as expected, but an unbound method. Calling it with `x.__class__()` returns the expected `werkzeug.local.LocalProxy` class. While it's not documented in Python except for builtin types, most of the ecosystem will assume `type(x) is x.__class__`, including it appears the standard library.

Environment:

- Python version: Python 3.9.6
- Werkzeug version: 2.0.1


Pull request:
fix proxy `__class__` and `__doc__` fallback
`__class__` and `__doc__` are both special attributes, not methods, so the fallback for an unbound proxy should evaluate the function rather than return it.

fixes #2188

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index fd0f7163..6bbb2e0b 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -11,6 +11,9 @@ Unreleased
     ``Request`` in addition to ``WSGIEnvironment`` for the first
     parameter. :pr:`2290`
 -   Fix type annotation for ``Request.user_agent_class``. :issue:`2273`
+-   Accessing ``LocalProxy.__class__`` and ``__doc__`` on an unbound
+    proxy returns the fallback value instead of a method object.
+    :issue:`2188`
 
 
 Version 2.0.2
diff --git a/src/werkzeug/local.py b/src/werkzeug/local.py
index d2c99af5..2b222274 100644
--- a/src/werkzeug/local.py
+++ b/src/werkzeug/local.py
@@ -381,19 +381,23 @@ class _ProxyLookup:
     :param f: The built-in function this attribute is accessed through.
         Instead of looking up the special method, the function call
         is redone on the object.
-    :param fallback: Call this method if the proxy is unbound instead of
-        raising a :exc:`RuntimeError`.
-    :param class_value: Value to return when accessed from the class.
-        Used for ``__doc__`` so building docs still works.
+    :param fallback: Return this function if the proxy is unbound
+        instead of raising a :exc:`RuntimeError`.
+    :param is_attr: This proxied name is an attribute, not a function.
+        Call the fallback immediately to get the value.
+    :param class_value: Value to return when accessed from the
+        ``LocalProxy`` class directly. Used for ``__doc__`` so building
+        docs still works.
     """
 
-    __slots__ = ("bind_f", "fallback", "class_value", "name")
+    __slots__ = ("bind_f", "fallback", "is_attr", "class_value", "name")
 
     def __init__(
         self,
         f: t.Optional[t.Callable] = None,
         fallback: t.Optional[t.Callable] = None,
         class_value: t.Optional[t.Any] = None,
+        is_attr: bool = False,
     ) -> None:
         bind_f: t.Optional[t.Callable[["LocalProxy", t.Any], t.Callable]]
 
@@ -416,6 +420,7 @@ class _ProxyLookup:
         self.bind_f = bind_f
         self.fallback = fallback
         self.class_value = class_value
+        self.is_attr = is_attr
 
     def __set_name__(self, owner: "LocalProxy", name: str) -> None:
         self.name = name
@@ -433,7 +438,14 @@ class _ProxyLookup:
             if self.fallback is None:
                 raise
 
-            return self.fallback.__get__(instance, owner)  # type: ignore
+            fallback = self.fallback.__get__(instance, owner)  # type: ignore
+
+            if self.is_attr:
+                # __class__ and __doc__ are attributes, not methods.
+                # Call the fallback to get the value.
+                return fallback()
+
+            return fallback
 
         if self.bind_f is not None:
             return self.bind_f(instance, obj)
@@ -559,7 +571,7 @@ class LocalProxy:
             raise RuntimeError(f"no object bound to {name}") from None
 
     __doc__ = _ProxyLookup(  # type: ignore
-        class_value=__doc__, fallback=lambda self: type(self).__doc__
+        class_value=__doc__, fallback=lambda self: type(self).__doc__, is_attr=True
     )
     # __del__ should only delete the proxy
     __repr__ = _ProxyLookup(  # type: ignore
@@ -591,7 +603,9 @@ class LocalProxy:
     # __weakref__ (__getattr__)
     # __init_subclass__ (proxying metaclass not supported)
     # __prepare__ (metaclass)
-    __class__ = _ProxyLookup(fallback=lambda self: type(self))  # type: ignore
+    __class__ = _ProxyLookup(
+        fallback=lambda self: type(self), is_attr=True
+    )  # type: ignore
     __instancecheck__ = _ProxyLookup(lambda self, other: isinstance(other, self))
     __subclasscheck__ = _ProxyLookup(lambda self, other: issubclass(other, self))
     # __class_getitem__ triggered through __getitem__


Test patch:
diff --git a/tests/test_local.py b/tests/test_local.py
index d434a840..dbab4e75 100644
--- a/tests/test_local.py
+++ b/tests/test_local.py
@@ -225,13 +225,20 @@ def test_proxy_doc():
 
 
 def test_proxy_fallback():
-    def _raises():
-        raise RuntimeError()
+    local_stack = local.LocalStack()
+    local_proxy = local_stack()
 
-    local_proxy = local.LocalProxy(_raises)
     assert repr(local_proxy) == "<LocalProxy unbound>"
     assert isinstance(local_proxy, local.LocalProxy)
-    assert not isinstance(local_proxy, Thread)
+    assert local_proxy.__class__ is local.LocalProxy
+    assert "LocalProxy" in local_proxy.__doc__
+
+    local_stack.push(42)
+
+    assert repr(local_proxy) == "42"
+    assert isinstance(local_proxy, int)
+    assert local_proxy.__class__ is int
+    assert "int(" in local_proxy.__doc__
 
 
 def test_proxy_unbound():