← oracle_full

fastapi_13786

failed WRONG_FIX UNSUBMITTED wrong_fix_unsubmitted(None) · None tool calls · 0 s · fastapi/fastapi

Task input

🐛 Use `401` status code in security classes when credentials are missing

## Warning

This description is partially outdated after changes described in [this comment](https://github.com/fastapi/fastapi/pull/13786#pullrequestreview-3501695067).

## Description

This PR is an attempt to finally solve the issue with security tools returning error responses with status code `403` instead of `401` when credentials are not provided.

## Breaking changes and workaround

These changes can break projects that rely on old behavior.
In order to mitigate this, the `not_authenticated_status_code` is introduced. If set to `403`, it will make it work the same way as it was before changes (return `403` status code).
This option should be treated as a temporary workaround to give developers more time to update Clients to follow the new behavior.

## Changes and reasoning

### APIKeyQuery, APIKeyHeader, APIKeyCookie

* **Standard:**
    * These schemes are not covered by standards, but developers usually follow the same rules as for other standards
* **Actions:**
    * The default status code for not providing API key was changed from 403 to 401.
    * Temporary `not_authenticated_status_code` parameter can be used to revert this behavior back to returning 403 error code without sending `WWW-Authenticate`.
* **Notes:**
    * It’s considered to be a good practice to include in `WWW-Authenticate` information needed to understand how the key is supposed to be passed. I implemented default format (`WWW-Authenticate: ApiKey in="...", name="..."` ), but it’s possible to override the template for `WWW-Authenticate` by subclassing and defining the `format_www_authenticate_header_value` method

### HTTP Basic

*  **Standard:**
    * https://datatracker.ietf.org/doc/html/rfc7617
* **Actions:**
    * No needed. This scheme already acts according to the standard in terms of returning 401 status code with `WWW-Authenticate` header on a lack of credentials
* **Notes:**
    * `realm` is required according to the RFC, but optional in the current implementation. Fixing this would introduce breaking changes. Considering this is not a problem for people who want to follow the standard, I suggest we leave it as it is.

### HTTP Digest

* **Standard:**
    * https://datatracker.ietf.org/doc/html/rfc7616
* **Actions:**
    * The default status code for not providing the authorization parameter was changed from 403 to 401.
    * `WWW-Authenticate` is just a stub for now (just `WWW-Authenticate: Digest`) (see notes)
    *  Temporary `not_authenticated_status_code` parameter can be used to revert this behavior back to returning 403 error code without sending `WWW-Authenticate`.
* **Notes:**
    * Since the current `HTTPDigest` implementation is just a stub, we can’t follow standards (we don’t generate `nonce`'s, don’t have `realm`, …). I suggest we just change the error status code and add a stub for `WWW-Authenticate` (just `WWW-Authenticate: Digest`). For now `HTTPDigest` can’t be used as it is, so, this is not a problem.
    * We can later add full implementation of `Digest` scheme. There have been made several attempts to implement it (#9825, #3071)
    * Should we add a note that `HTTPDigest` is just a stub?

### HTTP Bearer, OAuth2 schemes, OIDC

* **Standard:**
    * https://datatracker.ietf.org/doc/html/rfc6750
* **Actions:**
    * For `OAuth2PasswordBearer` and `OAuth2AuthorizationCodeBearer`:  not needed.
        * They already return a 401 error code. Implementation is probably not 100% correct (see notes), but considering nobody argued, I think we can leave it as it is for now.
    * For `HTTPBearer` and `OpenIdConnect`:
        * The default status code for not providing the authorization parameter was changed from 403 to 401. The suggested implementation will be in line with the current implementations of `OAuth2PasswordBearer` and `OAuth2AuthorizationCodeBearer`.
        * Temporary `not_authenticated_status_code` parameter added to `HTTPBearer` can be used to revert this behavior back to returning 403 error code without sending `WWW-Authenticate`.
* **Notes:**
    * It’s [recommended to return 400 error response](https://datatracker.ietf.org/doc/html/rfc6750#section-3.1) if the parameter is missed or of an unsupported type, but this is not a strict requirement (word SHOULD is used). I suggest we ignore this and follow the approach that is consistent with other schemes.
    * The format of `WWW-Authenticate` is not clearly described: It’s said that the value "Bearer" MUST be followed by one or more auth-param values. At the same time, all auth-param attributes are optional. In examples they always add `realm`. Since we don’t have `realm`, I suggest we just skip it and send just `WWW-Authenticate: Bearer`
    * We can later improve the `WWW-Authenticate` format by adding `realm` and `scope`

## Links
* https://github.com/fastapi/fastapi/issues/10177
* https://github.com/fastapi/fastapi/pull/2120
* https://github.com/fastapi/fastapi/pull/5332
* https://github.com/fastapi/fastapi/pull/12507
* https://github.com/fastapi/fastapi/pull/5310
* https://github.com/fastapi/fastapi/pull/13729
* https://github.com/fastapi/fastapi/pull/13514
* https://github.com/fastapi/fastapi/pull/13326





Tool calls (0)

#ToolArgumentsResult
No trace captured.

Patch

--- a/docs_src/authentication_error_status_code/tutorial001_an.py
+++ b/docs_src/authentication_error_status_code/tutorial001_an.py
@@ -0,0 +1,20 @@
+from fastapi import Depends, FastAPI, HTTPException, status
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+from typing_extensions import Annotated
+
+app = FastAPI()
+
+
+class HTTPBearer403(HTTPBearer):
+    def make_not_authenticated_error(self) -> HTTPException:
+        return HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN, detail="Not authenticated"
+        )
+
+
+CredentialsDep = Annotated[HTTPAuthorizationCredentials, Depends(HTTPBearer403())]
+
+
+@app.get("/me")
+def read_me(credentials: CredentialsDep):
+    return {"message": "You are authenticated", "token": credentials.credentials}
--- a/docs_src/authentication_error_status_code/tutorial001_an_py39.py
+++ b/docs_src/authentication_error_status_code/tutorial001_an_py39.py
@@ -0,0 +1,21 @@
+from typing import Annotated
+
+from fastapi import Depends, FastAPI, HTTPException, status
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+
+app = FastAPI()
+
+
+class HTTPBearer403(HTTPBearer):
+    def make_not_authenticated_error(self) -> HTTPException:
+        return HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN, detail="Not authenticated"
+        )
+
+
+CredentialsDep = Annotated[HTTPAuthorizationCredentials, Depends(HTTPBearer403())]
+
+
+@app.get("/me")
+def read_me(credentials: CredentialsDep):
+    return {"message": "You are authenticated", "token": credentials.credentials}
--- a/docs_src/security/tutorial003.py
+++ b/docs_src/security/tutorial003.py
@@ -60,7 +60,7 @@ async def get_current_user(token: str = Depends(oauth2_scheme)):
     if not user:
         raise HTTPException(
             status_code=status.HTTP_401_UNAUTHORIZED,
-            detail="Invalid authentication credentials",
+            detail="Not authenticated",
             headers={"WWW-Authenticate": "Bearer"},
         )
     return user
--- a/docs_src/security/tutorial003_an.py
+++ b/docs_src/security/tutorial003_an.py
@@ -61,7 +61,7 @@ async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
     if not user:
         raise HTTPException(
             status_code=status.HTTP_401_UNAUTHORIZED,
-            detail="Invalid authentication credentials",
+            detail="Not authenticated",
             headers={"WWW-Authenticate": "Bearer"},
         )
     return user
--- a/docs_src/security/tutorial003_an_py310.py
+++ b/docs_src/security/tutorial003_an_py310.py
@@ -60,7 +60,7 @@ async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
     if not user:
         raise HTTPException(
             status_code=status.HTTP_401_UNAUTHORIZED,
-            detail="Invalid authentication credentials",
+            detail="Not authenticated",
             headers={"WWW-Authenticate": "Bearer"},
         )
     return user
--- a/docs_src/security/tutorial003_an_py39.py
+++ b/docs_src/security/tutorial003_an_py39.py
@@ -60,7 +60,7 @@ async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
     if not user:
         raise HTTPException(
             status_code=status.HTTP_401_UNAUTHORIZED,
-            detail="Invalid authentication credentials",
+            detail="Not authenticated",
             headers={"WWW-Authenticate": "Bearer"},
         )
     return user
--- a/docs_src/security/tutorial003_py310.py
+++ b/docs_src/security/tutorial003_py310.py
@@ -58,7 +58,7 @@ async def get_current_user(token: str = Depends(oauth2_scheme)):
     if not user:
         raise HTTPException(
             status_code=status.HTTP_401_UNAUTHORIZED,
-            detail="Invalid authentication credentials",
+            detail="Not authenticated",
             headers={"WWW-Authenticate": "Bearer"},
         )
     return user
--- a/fastapi/security/api_key.py
+++ b/fastapi/security/api_key.py
@@ -1,22 +1,52 @@
-from typing import Optional
+from typing import Optional, Union
 
 from annotated_doc import Doc
 from fastapi.openapi.models import APIKey, APIKeyIn
 from fastapi.security.base import SecurityBase
 from starlette.exceptions import HTTPException
 from starlette.requests import Request
-from starlette.status import HTTP_403_FORBIDDEN
+from starlette.status import HTTP_401_UNAUTHORIZED
 from typing_extensions import Annotated
 
 
 class APIKeyBase(SecurityBase):
-    @staticmethod
-    def check_api_key(api_key: Optional[str], auto_error: bool) -> Optional[str]:
+    def __init__(
+        self,
+        location: APIKeyIn,
+        name: str,
+        description: Union[str, None],
+        scheme_name: Union[str, None],
+        auto_error: bool,
+    ):
+        self.auto_error = auto_error
+
+        self.model: APIKey = APIKey(
+            **{"in": location},
+            name=name,
+            description=description,
+        )
+        self.scheme_name = scheme_name or self.__class__.__name__
+
+    def make_not_authenticated_error(self) -> HTTPException:
+        """
+        The WWW-Authenticate header is not standardized for API Key authentication but
+        the HTTP specification requires that an error of 401 "Unauthorized" must
+        include a WWW-Authenticate header.
+
+        Ref: https://datatracker.ietf.org/doc/html/rfc9110#name-401-unauthorized
+
+        For this, this method sends a custom challenge `APIKey`.
+        """
+        return HTTPException(
+            status_code=HTTP_401_UNAUTHORIZED,
+            detail="Not authenticated",
+            headers={"WWW-Authenticate": "APIKey"},
+        )
+
+    def check_api_key(self, api_key: Optional[str]) -> Optional[str]:
         if not api_key:
-            if auto_error:
-                raise HTTPException(
-                    status_code=HTTP_403_FORBIDDEN, detail="Not authenticated"
-                )
+            if self.auto_error:
+                raise self.make_not_authenticated_error()
             return None
         return api_key
 
@@ -100,17 +130,17 @@ def __init__(
             ),
         ] = True,
     ):
-        self.model: APIKey = APIKey(
-            **{"in": APIKeyIn.query},
+        super().__init__(
+            location=APIKeyIn.query,
             name=name,
+            scheme_name=scheme_name,
             description=description,
+            auto_error=auto_error,
         )
-        self.scheme_name = scheme_name or self.__class__.__name__
-        self.auto_error = auto_error
 
     async def __call__(self, request: Request) -> Optional[str]:
         api_key = request.query_params.get(self.model.name)
-        return self.check_api_key(api_key, self.auto_error)
+        return self.check_api_key(api_key)
 
 
 class APIKeyHeader(APIKeyBase):
@@ -188,17 +218,17 @@ def __init__(
             ),
         ] = True,
     ):
-        self.model: APIKey = APIKey(
-            **{"in": APIKeyIn.header},
+        super().__init__(
+            location=APIKeyIn.header,
             name=name,
+            scheme_name=scheme_name,
             description=description,
+            auto_error=auto_error,
         )
-        self.scheme_name = scheme_name or self.__class__.__name__
-        self.auto_error = auto_error
 
     async def __call__(self, request: Request) -> Optional[str]:
         api_key = request.headers.get(self.model.name)
-        return self.check_api_key(api_key, self.auto_error)
+        return self.check_api_key(api_key)
 
 
 class APIKeyCookie(APIKeyBase):
@@ -276,14 +306,14 @@ def __init__(
             ),
         ] = True,
     ):
-        self.model: APIKey = APIKey(
-            **{"in": APIKeyIn.cookie},
+        super().__init__(
+            location=APIKeyIn.cookie,
             name=name,
+            scheme_name=scheme_name,
             description=description,
+            auto_error=auto_error,
         )
-        self.scheme_name = scheme_name or self.__class__.__name__
-        self.auto_error = auto_error
 
     async def __call__(self, request: Request) -> Optional[str]:
         api_key = request.cookies.get(self.model.name)
-        return self.check_api_key(api_key, self.auto_error)
+        return self.check_api_key(api_key)
--- a/fastapi/security/http.py
+++ b/fastapi/security/http.py
@@ -1,6 +1,6 @@
 import binascii
 from base64 import b64decode
-from typing import Optional
+from typing import Dict, Optional
 
 from annotated_doc import Doc
 from fastapi.exceptions import HTTPException
@@ -10,7 +10,7 @@
 from fastapi.security.utils import get_authorization_scheme_param
 from pydantic import BaseModel
 from starlette.requests import Request
-from starlette.status import HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN
+from starlette.status import HTTP_401_UNAUTHORIZED
 from typing_extensions import Annotated
 
 
@@ -76,20 +76,30 @@ def __init__(
         description: Optional[str] = None,
         auto_error: bool = True,
     ):
-        self.model = HTTPBaseModel(scheme=scheme, description=description)
+        self.model: HTTPBaseModel = HTTPBaseModel(
+            scheme=scheme, description=description
+        )
         self.scheme_name = scheme_name or self.__class__.__name__
         self.auto_error = auto_error
 
+    def make_authenticate_headers(self) -> Dict[str, str]:
+        return {"WWW-Authenticate": f"{self.model.scheme.title()}"}
+
+    def make_not_authenticated_error(self) -> HTTPException:
+        return HTTPException(
+            status_code=HTTP_401_UNAUTHORIZED,
+            detail="Not authenticated",
+            headers=self.make_authenticate_headers(),
+        )
+
     async def __call__(
         self, request: Request
     ) -> Optional[HTTPAuthorizationCredentials]:
         authorization = request.headers.get("Authorization")
         scheme, credentials = get_authorization_scheme_param(authorization)
         if not (authorization and scheme and credentials):
             if self.auto_error:
-                raise HTTPException(
-                    status_code=HTTP_403_FORBIDDEN, detail="Not authenticated"
-                )
+                raise self.make_not_authenticated_error()
             else:
                 return None
         return HTTPAuthorizationCredentials(scheme=scheme, credentials=credentials)
@@ -99,6 +109,8 @@ class HTTPBasic(HTTPBase):
     """
     HTTP Basic authentication.
 
+    Ref: https://datatracker.ietf.org/doc/html/rfc7617
+
     ## Usage
 
     Create an instance object and use that object as the dependency in `Depends()`.
@@ -185,36 +197,28 @@ def __init__(
         self.realm = realm
         self.auto_error = auto_error
 
+    def make_authenticate_headers(self) -> Dict[str, str]:
+        if self.realm:
+            return {"WWW-Authenticate": f'Basic realm="{self.realm}"'}
+        return {"WWW-Authenticate": "Basic"}
+
     async def __call__(  # type: ignore
         self, request: Request
     ) -> Optional[HTTPBasicCredentials]:
         authorization = request.headers.get("Authorization")
         scheme, param = get_authorization_scheme_param(authorization)
-        if self.realm:
-            unauthorized_headers = {"WWW-Authenticate": f'Basic realm="{self.realm}"'}
-        else:
-            unauthorized_headers = {"WWW-Authenticate": "Basic"}
         if not authorization or scheme.lower() != "basic":
             if self.auto_error:
-                raise HTTPException(
-                    status_code=HTTP_401_UNAUTHORIZED,
-                    detail="Not authenticated",
-                    headers=unauthorized_headers,
-                )
+                raise self.make_not_authenticated_error()
             else:
                 return None
-        invalid_user_credentials_exc = HTTPException(
-            status_code=HTTP_401_UNAUTHORIZED,
-            detail="Invalid authentication credentials",
-            headers=unauthorized_headers,
-        )
         try:
             data = b64decode(param).decode("ascii")
-        except (ValueError, UnicodeDecodeError, binascii.Error):
-            raise invalid_user_credentials_exc  # noqa: B904
+        except (ValueError, UnicodeDecodeError, binascii.Error) as e:
+            raise self.make_not_authenticated_error() from e
         username, separator, password = data.partition(":")
         if not separator:
-            raise invalid_user_credentials_exc
+            raise self.make_not_authenticated_error()
         return HTTPBasicCredentials(username=username, password=password)
 
 
@@ -306,17 +310,12 @@ async def __call__(
         scheme, credentials = get_authorization_scheme_param(authorization)
         if not (authorization and scheme and credentials):
             if self.auto_error:
-                raise HTTPException(
-                    status_code=HTTP_403_FORBIDDEN, detail="Not authenticated"
-                )
+                raise self.make_not_authenticated_error()
             else:
                 return None
         if scheme.lower() != "bearer":
             if self.auto_error:
-                raise HTTPException(
-                    status_code=HTTP_403_FORBIDDEN,
-                    detail="Invalid authentication credentials",
-                )
+                raise self.make_not_authenticated_error()
             else:
                 return None
         return HTTPAuthorizationCredentials(scheme=scheme, credentials=credentials)
@@ -326,6 +325,12 @@ class HTTPDigest(HTTPBase):
     """
     HTTP Digest authentication.
 
+    **Warning**: this is only a stub to connect the components with OpenAPI in FastAPI,
+    but it doesn't implement the full Digest scheme, you would need to to subclass it
+    and implement it in your code.
+
+    Ref: https://datatracker.ietf.org/doc/html/rfc7616
+
     ## Usage
 
     Create an instance object and use that object as the dependency in `Depends()`.
@@ -408,17 +413,12 @@ async def __call__(
         scheme, credentials = get_authorization_scheme_param(authorization)
         if not (authorization and scheme and credentials):
             if self.auto_error:
-                raise HTTPException(
-                    status_code=HTTP_403_FORBIDDEN, detail="Not authenticated"
-                )
+                raise self.make_not_authenticated_error()
             else:
                 return None
         if scheme.lower() != "digest":
             if self.auto_error:
-                raise HTTPException(
-                    status_code=HTTP_403_FORBIDDEN,
-                    detail="Invalid authentication credentials",
-                )
+                raise self.make_not_authenticated_error()
             else:
                 return None
         return HTTPAuthorizationCredentials(scheme=scheme, credentials=credentials)
--- a/fastapi/security/oauth2.py
+++ b/fastapi/security/oauth2.py
@@ -8,7 +8,7 @@
 from fastapi.security.base import SecurityBase
 from fastapi.security.utils import get_authorization_scheme_param
 from starlette.requests import Request
-from starlette.status import HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN
+from starlette.status import HTTP_401_UNAUTHORIZED
 
 # TODO: import from typing when deprecating Python 3.9
 from typing_extensions import Annotated
@@ -377,13 +377,33 @@ def __init__(
         self.scheme_name = scheme_name or self.__class__.__name__
         self.auto_error = auto_error
 
+    def make_not_authenticated_error(self) -> HTTPException:
+        """
+        The OAuth 2 specification doesn't define the challenge that should be used,
+        because a `Bearer` token is not really the only option to authenticate.
+
+        But declaring any other authentication challenge would be application-specific
+        as it's not defined in the specification.
+
+        For practical reasons, this method uses the `Bearer` challenge by default, as
+        it's probably the most common one.
+
+        If you are implementing an OAuth2 authentication scheme other than the provided
+        ones in FastAPI (based on bearer tokens), you might want to override this.
+
+        Ref: https://datatracker.ietf.org/doc/html/rfc6749
+        """
+        return HTTPException(
+            status_code=HTTP_401_UNAUTHORIZED,
+            detail="Not authenticated",
+            headers={"WWW-Authenticate": "Bearer"},
+        )
+
     async def __call__(self, request: Request) -> Optional[str]:
         authorization = request.headers.get("Authorization")
         if not authorization:
             if self.auto_error:
-                raise HTTPException(
-                    status_code=HTTP_403_FORBIDDEN, detail="Not authenticated"
-                )
+                raise self.make_not_authenticated_error()
             else:
                 return None
         return authorization
@@ -491,11 +511,7 @@ async def __call__(self, request: Request) -> Optional[str]:
         scheme, param = get_authorization_scheme_param(authorization)
         if not authorization or scheme.lower() != "bearer":
             if self.auto_error:
-                raise HTTPException(
-                    status_code=HTTP_401_UNAUTHORIZED,
-                    detail="Not authenticated",
-                    headers={"WWW-Authenticate": "Bearer"},
-                )
+                raise self.make_not_authenticated_error()
             else:
                 return None
         return param
@@ -601,11 +617,7 @@ async def __call__(self, request: Request) -> Optional[str]:
         scheme, param = get_authorization_scheme_param(authorization)
         if not authorization or scheme.lower() != "bearer":
             if self.auto_error:
-                raise HTTPException(
-                    status_code=HTTP_401_UNAUTHORIZED,
-                    detail="Not authenticated",
-                    headers={"WWW-Authenticate": "Bearer"},
-                )
+                raise self.make_not_authenticated_error()
             else:
                 return None  # pragma: nocover
         return param
--- a/fastapi/security/open_id_connect_url.py
+++ b/fastapi/security/open_id_connect_url.py
@@ -5,14 +5,19 @@
 from fastapi.security.base import SecurityBase
 from starlette.exceptions import HTTPException
 from starlette.requests import Request
-from starlette.status import HTTP_403_FORBIDDEN
+from starlette.status import HTTP_401_UNAUTHORIZED
 from typing_extensions import Annotated
 
 
 class OpenIdConnect(SecurityBase):
     """
     OpenID Connect authentication class. An instance of it would be used as a
     dependency.
+
+    **Warning**: this is only a stub to connect the components with OpenAPI in FastAPI,
+    but it doesn't implement the full OpenIdConnect scheme, for example, it doesn't use
+    the OpenIDConnect URL. You would need to to subclass it and implement it in your
+    code.
     """
 
     def __init__(
@@ -73,13 +78,18 @@ def __init__(
         self.scheme_name = scheme_name or self.__class__.__name__
         self.auto_error = auto_error
 
+    def make_not_authenticated_error(self) -> HTTPException:
+        return HTTPException(
+            status_code=HTTP_401_UNAUTHORIZED,
+            detail="Not authenticated",
+            headers={"WWW-Authenticate": "Bearer"},
+        )
+
     async def __call__(self, request: Request) -> Optional[str]:
         authorization = request.headers.get("Authorization")
         if not authorization:
             if self.auto_error:
-                raise HTTPException(
-                    status_code=HTTP_403_FORBIDDEN, detail="Not authenticated"
-                )
+                raise self.make_not_authenticated_error()
             else:
                 return None
         return authorization

Test output

show
==================================== ERRORS ====================================
________________ ERROR collecting tests/test_security_oauth2.py ________________
tests/test_security_oauth2.py:30: in <module>
    @app.post("/login")
     ^^^^^^^^^^^^^^^^^^
fastapi/routing.py:1078: in decorator
    self.add_api_route(
fastapi/routing.py:1017: in add_api_route
    route = route_class(
fastapi/routing.py:636: in __init__
    self.dependant = get_dependant(
fastapi/dependencies/utils.py:282: in get_dependant
    sub_dependant = get_dependant(
fastapi/dependencies/utils.py:260: in get_dependant
    param_details = analyze_param(
fastapi/dependencies/utils.py:496: in analyze_param
    ensure_multipart_is_installed()
fastapi/dependencies/utils.py:122: in ensure_multipart_is_installed
    raise RuntimeError(multipart_not_installed_error) from None
E   RuntimeError: Form data requires "python-multipart" to be installed. 
E   You can install "python-multipart" with: 
E   
E   pip install python-multipart
=============================== warnings summary ===============================
../../../../../../../Users/jp/repos/kaggle-gemini-coding-agent-post-training/.envs/overlays/starlette-0.50.0-py3-none-any/starlette/testclient.py:45
  /Users/jp/repos/kaggle-gemini-coding-agent-post-training/.envs/overlays/starlette-0.50.0-py3-none-any/starlette/testclient.py:45: DeprecationWarning: The anyio.abc.BlockingPortal alias is deprecated, use anyio.from_thread.BlockingPortal instead.
    _PortalFactoryType = Callable[[], AbstractContextManager[anyio.abc.BlockingPortal]]

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!
1 warning, 1 error in 1.02s