โ† oracle_full

fastapi_14419

resolved RESOLVED UNSUBMITTED PASS ยท None tool calls ยท 0 s ยท fastapi/fastapi

Task input

๐Ÿ› Cache dependencies that don't use scopes and don't have sub-dependencies with scopes

๐Ÿ› Cache dependencies that don't use scopes and don't have sub-dependencies with scopes

Related to https://github.com/fastapi/fastapi/pull/9790, and https://github.com/fastapi/fastapi/discussions/6024

Uses the ideas in @YuriiMotov's diagrams here for the tests: https://github.com/fastapi/fastapi/discussions/6024#discussioncomment-8541913

---

Dependencies are cached based on the function. And also on the **scopes**, but (now) **only** when those scopes are used by this dependency or by a sub-dependency: this dependency or a sub-dependency declare scopes or access the `SecurityScopes`.

Tool calls (0)

#ToolArgumentsResult
No trace captured.

Patch

--- a/fastapi/dependencies/models.py
+++ b/fastapi/dependencies/models.py
@@ -38,19 +38,43 @@ class Dependant:
     response_param_name: Optional[str] = None
     background_tasks_param_name: Optional[str] = None
     security_scopes_param_name: Optional[str] = None
-    security_scopes: Optional[List[str]] = None
+    own_oauth_scopes: Optional[List[str]] = None
+    parent_oauth_scopes: Optional[List[str]] = None
     use_cache: bool = True
     path: Optional[str] = None
     scope: Union[Literal["function", "request"], None] = None
 
+    @cached_property
+    def oauth_scopes(self) -> List[str]:
+        scopes = self.parent_oauth_scopes.copy() if self.parent_oauth_scopes else []
+        # This doesn't use a set to preserve order, just in case
+        for scope in self.own_oauth_scopes or []:
+            if scope not in scopes:
+                scopes.append(scope)
+        return scopes
+
     @cached_property
     def cache_key(self) -> DependencyCacheKey:
+        scopes_for_cache = (
+            tuple(sorted(set(self.oauth_scopes or []))) if self._uses_scopes else ()
+        )
         return (
             self.call,
-            tuple(sorted(set(self.security_scopes or []))),
+            scopes_for_cache,
             self.computed_scope or "",
         )
 
+    @cached_property
+    def _uses_scopes(self) -> bool:
+        if self.own_oauth_scopes:
+            return True
+        if self.security_scopes_param_name is not None:
+            return True
+        for sub_dep in self.dependencies:
+            if sub_dep._uses_scopes:
+                return True
+        return False
+
     @cached_property
     def is_gen_callable(self) -> bool:
         if inspect.isgeneratorfunction(self.call):
--- a/fastapi/dependencies/utils.py
+++ b/fastapi/dependencies/utils.py
@@ -58,8 +58,7 @@
 from fastapi.exceptions import DependencyScopeError
 from fastapi.logger import logger
 from fastapi.security.base import SecurityBase
-from fastapi.security.oauth2 import OAuth2, SecurityScopes
-from fastapi.security.open_id_connect_url import OpenIdConnect
+from fastapi.security.oauth2 import SecurityScopes
 from fastapi.types import DependencyCacheKey
 from fastapi.utils import create_model_field, get_path_param_names
 from pydantic import BaseModel
@@ -126,14 +125,14 @@ def get_parameterless_sub_dependant(*, depends: params.Depends, path: str) -> De
     assert callable(depends.dependency), (
         "A parameter-less dependency must have a callable dependency"
     )
-    use_security_scopes: List[str] = []
+    own_oauth_scopes: List[str] = []
     if isinstance(depends, params.Security) and depends.scopes:
-        use_security_scopes.extend(depends.scopes)
+        own_oauth_scopes.extend(depends.scopes)
     return get_dependant(
         path=path,
         call=depends.dependency,
         scope=depends.scope,
-        security_scopes=use_security_scopes,
+        own_oauth_scopes=own_oauth_scopes,
     )
 
 
@@ -232,27 +231,27 @@ def get_dependant(
     path: str,
     call: Callable[..., Any],
     name: Optional[str] = None,
-    security_scopes: Optional[List[str]] = None,
+    own_oauth_scopes: Optional[List[str]] = None,
+    parent_oauth_scopes: Optional[List[str]] = None,
     use_cache: bool = True,
     scope: Union[Literal["function", "request"], None] = None,
 ) -> Dependant:
     dependant = Dependant(
         call=call,
         name=name,
         path=path,
-        security_scopes=security_scopes,
         use_cache=use_cache,
         scope=scope,
+        own_oauth_scopes=own_oauth_scopes,
+        parent_oauth_scopes=parent_oauth_scopes,
     )
+    current_scopes = (parent_oauth_scopes or []) + (own_oauth_scopes or [])
     path_param_names = get_path_param_names(path)
     endpoint_signature = get_typed_signature(call)
     signature_params = endpoint_signature.parameters
     if isinstance(call, SecurityBase):
-        use_scopes: List[str] = []
-        if isinstance(call, (OAuth2, OpenIdConnect)):
-            use_scopes = security_scopes or use_scopes
         security_requirement = SecurityRequirement(
-            security_scheme=call, scopes=use_scopes
+            security_scheme=call, scopes=current_scopes
         )
         dependant.security_requirements.append(security_requirement)
     for param_name, param in signature_params.items():
@@ -275,17 +274,16 @@ def get_dependant(
                     f'The dependency "{dependant.call.__name__}" has a scope of '
                     '"request", it cannot depend on dependencies with scope "function".'
                 )
-            use_security_scopes = security_scopes or []
+            sub_own_oauth_scopes: List[str] = []
             if isinstance(param_details.depends, params.Security):
                 if param_details.depends.scopes:
-                    use_security_scopes = use_security_scopes + list(
-                        param_details.depends.scopes
-                    )
+                    sub_own_oauth_scopes = list(param_details.depends.scopes)
             sub_dependant = get_dependant(
                 path=path,
                 call=param_details.depends.dependency,
                 name=param_name,
-                security_scopes=use_security_scopes,
+                own_oauth_scopes=sub_own_oauth_scopes,
+                parent_oauth_scopes=current_scopes,
                 use_cache=param_details.depends.use_cache,
                 scope=param_details.depends.scope,
             )
@@ -611,7 +609,7 @@ async def solve_dependencies(
                 path=use_path,
                 call=call,
                 name=sub_dependant.name,
-                security_scopes=sub_dependant.security_scopes,
+                parent_oauth_scopes=sub_dependant.oauth_scopes,
                 scope=sub_dependant.scope,
             )
 
@@ -693,7 +691,7 @@ async def solve_dependencies(
         values[dependant.response_param_name] = response
     if dependant.security_scopes_param_name:
         values[dependant.security_scopes_param_name] = SecurityScopes(
-            scopes=dependant.security_scopes
+            scopes=dependant.oauth_scopes
         )
     return SolvedDependency(
         values=values,

Test output

show
..                                                                       [100%]
=============================== 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
2 passed, 1 warning in 0.76s