diff --git a/litellm/llms/anthropic/experimental_pass_through/context_management/editors/compact.py b/litellm/llms/anthropic/experimental_pass_through/context_management/editors/compact.py index c8cbbba8784..cda01192da4 100644 --- a/litellm/llms/anthropic/experimental_pass_through/context_management/editors/compact.py +++ b/litellm/llms/anthropic/experimental_pass_through/context_management/editors/compact.py @@ -14,9 +14,9 @@ Mirrors Anthropic's native ``compact_20260112`` for non-Anthropic providers: import re from collections.abc import Mapping, Sequence -from typing import TYPE_CHECKING, Any, Final, Literal, NotRequired, Optional, TypedDict, Union, cast +from typing import TYPE_CHECKING, Any, Final, Literal, Optional, TypedDict, Union, cast -from typing_extensions import ReadOnly +from typing_extensions import NotRequired, ReadOnly import litellm from litellm._logging import verbose_logger diff --git a/litellm/proxy/common_utils/reset_budget_job.py b/litellm/proxy/common_utils/reset_budget_job.py index 4ebcc549cdd..bb098a16bc8 100644 --- a/litellm/proxy/common_utils/reset_budget_job.py +++ b/litellm/proxy/common_utils/reset_budget_job.py @@ -6,7 +6,9 @@ from dataclasses import dataclass from datetime import datetime, 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/test_litellm/test_py310_typing_imports.py b/tests/test_litellm/test_py310_typing_imports.py new file mode 100644 index 00000000000..43682274019 --- /dev/null +++ b/tests/test_litellm/test_py310_typing_imports.py @@ -0,0 +1,119 @@ +import ast +import os +import re + +# Symbols that only exist in stdlib `typing` on Python 3.11+. +# pyproject sets requires-python >=3.10, so importing these from `typing` +# on a path that runs under 3.10 breaks `import litellm`. +POST_310_TYPING = { + "NotRequired", "Required", "assert_never", "assert_type", "Self", + "LiteralString", "Never", "TypeVarTuple", "Unpack", + "dataclass_transform", "override", "TypeAliasType", "ReadOnly", "TypeIs", +} + +# Cheap text prefilter so we only AST-parse the ~400 files that could offend, +# rather than all ~2,250. A real offender must contain both patterns as text, +# so this cannot hide one. +_TYPING_IMPORT_RE = re.compile(r"from\s+typing\s+import") +_SYMBOL_RE = re.compile("|".join(sorted(POST_310_TYPING))) + +REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) +LITELLM_ROOT = os.path.join(REPO_ROOT, "litellm") + + +def _version_branch(test): + """For `if sys.version_info (3, N)`, say which branch is the new-Python one.""" + if "version_info" not in ast.dump(test): + return None + if not isinstance(test, ast.Compare) or len(test.ops) != 1: + return "both" # version check we cannot read; stay conservative + op = test.ops[0] + if isinstance(op, (ast.GtE, ast.Gt)): + return "body" + if isinstance(op, (ast.Lt, ast.LtE)): + return "orelse" + return "both" + + +def _has_import_fallback(node): + for handler in node.handlers: + if handler.type is None: + return True + names = [n.id for n in ast.walk(handler.type) if isinstance(n, ast.Name)] + if "ImportError" in names or "Exception" in names: + return True + return False + + +def _scan(node, guarded, path, bad): + """Walk the tree tracking whether we are on a 3.10-safe code path.""" + if isinstance(node, ast.ImportFrom) and node.module == "typing" and not guarded: + for alias in node.names: + if alias.name in POST_310_TYPING: + bad.append(path + ":" + str(node.lineno) + " imports " + alias.name + " from typing") + return + + if isinstance(node, ast.If): + dumped = ast.dump(node.test) + if "TYPE_CHECKING" in dumped: + # never executed at runtime, so it cannot break the import + body_guarded, else_guarded = True, guarded + else: + branch = _version_branch(node.test) + if branch == "body": + body_guarded, else_guarded = True, guarded + elif branch == "orelse": + body_guarded, else_guarded = guarded, True + elif branch == "both": + body_guarded, else_guarded = True, True + else: + body_guarded, else_guarded = guarded, guarded + for child in node.body: + _scan(child, body_guarded, path, bad) + for child in node.orelse: + _scan(child, else_guarded, path, bad) + return + + if isinstance(node, ast.Try): + # `try: from typing import X / except ImportError: from typing_extensions import X` + # is the intended fallback. Only the try body gets the pass. + fallback = _has_import_fallback(node) + for child in node.body: + _scan(child, fallback or guarded, path, bad) + for handler in node.handlers: + for child in handler.body: + _scan(child, guarded, path, bad) + for child in node.orelse + node.finalbody: + _scan(child, guarded, path, bad) + return + + for child in ast.iter_child_nodes(node): + _scan(child, guarded, path, bad) + + +def _find_bad_typing_imports(): + bad = [] + for dirpath, _, filenames in os.walk(LITELLM_ROOT): + for filename in filenames: + if not filename.endswith(".py"): + continue + path = os.path.join(dirpath, filename) + with open(path, "r", encoding="utf-8") as f: + source = f.read() + if not (_TYPING_IMPORT_RE.search(source) and _SYMBOL_RE.search(source)): + continue + try: + tree = ast.parse(source) + except SyntaxError: + continue + _scan(tree, False, os.path.relpath(path, REPO_ROOT).replace(os.sep, "/"), bad) + return bad + + +def test_no_unguarded_post_310_typing_imports(): + bad = _find_bad_typing_imports() + assert not bad, ( + "These import 3.11+ symbols from stdlib `typing` on a code path that " + "runs under Python 3.10 (requires-python >=3.10). Import them from " + "typing_extensions instead:\n" + "\n".join(bad) + )