From c144f9fbd356e7e378a6b42cff68cf4a4667111a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Thu, 30 Oct 2025 01:51:50 -0300 Subject: [PATCH 1/5] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor=20internals?= =?UTF-8?q?=20of=20dependencies,=20simplify=20using=20dataclasses=20(#1425?= =?UTF-8?q?4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/dependencies/utils.py | 4 ++-- fastapi/params.py | 25 ++++++------------------- tests/test_params_repr.py | 11 +---------- 3 files changed, 9 insertions(+), 31 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index aa06dd2a9..e13b53095 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -160,8 +160,8 @@ def get_sub_dependant( security_requirement = None security_scopes = security_scopes or [] if isinstance(depends, params.Security): - dependency_scopes = depends.scopes - security_scopes.extend(dependency_scopes) + if depends.scopes: + security_scopes.extend(depends.scopes) if isinstance(dependency, SecurityBase): use_scopes: List[str] = [] if isinstance(dependency, (OAuth2, OpenIdConnect)): diff --git a/fastapi/params.py b/fastapi/params.py index e85375018..2dc04be14 100644 --- a/fastapi/params.py +++ b/fastapi/params.py @@ -1,4 +1,5 @@ import warnings +from dataclasses import dataclass from enum import Enum from typing import Any, Callable, Dict, List, Optional, Sequence, Union @@ -761,26 +762,12 @@ class File(Form): # type: ignore[misc] ) +@dataclass class Depends: - def __init__( - self, dependency: Optional[Callable[..., Any]] = None, *, use_cache: bool = True - ): - self.dependency = dependency - self.use_cache = use_cache - - def __repr__(self) -> str: - attr = getattr(self.dependency, "__name__", type(self.dependency).__name__) - cache = "" if self.use_cache else ", use_cache=False" - return f"{self.__class__.__name__}({attr}{cache})" + dependency: Optional[Callable[..., Any]] = None + use_cache: bool = True +@dataclass class Security(Depends): - def __init__( - self, - dependency: Optional[Callable[..., Any]] = None, - *, - scopes: Optional[Sequence[str]] = None, - use_cache: bool = True, - ): - super().__init__(dependency=dependency, use_cache=use_cache) - self.scopes = scopes or [] + scopes: Optional[Sequence[str]] = None diff --git a/tests/test_params_repr.py b/tests/test_params_repr.py index bfc7bed09..baa172497 100644 --- a/tests/test_params_repr.py +++ b/tests/test_params_repr.py @@ -1,7 +1,7 @@ from typing import Any, List from dirty_equals import IsOneOf -from fastapi.params import Body, Cookie, Depends, Header, Param, Path, Query +from fastapi.params import Body, Cookie, Header, Param, Path, Query test_data: List[Any] = ["teststr", None, ..., 1, []] @@ -141,12 +141,3 @@ def test_body_repr_number(): def test_body_repr_list(): assert repr(Body([])) == "Body([])" - - -def test_depends_repr(): - assert repr(Depends()) == "Depends(NoneType)" - assert repr(Depends(get_user)) == "Depends(get_user)" - assert repr(Depends(use_cache=False)) == "Depends(NoneType, use_cache=False)" - assert ( - repr(Depends(get_user, use_cache=False)) == "Depends(get_user, use_cache=False)" - ) From 9d1a384f4f904aaf72be5a73a2f721f92fad3e9e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 30 Oct 2025 04:52:12 +0000 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] --- docs/en/docs/release-notes.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index e2d1514c7..30c645e6c 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,10 @@ hide: ## Latest Changes +### Refactors + +* ♻️ Refactor internals of dependencies, simplify using dataclasses. PR [#14254](https://github.com/fastapi/fastapi/pull/14254) by [@tiangolo](https://github.com/tiangolo). + ## 0.120.2 ### Fixes From bb88a0f94a9633b861f90f6752e397980a7cfea9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Thu, 30 Oct 2025 01:58:49 -0300 Subject: [PATCH 3/5] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor=20internals?= =?UTF-8?q?=20of=20dependencies,=20simplify=20code=20and=20remove=20`get?= =?UTF-8?q?=5Fparam=5Fsub=5Fdependant`=20(#14255)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/dependencies/utils.py | 29 +++++++---------------------- 1 file changed, 7 insertions(+), 22 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index e13b53095..18f6a234e 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -125,23 +125,6 @@ def ensure_multipart_is_installed() -> None: raise RuntimeError(multipart_not_installed_error) from None -def get_param_sub_dependant( - *, - param_name: str, - depends: params.Depends, - path: str, - security_scopes: Optional[List[str]] = None, -) -> Dependant: - assert depends.dependency - return get_sub_dependant( - depends=depends, - dependency=depends.dependency, - path=path, - name=param_name, - security_scopes=security_scopes, - ) - - def get_parameterless_sub_dependant(*, depends: params.Depends, path: str) -> Dependant: assert callable(depends.dependency), ( "A parameter-less dependency must have a callable dependency" @@ -282,9 +265,6 @@ def get_dependant( security_scopes: Optional[List[str]] = None, use_cache: bool = True, ) -> Dependant: - path_param_names = get_path_param_names(path) - endpoint_signature = get_typed_signature(call) - signature_params = endpoint_signature.parameters dependant = Dependant( call=call, name=name, @@ -292,6 +272,9 @@ def get_dependant( security_scopes=security_scopes, use_cache=use_cache, ) + path_param_names = get_path_param_names(path) + endpoint_signature = get_typed_signature(call) + signature_params = endpoint_signature.parameters for param_name, param in signature_params.items(): is_path_param = param_name in path_param_names param_details = analyze_param( @@ -301,10 +284,12 @@ def get_dependant( is_path_param=is_path_param, ) if param_details.depends is not None: - sub_dependant = get_param_sub_dependant( - param_name=param_name, + assert param_details.depends.dependency + sub_dependant = get_sub_dependant( depends=param_details.depends, + dependency=param_details.depends.dependency, path=path, + name=param_name, security_scopes=security_scopes, ) dependant.dependencies.append(sub_dependant) From 1fc586c3a5ae457457c5c70adddb07c7e9185f16 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 30 Oct 2025 04:59:14 +0000 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] --- docs/en/docs/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 30c645e6c..bbcbc74b7 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -9,6 +9,7 @@ hide: ### Refactors +* ♻️ Refactor internals of dependencies, simplify code and remove `get_param_sub_dependant`. PR [#14255](https://github.com/fastapi/fastapi/pull/14255) by [@tiangolo](https://github.com/tiangolo). * ♻️ Refactor internals of dependencies, simplify using dataclasses. PR [#14254](https://github.com/fastapi/fastapi/pull/14254) by [@tiangolo](https://github.com/tiangolo). ## 0.120.2 From dcfb8b9dda7b8117141b89e84527b48f978e0b31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Thu, 30 Oct 2025 16:35:04 -0300 Subject: [PATCH 5/5] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Reduce=20internal=20cy?= =?UTF-8?q?clic=20recursion=20in=20dependencies,=20from=202=20functions=20?= =?UTF-8?q?calling=20each=20other=20to=201=20calling=20itself=20(#14256)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/dependencies/utils.py | 59 +++++++++------------- tests/test_dependency_paramless.py | 78 ++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 36 deletions(-) create mode 100644 tests/test_dependency_paramless.py diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 18f6a234e..d2d4e8b4c 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -129,39 +129,12 @@ def get_parameterless_sub_dependant(*, depends: params.Depends, path: str) -> De assert callable(depends.dependency), ( "A parameter-less dependency must have a callable dependency" ) - return get_sub_dependant(depends=depends, dependency=depends.dependency, path=path) - - -def get_sub_dependant( - *, - depends: params.Depends, - dependency: Callable[..., Any], - path: str, - name: Optional[str] = None, - security_scopes: Optional[List[str]] = None, -) -> Dependant: - security_requirement = None - security_scopes = security_scopes or [] - if isinstance(depends, params.Security): - if depends.scopes: - security_scopes.extend(depends.scopes) - if isinstance(dependency, SecurityBase): - use_scopes: List[str] = [] - if isinstance(dependency, (OAuth2, OpenIdConnect)): - use_scopes = security_scopes - security_requirement = SecurityRequirement( - security_scheme=dependency, scopes=use_scopes - ) - sub_dependant = get_dependant( - path=path, - call=dependency, - name=name, - security_scopes=security_scopes, - use_cache=depends.use_cache, + use_security_scopes: List[str] = [] + if isinstance(depends, params.Security) and depends.scopes: + use_security_scopes.extend(depends.scopes) + return get_dependant( + path=path, call=depends.dependency, security_scopes=use_security_scopes ) - if security_requirement: - sub_dependant.security_requirements.append(security_requirement) - return sub_dependant CacheKey = Tuple[Optional[Callable[..., Any]], Tuple[str, ...]] @@ -285,13 +258,27 @@ def get_dependant( ) if param_details.depends is not None: assert param_details.depends.dependency - sub_dependant = get_sub_dependant( - depends=param_details.depends, - dependency=param_details.depends.dependency, + use_security_scopes = security_scopes or [] + if isinstance(param_details.depends, params.Security): + if param_details.depends.scopes: + use_security_scopes.extend(param_details.depends.scopes) + sub_dependant = get_dependant( path=path, + call=param_details.depends.dependency, name=param_name, - security_scopes=security_scopes, + security_scopes=use_security_scopes, + use_cache=param_details.depends.use_cache, ) + if isinstance(param_details.depends.dependency, SecurityBase): + use_scopes: List[str] = [] + if isinstance( + param_details.depends.dependency, (OAuth2, OpenIdConnect) + ): + use_scopes = use_security_scopes + security_requirement = SecurityRequirement( + security_scheme=param_details.depends.dependency, scopes=use_scopes + ) + sub_dependant.security_requirements.append(security_requirement) dependant.dependencies.append(sub_dependant) continue if add_non_field_param_to_dependency( diff --git a/tests/test_dependency_paramless.py b/tests/test_dependency_paramless.py new file mode 100644 index 000000000..9c3cc3878 --- /dev/null +++ b/tests/test_dependency_paramless.py @@ -0,0 +1,78 @@ +from typing import Union + +from fastapi import FastAPI, HTTPException, Security +from fastapi.security import ( + OAuth2PasswordBearer, + SecurityScopes, +) +from fastapi.testclient import TestClient +from typing_extensions import Annotated + +app = FastAPI() + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") + + +def process_auth( + credentials: Annotated[Union[str, None], Security(oauth2_scheme)], + security_scopes: SecurityScopes, +): + # This is an incorrect way of using it, this is not checking if the scopes are + # provided by the token, only if the endpoint is requesting them, but the test + # here is just to check if FastAPI is indeed registering and passing the scopes + # correctly when using Security with parameterless dependencies. + if "a" not in security_scopes.scopes or "b" not in security_scopes.scopes: + raise HTTPException(detail="a or b not in scopes", status_code=401) + return {"token": credentials, "scopes": security_scopes.scopes} + + +@app.get("/get-credentials") +def get_credentials( + credentials: Annotated[dict, Security(process_auth, scopes=["a", "b"])], +): + return credentials + + +@app.get( + "/parameterless-with-scopes", + dependencies=[Security(process_auth, scopes=["a", "b"])], +) +def get_parameterless_with_scopes(): + return {"status": "ok"} + + +@app.get( + "/parameterless-without-scopes", + dependencies=[Security(process_auth)], +) +def get_parameterless_without_scopes(): + return {"status": "ok"} + + +client = TestClient(app) + + +def test_get_credentials(): + response = client.get("/get-credentials", headers={"authorization": "Bearer token"}) + assert response.status_code == 200, response.text + assert response.json() == {"token": "token", "scopes": ["a", "b"]} + + +def test_parameterless_with_scopes(): + response = client.get( + "/parameterless-with-scopes", headers={"authorization": "Bearer token"} + ) + assert response.status_code == 200, response.text + assert response.json() == {"status": "ok"} + + +def test_parameterless_without_scopes(): + response = client.get( + "/parameterless-without-scopes", headers={"authorization": "Bearer token"} + ) + assert response.status_code == 401, response.text + assert response.json() == {"detail": "a or b not in scopes"} + + +def test_call_get_parameterless_without_scopes_for_coverage(): + assert get_parameterless_without_scopes() == {"status": "ok"}