Repository: encode/starlette

Issue:
No module named 'exceptiongroup' when upgraded to starlette 0.31.0
After upgrading to starlette 0.31.0 today, we run into the following exception when importing starlette:

```
from starlette import applications, requests, responses, routing
2023-07-24 15:47:43    File "/opt/conda/lib/python3.8/site-packages/starlette/applications.py", line 8, in <module>
2023-07-24 15:47:43      from starlette.middleware.base import BaseHTTPMiddleware
2023-07-24 15:47:43    File "/opt/conda/lib/python3.8/site-packages/starlette/middleware/base.py", line 14, in <module>
2023-07-24 15:47:43      from exceptiongroup import BaseExceptionGroup
2023-07-24 15:47:43  ModuleNotFoundError: No module named 'exceptiongroup'
```

This is likely introduced from https://github.com/encode/starlette/pull/2211/files
Apply AnyIO upgrade changes only on test suite
- Closes https://github.com/encode/starlette/issues/2227
- Closes https://github.com/encode/starlette/discussions/2228


Discussion:
I'm struggling with time, but I'll fix in the weekend if no ones open a PR to wrap it with a `try..except ModuleImportError`.
Are you sure this is the right solution? You told me yourself that you'd prefer the singular exception from the exception group to be extracted and reraised. If you don't want to add `exceptiongroup` as a dependency yet, I think the exceptiongroup import just needs to be wrapped with a `try...except ImportError`. Then we can replace the context manager with a no-op.

Resolving patch:
diff --git a/starlette/_utils.py b/starlette/_utils.py
--- a/starlette/_utils.py
+++ b/starlette/_utils.py
@@ -2,12 +2,20 @@
 import functools
 import sys
 import typing
+from contextlib import contextmanager
 
 if sys.version_info >= (3, 10):  # pragma: no cover
     from typing import TypeGuard
 else:  # pragma: no cover
     from typing_extensions import TypeGuard
 
+has_exceptiongroups = True
+if sys.version_info < (3, 11):  # pragma: no cover
+    try:
+        from exceptiongroup import BaseExceptionGroup
+    except ImportError:
+        has_exceptiongroups = False
+
 T = typing.TypeVar("T")
 AwaitableCallable = typing.Callable[..., typing.Awaitable[T]]
 
@@ -66,3 +74,15 @@ async def __aenter__(self) -> SupportsAsyncCloseType:
     async def __aexit__(self, *args: typing.Any) -> typing.Union[None, bool]:
         await self.entered.close()
         return None
+
+
+@contextmanager
+def collapse_excgroups() -> typing.Generator[None, None, None]:
+    try:
+        yield
+    except BaseException as exc:
+        if has_exceptiongroups:
+            while isinstance(exc, BaseExceptionGroup) and len(exc.exceptions) == 1:
+                exc = exc.exceptions[0]  # pragma: no cover
+
+        raise exc
diff --git a/starlette/middleware/base.py b/starlette/middleware/base.py
--- a/starlette/middleware/base.py
+++ b/starlette/middleware/base.py
@@ -1,18 +1,14 @@
-import sys
 import typing
-from contextlib import contextmanager
 
 import anyio
 from anyio.abc import ObjectReceiveStream, ObjectSendStream
 
+from starlette._utils import collapse_excgroups
 from starlette.background import BackgroundTask
 from starlette.requests import ClientDisconnect, Request
 from starlette.responses import ContentStream, Response, StreamingResponse
 from starlette.types import ASGIApp, Message, Receive, Scope, Send
 
-if sys.version_info < (3, 11):  # pragma: no cover
-    from exceptiongroup import BaseExceptionGroup
-
 RequestResponseEndpoint = typing.Callable[[Request], typing.Awaitable[Response]]
 DispatchFunction = typing.Callable[
     [Request, RequestResponseEndpoint], typing.Awaitable[Response]
@@ -20,17 +16,6 @@
 T = typing.TypeVar("T")
 
 
-@contextmanager
-def _convert_excgroups() -> typing.Generator[None, None, None]:
-    try:
-        yield
-    except BaseException as exc:
-        while isinstance(exc, BaseExceptionGroup) and len(exc.exceptions) == 1:
-            exc = exc.exceptions[0]
-
-        raise exc
-
-
 class _CachedRequest(Request):
     """
     If the user calls Request.body() from their dispatch function
@@ -201,7 +186,7 @@ async def body_stream() -> typing.AsyncGenerator[bytes, None]:
             response.raw_headers = message["headers"]
             return response
 
-        with _convert_excgroups():
+        with collapse_excgroups():
             async with anyio.create_task_group() as task_group:
                 response = await self.dispatch_func(request, call_next)
                 await response(scope, wrapped_receive, send)


Test patch:
diff --git a/tests/middleware/test_wsgi.py b/tests/middleware/test_wsgi.py
--- a/tests/middleware/test_wsgi.py
+++ b/tests/middleware/test_wsgi.py
@@ -2,11 +2,9 @@
 
 import pytest
 
+from starlette._utils import collapse_excgroups
 from starlette.middleware.wsgi import WSGIMiddleware, build_environ
 
-if sys.version_info < (3, 11):  # pragma: no cover
-    from exceptiongroup import ExceptionGroup
-
 
 def hello_world(environ, start_response):
     status = "200 OK"
@@ -69,12 +67,9 @@ def test_wsgi_exception(test_client_factory):
     # The HTTP protocol implementations would catch this error and return 500.
     app = WSGIMiddleware(raise_exception)
     client = test_client_factory(app)
-    with pytest.raises(ExceptionGroup) as exc:
+    with pytest.raises(RuntimeError), collapse_excgroups():
         client.get("/")
 
-    assert len(exc.value.exceptions) == 1
-    assert isinstance(exc.value.exceptions[0], RuntimeError)
-
 
 def test_wsgi_exc_info(test_client_factory):
     # Note that we're testing the WSGI app directly here.