Repository: django/asgiref (python)

Issue:
AttributeError: asgiref.local._CVar object has no attribute 'value'
Running the latest `main` (`abc69a054aa440ef42c939b5a197df05c3ad48d2`) in production to test a fix for https://github.com/django/asgiref/issues/467, I see this error somewhat frequently across a wide spread of our requests/endpoints. This error was frequent enough that I reverted back to the old version which errors in a different way less frequently like https://github.com/django/asgiref/issues/467.

Not really sure why `__delattr__` in `_CVar` needs to throw if we try to delete an attribute that doesn't exist. Does this indicate a programming/synchronization bug somewhere or is this just a bit over-restricting? Not sure if this is bug prevention or its just fine.

Happy to test any further improvements/fixes here to try and get a release which doesn't have errors. Can also provide any additional info that might be helpful here if need be.

Relevant versions:
```
    "ddtrace==2.9.2",
    "django==4.2.13",
```

Stack trace (all failures are basically the same):

```
AttributeError: <asgiref.local._CVar object at 0x7f05030333a0> object has no attribute 'value'
  File "django/core/handlers/asgi.py", line 160, in __call__
    await self.handle(scope, receive, send)
  File "django/core/handlers/asgi.py", line 190, in handle
    await self.send_response(response, send)
  File "django/core/handlers/asgi.py", line 296, in send_response
    await sync_to_async(response.close, thread_sensitive=True)()
  File "concurrent/futures/thread.py", line 58, in run
    result = self.fn(*self.args, **self.kwargs)
  File "ddtrace/contrib/futures/threading.py", line 36, in _wrap_execution
    return fn(*args, **kwargs)
  File "django/http/response.py", line 335, in close
    signals.request_finished.send(sender=self._handler_class)
  File "django/dispatch/dispatcher.py", line 176, in send
    return [
  File "django/dispatch/dispatcher.py", line 177, in <listcomp>
    (receiver, receiver(signal=self, sender=sender, **named))
  File "django/core/handlers/base.py", line 370, in reset_urlconf
    set_urlconf(None)
  File "django/urls/base.py", line 137, in set_urlconf
    del _urlconfs.value
  File "asgiref/local.py", line 129, in __delattr__
    delattr(storage, key)
  File "asgiref/local.py", line 38, in __delattr__
    raise AttributeError(f"{self!r} object has no attribute {key!r}")
```

Pull request description:
fix: prevent deletion of Local values from messing with other ctxs
https://github.com/django/asgiref/commit/85d2445021ff8e588fb2883a865d768b60bd0902#r161477713 changed the `ContextVar` to be a dict of `ContextVars`

While that does work in preventing the contexts to leak, removing a value with `del local.value` completely removes the `ContextVar`, literally unsetting it from all other contexts.

As we discussed [here](https://github.com/django/asgiref/issues/475#issuecomment-3043620092), using `.copy()` is simpler and should have no downsides compared to the current solution, especially since it is a shallow copy only

This PR reverts the mentioned change and instead uses a `.copy()` for both `__setattr__` and `__delattr__`. The added tests there still pass for this solution, and I also added a new test that would have caught this issue.

Fixes #522
Fixes #485

Patch:
diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index af34745..0157dde 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -15,6 +15,11 @@
 * Fixes a deadlock in CurrentThreadExecutor with nested async_to_sync →
   sync_to_async → async_to_sync → create_task calls. (#494)
 
+* The ApplicationCommunicator testing utility will now return the task result
+  if it's already completed on send_input and receive_nothing. You may need to
+  catch (e.g.) the asyncio.exceptions.CancelledError if sending messages to
+  already finished consumers in your tests. (#505)
+
 3.8.1 (2024-03-22)
 ------------------
 
diff --git a/asgiref/local.py b/asgiref/local.py
index 19b395f..845313a 100644
--- a/asgiref/local.py
+++ b/asgiref/local.py
@@ -2,38 +2,37 @@ import asyncio
 import contextlib
 import contextvars
 import threading
-from typing import Any, Union
+from typing import Any, Dict, Union
 
 
 class _CVar:
     """Storage utility for Local."""
 
     def __init__(self) -> None:
-        self._data: dict[str, contextvars.ContextVar[Any]] = {}
+        self._data: "contextvars.ContextVar[Dict[str, Any]]" = contextvars.ContextVar(
+            "asgiref.local"
+        )
 
-    def __getattr__(self, key: str) -> Any:
+    def __getattr__(self, key):
+        storage_object = self._data.get({})
         try:
-            var = self._data[key]
+            return storage_object[key]
         except KeyError:
             raise AttributeError(f"{self!r} object has no attribute {key!r}")
 
-        try:
-            return var.get()
-        except LookupError:
-            raise AttributeError(f"{self!r} object has no attribute {key!r}")
-
     def __setattr__(self, key: str, value: Any) -> None:
         if key == "_data":
             return super().__setattr__(key, value)
 
-        var = self._data.get(key)
-        if var is None:
-            self._data[key] = var = contextvars.ContextVar(key)
-        var.set(value)
+        storage_object = self._data.get({}).copy()
+        storage_object[key] = value
+        self._data.set(storage_object)
 
     def __delattr__(self, key: str) -> None:
-        if key in self._data:
-            del self._data[key]
+        storage_object = self._data.get({}).copy()
+        if key in storage_object:
+            del storage_object[key]
+            self._data.set(storage_object)
         else:
             raise AttributeError(f"{self!r} object has no attribute {key!r}")
 


Test patch:
diff --git a/tests/test_local.py b/tests/test_local.py
index cdcbd28..77e57e8 100644
--- a/tests/test_local.py
+++ b/tests/test_local.py
@@ -375,3 +375,19 @@ async def test_visibility_task() -> None:
 
     # Changes should not leak to the caller
     assert test_local.value == 0
+
+
+@pytest.mark.asyncio
+async def test_deletion() -> None:
+    """Check visibility with asyncio tasks."""
+    test_local = Local()
+    test_local.value = 123
+
+    async def _test() -> None:
+        # Local is inherited when changing task
+        assert test_local.value == 123
+        del test_local.value
+        assert not hasattr(test_local, "value")
+
+    await asyncio.create_task(_test())
+    assert test_local.value == 123