diff --git a/tests/test_litellm/proxy/management_endpoints/management_v1/test_common.py b/tests/test_litellm/proxy/management_endpoints/management_v1/test_common.py index 167a06ed551..f3515e84d0d 100644 --- a/tests/test_litellm/proxy/management_endpoints/management_v1/test_common.py +++ b/tests/test_litellm/proxy/management_endpoints/management_v1/test_common.py @@ -1,11 +1,18 @@ +import ast +import importlib.util +from pathlib import Path +from types import ModuleType from typing import Annotated +import fastapi.dependencies.utils as fastapi_dependency_utils +import pytest from fastapi import Depends, FastAPI, Header, Query, Request from fastapi.testclient import TestClient +import litellm.proxy.management_endpoints.management_v1.common as common_module from litellm.proxy.management_endpoints.management_v1.common import ( - ManagementProblem, PROBLEM_CONTENT_TYPE, + ManagementProblem, _declared_query_params, problem_response, reject_unknown_query_params, @@ -93,3 +100,57 @@ def test_declared_query_params_is_empty_when_the_route_has_no_dependant(): } ) assert _declared_query_params(request) == frozenset() + + +# fastapi removed these in 0.140.7, which `pyproject.toml` still allows via +# `fastapi>=0.136.3,<1.0`. Add a name here whenever a supported release drops one. +FASTAPI_NAMES_REMOVED_IN_0_140_7 = frozenset({"get_flat_dependant"}) + +MANAGEMENT_V1_PACKAGE = Path(str(common_module.__file__)).parent + + +def _public_names(module: ModuleType) -> frozenset[str]: + return frozenset(name for name in vars(module) if not name.startswith("_")) + + +def _fastapi_names_imported_by(source_file: Path) -> frozenset[str]: + tree = ast.parse(source_file.read_text()) + return frozenset( + alias.name + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) and (node.module or "").startswith("fastapi") + for alias in node.names + ) + + +@pytest.mark.parametrize( + "source_file", sorted(MANAGEMENT_V1_PACKAGE.glob("*.py")), ids=lambda path: path.name +) +def test_no_module_imports_a_fastapi_name_removed_in_a_supported_release(source_file: Path): + """`pyproject.toml` allows fastapi up to <1.0, but CI only ever resolves 0.136.3. + + Every other test here passes just as well against a module importing a name + fastapi has since deleted, because the pinned fastapi still has it. On a user's + fastapi>=0.140.7 that import is an ImportError, and `proxy_server` imports this + package unguarded at module level, so it takes the whole proxy down rather than + just these routes. Globbing the package means a new module is covered on sight. + """ + assert not _fastapi_names_imported_by(source_file) & FASTAPI_NAMES_REMOVED_IN_0_140_7 + + +def test_common_still_imports_when_fastapi_has_dropped_those_names(monkeypatch: pytest.MonkeyPatch): + """The static check above cannot prove the module actually loads; this does. + + Behaviour cannot be asserted under the same simulation: on 0.136.3 + `get_flat_params` calls `get_flat_dependant` internally, so it raises NameError + once the name is gone. Loading is the part this pins. + """ + for name in FASTAPI_NAMES_REMOVED_IN_0_140_7: + monkeypatch.delattr(fastapi_dependency_utils, name, raising=False) + spec = importlib.util.spec_from_file_location( + "management_v1_common__simulated_fastapi", Path(str(common_module.__file__)) + ) + assert spec is not None and spec.loader is not None + reimported = importlib.util.module_from_spec(spec) + spec.loader.exec_module(reimported) + assert _public_names(reimported) == _public_names(common_module)