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

Comments:

Patch:
diff --git a/requirements.txt b/requirements.txt
index 65e2408..4794d12 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -12,8 +12,8 @@ types-contextvars==2.4.7.2
 types-PyYAML==6.0.12.10
 types-dataclasses==0.6.6
 pytest==7.4.0
-trio==0.22.1
-anyio@git+https://github.com/agronholm/anyio.git
+trio==0.21.0
+anyio==3.7.1
 
 # Documentation
 mkdocs==1.4.3
diff --git a/starlette/_utils.py b/starlette/_utils.py
index f06dd55..26854f3 100644
--- a/starlette/_utils.py
+++ b/starlette/_utils.py
@@ -2,12 +2,20 @@ import asyncio
 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 @@ class AwaitableOrContextManagerWrapper(typing.Generic[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
index ee99ee6..ad3ffcf 100644
--- 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 @@ DispatchFunction = typing.Callable[
 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 @@ class BaseHTTPMiddleware:
             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
index ad39754..fe527e3 100644
--- a/tests/middleware/test_wsgi.py
+++ b/tests/middleware/test_wsgi.py
@@ -2,11 +2,9 @@ import sys
 
 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.