Repository: django/asgiref

Issue:
Clarify `raw_path` Definition for WebSockets Protocol in the Specification
The definition of `raw_path` in the ASGI specification differs slightly between HTTP and WebSockets protocols.

- **HTTP**: The specification explicitly states that the query string is excluded from `raw_path`.

https://github.com/django/asgiref/blob/b7aaa795fbbea7ca88458279cf8302533d249101/specs/www.rst?#L107-L110

- **WebSockets**: The specification does not mention whether the query string should be included or excluded from `raw_path`.

https://github.com/django/asgiref/blob/b7aaa795fbbea7ca88458279cf8302533d249101/specs/www.rst?#L318-L321

To ensure clarity and consistency, it would be beneficial to explicitly define the behavior of `raw_path` for WebSockets:

1. **If the query string should be excluded**: The specification should use the same wording as in the HTTP section to avoid ambiguity.
2. **If the query string should be included**: This should be explicitly stated, along with any rationale for the differing behavior.

Given the potential for confusion, it would be helpful to clarify this in the specification.

Discussion:
@Kludex: Yeah, having this difference looks a bit weird. Do you remember if it was intentional? @andrewgodwin @andrewgodwin: I do not believe that was intentional - my guess is that we specifically clarified it for standard HTTP but not for WebSocket. I would say we should update the WebSocket side to say that it also excludes the query string.@Kludex: @raptium Would you like to send a PR here?@Kludex: We can close this @andrewgodwin 🙏 

Solution patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 72bb43d1..7b766211 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -14,17 +14,17 @@ jobs:
       fail-fast: false
       matrix:
         python-version:
-        - 3.8
         - 3.9
         - '3.10'
         - '3.11'
         - '3.12'
+        - '3.13'
 
     steps:
       - uses: actions/checkout@v4
 
       - name: Set up Python ${{ matrix.python-version }}
-        uses: actions/setup-python@v4
+        uses: actions/setup-python@v5
         with:
           python-version: ${{ matrix.python-version }}
 
@@ -42,9 +42,9 @@ jobs:
     steps:
       - uses: actions/checkout@v4
       - name: Set up Python
-        uses: actions/setup-python@v4
+        uses: actions/setup-python@v5
         with:
-          python-version: '3.12'
+          python-version: '3.13'
       - name: Install dependencies
         run: |
           python -m pip install --upgrade pip tox
diff --git a/README.rst b/README.rst
index 0cdb00cb..2a427f97 100644
--- a/README.rst
+++ b/README.rst
@@ -96,7 +96,7 @@ file handles for incoming POST bodies).
 Dependencies
 ------------
 
-``asgiref`` requires Python 3.8 or higher.
+``asgiref`` requires Python 3.9 or higher.
 
 
 Contributing
diff --git a/asgiref/local.py b/asgiref/local.py
index a8b9459b..7d228aeb 100644
--- a/asgiref/local.py
+++ b/asgiref/local.py
@@ -2,37 +2,38 @@
 import contextlib
 import contextvars
 import threading
-from typing import Any, Dict, Union
+from typing import Any, Union
 
 
 class _CVar:
     """Storage utility for Local."""
 
     def __init__(self) -> None:
-        self._data: "contextvars.ContextVar[Dict[str, Any]]" = contextvars.ContextVar(
-            "asgiref.local"
-        )
+        self._data: dict[str, contextvars.ContextVar[Any]] = {}
 
-    def __getattr__(self, key):
-        storage_object = self._data.get({})
+    def __getattr__(self, key: str) -> Any:
         try:
-            return storage_object[key]
+            var = self._data[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)
 
-        storage_object = self._data.get({})
-        storage_object[key] = value
-        self._data.set(storage_object)
+        var = self._data.get(key)
+        if var is None:
+            self._data[key] = var = contextvars.ContextVar(key)
+        var.set(value)
 
     def __delattr__(self, key: str) -> None:
-        storage_object = self._data.get({})
-        if key in storage_object:
-            del storage_object[key]
-            self._data.set(storage_object)
+        if key in self._data:
+            del self._data[key]
         else:
             raise AttributeError(f"{self!r} object has no attribute {key!r}")
 
diff --git a/asgiref/sync.py b/asgiref/sync.py
index 87ee4064..377075d1 100644
--- a/asgiref/sync.py
+++ b/asgiref/sync.py
@@ -233,9 +233,8 @@ def __call__(self, *args: _P.args, **kwargs: _P.kwargs) -> _R:
                 loop_future = loop_executor.submit(
                     self._run_event_loop, loop, awaitable
                 )
-                if current_executor:
-                    # Run the CurrentThreadExecutor until the future is done
-                    current_executor.run_until_future(loop_future)
+                # Run the CurrentThreadExecutor until the future is done.
+                current_executor.run_until_future(loop_future)
                 # Wait for future and/or allow for exception propagation
                 loop_future.result()
             else:
@@ -243,9 +242,8 @@ def __call__(self, *args: _P.args, **kwargs: _P.kwargs) -> _R:
                 self.main_event_loop.call_soon_threadsafe(
                     self.main_event_loop.create_task, awaitable
                 )
-                if current_executor:
-                    # Run the CurrentThreadExecutor until the future is done
-                    current_executor.run_until_future(call_result)
+                # Run the CurrentThreadExecutor until the future is done.
+                current_executor.run_until_future(call_result)
         finally:
             # Clean up any executor we were running
             if loop is not None:
diff --git a/setup.cfg b/setup.cfg
index ab3364d6..4650df5f 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -16,11 +16,11 @@ classifiers =
     Programming Language :: Python
     Programming Language :: Python :: 3
     Programming Language :: Python :: 3 :: Only
-    Programming Language :: Python :: 3.8
     Programming Language :: Python :: 3.9
     Programming Language :: Python :: 3.10
     Programming Language :: Python :: 3.11
     Programming Language :: Python :: 3.12
+    Programming Language :: Python :: 3.13
     Topic :: Internet :: WWW/HTTP
 project_urls =
     Documentation = https://asgi.readthedocs.io/
@@ -28,7 +28,7 @@ project_urls =
     Changelog = https://github.com/django/asgiref/blob/master/CHANGELOG.txt
 
 [options]
-python_requires = >=3.8
+python_requires = >=3.9
 packages = find:
 include_package_data = true
 install_requires =
diff --git a/specs/www.rst b/specs/www.rst
index dcec9192..3417b28e 100644
--- a/specs/www.rst
+++ b/specs/www.rst
@@ -315,10 +315,10 @@ metadata (mostly from the HTTP request line and headers):
   string, with percent-encoded sequences and UTF-8 byte sequences
   decoded into characters.
 
-* ``raw_path`` (*byte string*) -- The original HTTP path component
-  unmodified from the bytes that were received by the web server. Some
-  web server implementations may be unable to provide this. Optional;
-  if missing defaults to ``None``.
+* ``raw_path`` (*byte string*) -- The original HTTP path component,
+  excluding any query string, unmodified from the bytes that were
+  received by the web server. Some web server implementations may
+  be unable to provide this. Optional; if missing defaults to ``None``.
 
 * ``query_string`` (*byte string*) -- URL portion after the
   ``?``. Optional; if missing or ``None`` default is empty string.
diff --git a/tox.ini b/tox.ini
index a271b813..49c49bce 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,6 +1,6 @@
 [tox]
 envlist =
-    py{38,39,310,311,312}-{test,mypy}
+    py{38,39,310,311,312,313}-{test,mypy}
     qa
 
 [testenv]


Test patch:
diff --git a/tests/test_local.py b/tests/test_local.py
index d50cba21..cdcbd280 100644
--- a/tests/test_local.py
+++ b/tests/test_local.py
@@ -1,6 +1,7 @@
 import asyncio
 import gc
 import threading
+from threading import Thread
 
 import pytest
 
@@ -338,3 +339,39 @@ async def async_function():
     # inner value was set inside a new async context, meaning that
     # we do not see it, as context vars don't propagate up the stack
     assert not hasattr(test_local_not_tc, "test_value")
+
+
+def test_visibility_thread_asgiref() -> None:
+    """Check visibility with subthreads."""
+    test_local = Local()
+    test_local.value = 0
+
+    def _test() -> None:
+        # Local() is cleared when changing thread
+        assert not hasattr(test_local, "value")
+        setattr(test_local, "value", 1)
+        assert test_local.value == 1
+
+    thread = Thread(target=_test)
+    thread.start()
+    thread.join()
+
+    assert test_local.value == 0
+
+
+@pytest.mark.asyncio
+async def test_visibility_task() -> None:
+    """Check visibility with asyncio tasks."""
+    test_local = Local()
+    test_local.value = 0
+
+    async def _test() -> None:
+        # Local is inherited when changing task
+        assert test_local.value == 0
+        test_local.value = 1
+        assert test_local.value == 1
+
+    await asyncio.create_task(_test())
+
+    # Changes should not leak to the caller
+    assert test_local.value == 0