diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index e2d1514c7..a2c99eaf9 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,18 @@ hide: ## Latest Changes +## 0.120.3 + +### Refactors + +* ♻️ Reduce internal cyclic recursion in dependencies, from 2 functions calling each other to 1 calling itself. PR [#14256](https://github.com/fastapi/fastapi/pull/14256) by [@tiangolo](https://github.com/tiangolo). +* ♻️ 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). + +### Docs + +* 📝 Update note for untranslated pages. PR [#14257](https://github.com/fastapi/fastapi/pull/14257) by [@YuriiMotov](https://github.com/YuriiMotov). + ## 0.120.2 ### Fixes diff --git a/docs/missing-translation.md b/docs/missing-translation.md index c2882e90e..bfff84766 100644 --- a/docs/missing-translation.md +++ b/docs/missing-translation.md @@ -1,7 +1,9 @@ /// warning -The current page still doesn't have a translation for this language. +This page hasn’t been translated into your language yet. 🌍 -But you can help translating it: [Contributing](https://fastapi.tiangolo.com/contributing/){.internal-link target=_blank}. +We’re currently switching to an automated translation system 🤖, which will help keep all translations complete and up to date. + +Learn more: [Contributing – Translations](https://fastapi.tiangolo.com/contributing/#translations){.internal-link target=_blank} /// diff --git a/fastapi/__init__.py b/fastapi/__init__.py index a4c17a6bd..945f800e4 100644 --- a/fastapi/__init__.py +++ b/fastapi/__init__.py @@ -1,6 +1,6 @@ """FastAPI framework, high performance, easy to learn, fast to code, ready for production""" -__version__ = "0.120.2" +__version__ = "0.120.3" from starlette import status as status diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index aa06dd2a9..d2d4e8b4c 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -125,60 +125,16 @@ 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" ) - 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): - dependency_scopes = depends.scopes - security_scopes.extend(dependency_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, ...]] @@ -282,9 +238,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 +245,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,12 +257,28 @@ 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, - depends=param_details.depends, + assert 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, - security_scopes=security_scopes, + call=param_details.depends.dependency, + name=param_name, + 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/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_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"} 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)" - )