fix(lint): guard the Router stub through the litellm export and keep TypeAliasType bare in LIT010 docs
Some checks failed
Cost map pins recheck / rerun-cost-map-guard (push) Has been cancelled
LiteLLM Rust DB / db-queries (push) Has been cancelled
LiteLLM Rust / rust-lint (push) Has been cancelled
LiteLLM Rust / rust-test (push) Has been cancelled
LiteLLM Rust / rust-wheel (push) Has been cancelled

This commit is contained in:
mateo-berri 2026-11-07 09:01:56 -08:00
parent dcb6324986
commit 17957a6b73
5 changed files with 14 additions and 11 deletions

View file

@ -101,7 +101,7 @@ Follow these coding conventions for new/updated code (a three-line fix in a lega
- Never-nester: early returns over deep nesting
- Don't throw. Model failures as values (One function (e.g., raise_public) maps error union to existing public exception contracts via exhaustive match + assert_never)
- No mutation. Don't reassign variables, global or local. Instead of mutable lists and dicts, prefer tuples, frozen dataclasses (with slots=True), `MappingProxyType`, etc.
- Annotate every variable with `: Final` (LIT010). A `TypeVar`, `ParamSpec`, or `TypeVarTuple` is the exception and stays bare, since mypy reads a Final one as `Any`. Unpacking and walrus targets cannot carry the annotation, so they are implicitly final. Don't rebind them. Never rebind or mutate function parameters (LIT011). `self`/`cls` attribute stores are the exception. If rebinding or in-place mutation is truly unavoidable, suppress with `# rebind-ok: <reason>`
- Annotate every variable with `: Final` (LIT010). A `TypeVar`, `ParamSpec`, `TypeVarTuple`, or `TypeAliasType` is the exception and stays bare, since mypy reads a Final one as a plain variable instead of a type. Unpacking and walrus targets cannot carry the annotation, so they are implicitly final. Don't rebind them. Never rebind or mutate function parameters (LIT011). `self`/`cls` attribute stores are the exception. If rebinding or in-place mutation is truly unavoidable, suppress with `# rebind-ok: <reason>`
- Qualify every TypedDict field with `ReadOnly[...]` (LIT012), which nests freely with `Required` / `NotRequired` / `Annotated` in any order. If making the key writable is truly unavoidable, suppress with `# writable-ok: <reason>`
- Comprehensions take at most one `for` clause and one `if` clause (LIT014). Split stacked clauses into a helper generator, a named intermediate, or a plain loop. Suppress with `# comprehension-ok: <reason>` only when unavoidable
- Every pydantic model must be frozen (LIT015), set via `model_config = ConfigDict(frozen=True)`, a dict-literal `model_config`, an inner `class Config`, or the class keywords. Subclasses inherit it unless they override it. Replace in-place field writes with `model_copy(update=...)`. If making the model mutable is truly unavoidable, suppress with `# frozen-ok: <reason>` on the `class` line

View file

@ -54,11 +54,11 @@ LIT010 Variable assignment without a `Final` declaration. Every local and modul
that namespace is the SDK's runtime-settable config surface (users follow
the documented `litellm.api_key = ...` pattern and the proxy rebinds these
via setattr), and the package ships py.typed, so a Final there turns every
documented downstream assignment into a mypy error. Type variables are the
reverse: a `TypeVar`, `ParamSpec`, or `TypeVarTuple` stays bare, and a Final one
trips, since mypy reads it as a plain variable and every signature using it as
Any. Suppress a deliberately rebindable name with `# rebind-ok: <reason>` on
each offending line.
documented downstream assignment into a mypy error. Type variables and type
aliases are the reverse: a `TypeVar`, `ParamSpec`, `TypeVarTuple`, or
`TypeAliasType` stays bare, and a Final one trips, since mypy reads it as a
plain variable instead of a type. Suppress a deliberately rebindable name
with `# rebind-ok: <reason>` on each offending line.
LIT011 Function-argument mutation: a parameter that is re-bound (`param = ...`,
`param += ...`, a `for`/`with`/unpacking/walrus target, `del param`, or a
re-bind in a nested function under `nonlocal param`) or mutated in place

View file

@ -15,7 +15,7 @@
# -> `make lint-e2e-basedpyright` (test-linting.yml's e2e type-check step)
# - tests/e2e and tests/e2e_harness Python
# -> raw HTTP client ban (test-linting.yml's check_e2e_no_raw_requests)
# - litellm/router.py, uv.lock
# - litellm/router.py, litellm/__init__.py, uv.lock
# -> mypy Router stub guard (test-linting.yml's check_router_constructor_stub)
# - tests/ Python, ruff-tests.toml, scripts/check_test_quality.py,
# scripts/test_quality_gate.py
@ -112,7 +112,7 @@ litellm_py_files=$(scope_match "$litellm_py_pattern")
if [ -n "$(scope_match '^(litellm/proxy/mcp_server/|scripts/check_mcp_operation_boundary\.py)')" ]; then
uv run --no-sync python scripts/check_mcp_operation_boundary.py || exit 1
fi
if [ -n "$(scope_match '^(litellm/router\.py|tests/code_coverage_tests/check_router_constructor_stub\.py|uv\.lock)$')" ]; then
if [ -n "$(scope_match '^(litellm/(router|__init__)\.py|tests/code_coverage_tests/check_router_constructor_stub\.py|uv\.lock)$')" ]; then
uv run --no-sync python tests/code_coverage_tests/check_router_constructor_stub.py || exit 1
fi
e2e_py_files=$(scope_match "$e2e_py_pattern")

View file

@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Fail unless mypy reads the Router constructor stub in litellm/router.py.
"""Fail unless mypy reads the Router constructor stub in litellm/router.py through `from litellm import Router`.
litellm ships py.typed, so a stub that mypy reads as Any accepts every argument name in SDK users' code,
while basedpyright, which `make lint-e2e-basedpyright` runs, can still read it.
@ -12,7 +12,7 @@ from pathlib import Path
from typing import Final
PROBE: Final = """\
from litellm.router import Router
from litellm import Router
Router(model_list=[], num_retries=2)
Router(model_list=[], redis_host="localhost", redis_port="6379")
@ -31,6 +31,7 @@ def mypy_errors(probe: Path) -> tuple[str, ...]:
"--config-file=",
"--follow-imports=skip",
"--ignore-missing-imports",
"litellm/__init__.py",
"litellm/router.py",
str(probe),
),

View file

@ -454,7 +454,9 @@ def test_an_e2e_type_check_input_alone_runs_the_e2e_basedpyright_step(tmp_path:
assert "ran: tests/e2e basedpyright" in proc.stdout
@pytest.mark.parametrize(("changed", "runs"), (("litellm/router.py", True), ("litellm/foo.py", False)))
@pytest.mark.parametrize(
("changed", "runs"), (("litellm/router.py", True), ("litellm/__init__.py", True), ("litellm/foo.py", False))
)
def test_the_router_stub_guard_runs_only_when_the_router_changes(tmp_path: Path, changed: str, runs: bool) -> None:
repo, bin_dir = _sandbox(tmp_path)
_commit_all(repo, "base")