From 2e7518af0d0d91454459d751e3f7228de662b358 Mon Sep 17 00:00:00 2001 From: Priyansh Nandwana Date: Thu, 27 Aug 2026 18:28:13 +0530 Subject: [PATCH] fix(proxy): import assert_never and NotRequired from typing_extensions typing.assert_never and typing.NotRequired only exist from Python 3.11, but pyproject.toml declares requires-python >=3.10. Both are imported at module scope on the proxy start path, so `import litellm.proxy.proxy_server` raises ImportError on 3.10 and the proxy does not come up at all. Import them from typing_extensions, which 191 other modules already do, and add a code-quality gate so a 3.11-only stdlib name cannot be reintroduced unguarded. Fixes #38202 --- .github/workflows/test-code-quality.yml | 3 + .../proxy/common_utils/reset_budget_job.py | 4 +- .../batch_file_validation.py | 4 +- .../check_python_min_version_imports.py | 115 ++++++++++++++++++ 4 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 tests/code_coverage_tests/check_python_min_version_imports.py diff --git a/.github/workflows/test-code-quality.yml b/.github/workflows/test-code-quality.yml index c112bf2bb22..ee47891fe50 100644 --- a/.github/workflows/test-code-quality.yml +++ b/.github/workflows/test-code-quality.yml @@ -74,6 +74,9 @@ jobs: - name: check_workflow_startup_safety run: uv run --no-sync python ./tests/code_coverage_tests/check_workflow_startup_safety.py + - name: check_python_min_version_imports + run: uv run --no-sync python ./tests/code_coverage_tests/check_python_min_version_imports.py + - name: router_code_coverage run: uv run --no-sync python ./tests/code_coverage_tests/router_code_coverage.py diff --git a/litellm/proxy/common_utils/reset_budget_job.py b/litellm/proxy/common_utils/reset_budget_job.py index 1682cf12f4e..47f69732e95 100644 --- a/litellm/proxy/common_utils/reset_budget_job.py +++ b/litellm/proxy/common_utils/reset_budget_job.py @@ -7,7 +7,9 @@ from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone from enum import Enum from types import MappingProxyType -from typing import Final, Literal, Protocol, TypeVar, assert_never +from typing import Final, Literal, Protocol, TypeVar + +from typing_extensions import assert_never import litellm from litellm._logging import verbose_proxy_logger diff --git a/litellm/proxy/openai_files_endpoints/batch_file_validation.py b/litellm/proxy/openai_files_endpoints/batch_file_validation.py index 0aee5e8cc54..a41bd36d510 100644 --- a/litellm/proxy/openai_files_endpoints/batch_file_validation.py +++ b/litellm/proxy/openai_files_endpoints/batch_file_validation.py @@ -2,7 +2,9 @@ import json from collections.abc import Iterator from dataclasses import dataclass from itertools import chain -from typing import BinaryIO, Final, NoReturn, assert_never +from typing import BinaryIO, Final, NoReturn + +from typing_extensions import assert_never from litellm.proxy._types import ProxyException diff --git a/tests/code_coverage_tests/check_python_min_version_imports.py b/tests/code_coverage_tests/check_python_min_version_imports.py new file mode 100644 index 00000000000..8296471d7b6 --- /dev/null +++ b/tests/code_coverage_tests/check_python_min_version_imports.py @@ -0,0 +1,115 @@ +""" +`pyproject.toml` declares `requires-python = ">=3.10"`, but several stdlib names +only exist from 3.11 on. Importing one unguarded from `typing`, `enum` or +`asyncio` raises ImportError at module import time, which on a proxy start path +takes the whole proxy down on an interpreter we claim to support. + +Import these from `typing_extensions` instead, as the rest of the codebase does, +or gate the import on `sys.version_info`. +""" + +import ast +import os +from typing import Any + +SCAN_DIR = "litellm" + +# Added in 3.11; absent from the 3.10 stdlib +PY311_ONLY = { + "typing": { + "NotRequired", + "Required", + "Self", + "LiteralString", + "Never", + "assert_never", + "assert_type", + "TypeVarTuple", + "Unpack", + "dataclass_transform", + "reveal_type", + "get_overloads", + "clear_overloads", + }, + "enum": {"StrEnum", "ReprEnum", "EnumCheck", "verify", "member", "nonmember", "global_enum"}, + "asyncio": {"TaskGroup", "Runner", "Barrier"}, +} + +SUGGESTED_SOURCE = { + "typing": "typing_extensions", + "enum": "backports.strenum or a plain str/Enum subclass", + "asyncio": "a sys.version_info guard", +} + + +def _is_version_guarded(tree: ast.Module, lineno: int) -> bool: + """True when the import sits inside an `if sys.version_info ...` branch.""" + for node in ast.walk(tree): + if not isinstance(node, ast.If): + continue + source = ast.dump(node.test) + if "version_info" not in source: + continue + for branch in (node.body, node.orelse): + for stmt in branch: + start = getattr(stmt, "lineno", None) + end = getattr(stmt, "end_lineno", start) + if start is not None and end is not None and start <= lineno <= end: + return True + return False + + +def _violations_in_file(file_path: str) -> list[dict[str, Any]]: + try: + with open(file_path, "r", encoding="utf-8") as f: + source = f.read() + tree = ast.parse(source, filename=file_path) + except Exception: + return [] + + results: list[dict[str, Any]] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.ImportFrom) or node.module not in PY311_ONLY: + continue + flagged = sorted({a.name for a in node.names} & PY311_ONLY[node.module]) + if not flagged or _is_version_guarded(tree, node.lineno): + continue + results.append( + { + "file": os.path.normpath(file_path), + "line": node.lineno, + "module": node.module, + "names": flagged, + } + ) + return results + + +def scan_directory(base_dir: str) -> list[dict[str, Any]]: + violations: list[dict[str, Any]] = [] + for root, _dirs, files in os.walk(os.path.join(base_dir, SCAN_DIR)): + for name in files: + if name.endswith(".py"): + violations.extend(_violations_in_file(os.path.join(root, name))) + return sorted(violations, key=lambda v: (v["file"], v["line"])) + + +def main() -> None: + base_dir = "." # tests run from repo root in CI + violations = scan_directory(base_dir) + if violations: + print("\n🚨 Found stdlib imports that do not exist on Python 3.10:") + for v in violations: + names = ", ".join(v["names"]) + suggestion = SUGGESTED_SOURCE[v["module"]] + print(f"* {v['file']}:{v['line']} -> from {v['module']} import {names} (use {suggestion})") + print("\n") + raise Exception( + "pyproject.toml declares requires-python >=3.10, so these imports break " + "`import litellm` on a supported interpreter." + ) + print("No Python 3.11+ stdlib imports found outside a version guard.") + + +if __name__ == "__main__": + main()