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.

Hints from issue comments:
@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