ci: ratchet lint and type-check gates (ruff preview, ANN, mypy, basedpyright) (#30379)

* ci: enable ruff preview rules under the budgeted strict gate

Turn on ruff preview in the strict-budget lane (ruff-strict.toml) only,
leaving the clean gate (ruff.toml) untouched so make lint-ruff stays at
zero. Enumerate the 118 firing codes explicitly with
explicit-preview-rules so the gate is deterministic and stable across
ruff upgrades rather than depending on preview auto-selecting the broad
catalog.

Grandfather the existing 58438 violations into ruff-strict-budget.json
as per-rule baselines with headroom, so only net-new violations fail CI.
The existing ten rules keep their hand-tuned slack; the new rules get
slack 10 when the baseline is 50 or more and 3 otherwise.

* ci: add ANN return-type rules to the budgeted strict gate

Add ANN201/202/204/205/206 (missing return annotations) to the strict
lane and grandfather the existing counts into ruff-strict-budget.json so
the codebase ratchets toward explicit return types without breaking CI.

* ci: add mypy (disallow_untyped_defs) and basedpyright strict gates with baselines

Add two type-check gates, each grandfathering the current tree so only
net-new violations fail CI, matching the ruff strict-budget ratchet.

mypy gains disallow_untyped_defs in litellm/mypy.ini (the config the CI
invocation actually reads; the root [tool.mypy] is not picked up from the
litellm/ working dir). The 4885 existing missing-annotation errors are
captured in litellm/.mypy-baseline.txt and the run is piped through
mypy-baseline filter so new untyped defs are rejected.

basedpyright runs in strict mode over litellm/, with
enableTypeIgnoreComments disabled so it only honors '# pyright: ignore'
and never polices mypy's '# type: ignore'. The existing strict diagnostics
are grandfathered into .basedpyright/baseline.json.

Both tools are pinned in the dev group and uv.lock; the lint workflow and
Makefile run them filtered through their baselines, with
lint-mypy-baseline-update and lint-basedpyright-baseline-update to ratchet.

* ci: raise lint job timeout to 15m for the basedpyright strict pass

* ci: pin pythonVersion 3.12 and regenerate baselines against merged base

Merge litellm_internal_staging so the baselines cover code the CI merge
includes (e.g. the cisco_ai_defense guardrail), which otherwise tripped
the mypy gate with 3 ungrandfathered no-untyped-def errors. Pin
pythonVersion 3.12 in pyrightconfig so basedpyright's strict analysis is
reproducible across interpreter versions (CI runs 3.12).

* ci: regenerate basedpyright baseline against the frozen lint env

The previous baseline was generated with optional provider deps (azure,
google, anthropic, mcp, numpydoc, google-genai) installed locally, so CI's
dev-only env surfaced ~3500 reportUnknown*/reportMissingTypeStubs errors
not in the baseline. Regenerate after uv sync --frozen so the baseline
reflects the same dependency set the lint job sees.

* ci: regenerate basedpyright baseline on python 3.12 frozen env

The prior baseline still carried proxy-dev packages (e.g. prisma) that the
lint job's dev-only, python 3.12 env lacks, leaving 2 unresolved-import
errors ungrandfathered. Regenerate in a python 3.12 venv synced to the
frozen lock with default groups only, so the baseline matches exactly what
CI sees.

* ci: replace type-check baselines with per-file count budgets

The mypy and basedpyright baselines were position-sensitive (and the
basedpyright one was a 27MB file), so ordinary line shifts churned them.
Replace both with a per-file count gate: scripts/type_check_gate.py reduces
each tool's output to errors-per-file and checks it against a committed
{file: max} budget, ignoring line and column numbers. A file fails only
when it gains more errors than its ceiling; debt can't be shuffled between
files because each file has its own cap and new files default to zero.

Budgets (mypy-file-budget.json 48K, basedpyright-file-budget.json 96K) are
generated in the python 3.12 frozen lint env so they match CI. Drops the
mypy-baseline dependency; basedpyright runs without its native baseline.
ratchet via make lint-mypy-budget-update / lint-basedpyright-budget-update.

* ci: add a small per-file slack to the type-check gate

Allow each file to drift PER_FILE_SLACK (5) errors past its recorded count
before failing, so a basedpyright inference ripple in an unrelated file
doesn't break the build over a couple of errors. Budgets still record exact
counts; the tolerance is applied at check time.

* ci: move type-check slack into the budget json and trim lint timeout

Make slack declarative: the budget is now {"slack": N, "files": {path: count}}
so the tolerance is tuned in JSON without editing the script, mirroring how
ruff-strict-budget.json carries its slack. --update preserves the existing
slack. Also drop the lint job timeout from 15m to 10m; the mypy and
basedpyright passes add ~2m, leaving the job around 4-5m, so 10m is a
comfortable margin.

* ci: collapse fully-adopted ruff categories and drop inert preview flag

ANN (all nine non-removed rules) and BLE (its only rule) were spelled out
code-by-code; replace each with its category selector, which is exactly
equivalent in 0.15.3 (the removed ANN101/ANN102 are skipped by a category
selector and error when named explicitly). explicit-preview-rules was inert:
every selected rule is stable and nothing is selected by category, so the flag
had nothing to gate. Verified the strict-rule counts are identical before and
after (62379 each, zero per-rule drift), so no budget change.

* ci: drop redundant pyright dev dependency

Nothing invokes bare pyright in the Makefile, the linting workflow, or
scripts; the basedpyright gate added on this branch is the only type
checker that runs. basedpyright is a superset fork that reads the same
pyrightconfig.json and honors the same "# pyright: ignore" comments, so
pyright==1.1.408 in the ci group was dead weight. Regenerated uv.lock
under the same exclude-newer cutoff so the only change is removing
pyright and its package stanza

* ci: un-weaken mypy and error on Any in basedpyright

mypy: enable warn_return_any, drop the valid-type silencer, and stop globally ignoring missing first-party imports via [mypy-litellm.*] ignore_missing_imports = False, which surfaced eight real broken litellm.* imports the blanket ignore was hiding; third-party imports stay ignored. The per-file budget moves 4888 -> 5799 (902 no-any-return, 1 valid-type, 8 import-not-found), all grandfathered so only net-new errors fail and the ceilings ratchet down

basedpyright: error on reportExplicitAny and reportAny. The per-file budget moves 117033 -> 148946 (6931 explicit-Any, 24954 Any-typed expressions), grandfathered the same way

* ci: add Any-discipline gate on changed lines under litellm/

Add scripts/check_any_discipline.py, a type-aware gate that fails when a
changed line holds a value typed Any -- including the X | Any unions that
mypy --strict / basedpyright accept (e.g. re.Match.group() -> str | Any,
json.loads() -> Any, bare dict -> dict[Any, Any]).

It reuses the repo's mypyc-compiled mypy 1.19 via a custom generic AST
walker (mypyc precludes subclassing TraverserVisitor), loads litellm/mypy.ini
for parity with lint-mypy, and uses a dedicated incremental cache
(.mypy_cache_any) with mtime+hash invalidation to force re-checks. Scope is
changed-lines-only so editing a legacy file never forces cleaning its
existing Any debt; suppress a genuine typed/untyped boundary with
# any-ok: <reason> (ANY002 requires the reason).

Wire it into the Makefile (lint-any, lint, lint-dev), a parallel
any-discipline CI job with its own actions/cache, .gitignore, and the
CLAUDE.md / CONTRIBUTING.md docs.

* ci: move Any-gate codes into the shared LIT namespace

Renumber the Any-discipline checker into the LIT*** scheme owned by
scripts/check_type_discipline.py (PR #30500) so the two checkers share one
rule namespace and suppression convention:

  ANY001 -> LIT002  (Any-typed value; LIT002 was the retired/free slot)
  ANY002 -> LIT005  (any-ok without a reason; the shared suppression-reason code)
  ANY000 -> LIT000  (setup/build/read error; the shared error code)

Messages and behavior are unchanged; LIT005's text already matches the
"<token> requires a reason" shape used for cast-ok/guard-ok.

* ci: gate mypy and basedpyright per error rule, not per file

Switch the mypy/basedpyright budget gate from per-file error counts to
per-rule-code totals, mirroring the {rule: {baseline, slack}} shape of
ruff-strict-budget.json. A rule fails when its codebase-wide error count
exceeds baseline + slack, so violations are tracked by category rather
than by file location.

scripts/type_check_gate.py now parses mypy from its text output (trailing
[code]) and basedpyright from --outputjson (the JSON `rule` field), since
basedpyright's wrapped text diagnostics mis-attribute the rule on
continuation lines. Replace the *-file-budget.json files with freshly
captured *-code-budget.json baselines and update the Makefile, CI, and
CLAUDE.md accordingly.

* docs: prefer Pydantic validation over any-ok suppression

Point the Any-discipline guidance at validating Any with Pydantic (a model
or TypeAdapter that returns a typed value or raises) and frame
# any-ok as a last resort that should ideally never be used.

* chore: remove extraneous comment

* chore: make the CLAUDE.md more concise

* chore: clean up bloated CONTRIBUTING.md additions

* chore: make Makefile more concise

* ci: add the lint-budget-update target CLAUDE.md references

CLAUDE.md tells contributors to run make lint-budget-update, but the
target was never defined. Add it as an aggregate that re-captures the
ruff, mypy, and basedpyright budgets in one shot.

* ci: recapture mypy and basedpyright budgets in the lint env

The per-rule baselines were captured in a richer dependency env than the
CI lint job's uv sync --frozen, so CI resolved fewer types and reported
more errors than the budgets allowed (no-any-return 902 over cap 900, plus
several basedpyright reportUnknown* rules). Regenerate both in the frozen
env so they grandfather the true CI debt: mypy 5786 -> 5799 (no-any-return
890 -> 902, valid-type 1 restored), basedpyright 146213 -> 148942.

* ci: check out PR head sha in lint and any-discipline jobs

The default pull_request checkout uses refs/pull/N/merge, which folds the
latest base commits into HEAD. The diff-based gates (ruff delta, Any
discipline) then diff against the event's older base.sha and blame base's
own new commits on this branch; staging's otel-v2 and streaming changes
(#30326, #30485) tripped the Any gate on files this branch never touched.
Checking out the PR head sha makes the gates diff the real branch tip
against base, and pins the tree the mypy/basedpyright budgets were captured
against so their counts stay deterministic as the base advances.

* ci(lint): renumber Any-typed-value rule LIT002 -> LIT009

Free up LIT002 for the sibling type-discipline gate (check_type_discipline.py,
#30500), which groups its mutable-collection family at LIT001 (annotation) and
LIT002 (construction). This gate's Any-typed-value rule moves to LIT009 so the
shared LIT namespace stays contiguous with no holes; LIT000 and LIT005 are
unchanged.

* style: rename lint-strict-budget -> lint-ruff-budget

* ci: harden type-check gates against silent passes (greptile review)

type_check_gate.py: refuse to certify a vacuous run. The CI pipe swallows
the tool's exit code ('tool || true'), so a crashed mypy/basedpyright that
emits nothing would parse to zero errors, breach no ceiling, and pass.
is_vacuous_run() now fails when nothing was parsed but the budget expects
errors. Also wrap basedpyright's json.loads in a JSONDecodeError handler
that prints the offending output instead of dumping a raw traceback.

check_any_discipline.py: ALL_LINES was None, which dict.get() also returns
for a path absent from the line map, so a path-normalisation mismatch could
let a violation on an unchanged file pass the scope filter. Make ALL_LINES a
distinct sentinel object so 'whole file' and 'path missing' are unambiguous.

Adds tests for all three.
This commit is contained in:
Mateo Wang 2026-06-16 12:07:46 -07:00 committed by GitHub
parent 9c775e7304
commit d0c2e87810
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1773 additions and 47 deletions

View file

@ -14,11 +14,15 @@ permissions:
jobs:
lint:
runs-on: ubuntu-latest
timeout-minutes: 5
timeout-minutes: 10
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
# Check out the PR head, not the default refs/pull/N/merge: the merge ref
# folds in newer base commits, which the diff-based gates (ruff delta,
# Any-discipline) would otherwise blame on this branch.
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
clean: true
persist-credentials: false
@ -80,8 +84,11 @@ jobs:
- name: Run MyPy type checking
run: |
cd litellm
uv run --no-sync mypy .
cd ..
(uv run --no-sync mypy . || true) | uv run --no-sync python ../scripts/type_check_gate.py --tool mypy
- name: Run basedpyright type checking
run: |
(uv run --no-sync basedpyright --outputjson || true) | uv run --no-sync python scripts/type_check_gate.py --tool basedpyright
- name: Check for circular imports
run: |
@ -93,6 +100,56 @@ jobs:
run: |
uv run --no-sync python -c "from litellm import *" || (echo '🚨 import failed, this means you introduced unprotected imports! 🚨'; exit 1)
any-discipline:
# Separate job: the first run cold-builds litellm's type cache (~2 min, ~3 GB),
# so keep it off the main lint job's time budget. Subsequent runs reuse the
# cached .mypy_cache_any and only re-type-check the changed files.
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
# Check out the PR head, not the default refs/pull/N/merge: the merge ref
# folds in newer base commits, which the diff-based gates (ruff delta,
# Any-discipline) would otherwise blame on this branch.
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
clean: true
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Set up uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
with:
version: "0.10.9"
- name: Install dependencies
run: |
uv sync --frozen
# Keyed on deps + mypy config (which fix the type cache's validity), not on
# source content, so changed files always differ from the restored cache.
# The gate also defensively invalidates each target's cache entry, so
# correctness never depends on cache freshness -- this is purely for speed.
- name: Restore Any-gate type cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: .mypy_cache_any
key: any-mypy-cache-${{ runner.os }}-py3.12-${{ hashFiles('uv.lock', 'litellm/mypy.ini') }}
restore-keys: |
any-mypy-cache-${{ runner.os }}-py3.12-
- name: Check Any discipline on changed lines
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
uv run --no-sync python scripts/check_any_discipline.py --changed --base "$BASE_SHA"
secret-scan:
runs-on: ubuntu-latest
timeout-minutes: 5

1
.gitignore vendored
View file

@ -75,6 +75,7 @@ tests/local_testing/log.txt
litellm/proxy/_new_new_secret_config.yaml
litellm/proxy/custom_guardrail.py
**/.mypy_cache/
**/.mypy_cache_any/
litellm/proxy/application.log
tests/llm_translation/vertex_test_account.json
tests/llm_translation/test_vertex_key.json

View file

@ -36,7 +36,11 @@ Don't hesitate to use values in .env to get needed API keys and other secrets, a
Run tests, format your code, and lint your code before each commit
When you fix strict-rule violations gated by `ruff-strict-budget.json`, run `make lint-strict-budget-update` and commit the lowered baselines so the ceilings ratchet down instead of leaving stale headroom
When you fix violations gated by `ruff-strict-budget.json`, `mypy-code-budget.json`, or `basedpyright-code-budget.json`, run `make lint-budget-update` and commit the lowered baselines so the ceilings ratchet down instead of leaving stale headroom
If you're trying to create a new function that relies on untyped stuff, instead of adding more Any's and bringing it closer to the max, just validate it in the caller with Pydantic (a model or `TypeAdapter` that returns the typed thing or raises will do) and then pass the now typed variable in
The Any-discipline gate (`make lint-any`, also a CI job) fails when a line you changed under `litellm/` holds a value typed `Any`, including the `X | Any`. Ideally `# any-ok: <reason>` is never used; treat it as a last resort for a genuine typed/untyped boundary that Pydantic truly can't model
Ask to commit and push your work when you're done (or if you're confident that your code is good and works, just do it)
@ -67,8 +71,6 @@ Follow these coding conventions for new/updated code (a three-line fix in a lega
- No file sprawl: deliberate file and folder structure
- Standard over hand-rolled: use the official SDK or a library where one exists; where none does, follow industry standards instead of inventing local conventions
if you're trying to create a new function that relies on untyped stuff, instead of adding more Any's and bringing it closer to the max, just validate it in the caller (a simple function that returns the typed thing or raises will do) and then pass the now typed variable in
Follow conventional commits for commit names and PR titles
## Think Before Coding

View file

@ -155,6 +155,7 @@ Individual linting commands:
make format-check # Check Black formatting
make lint-ruff # Run Ruff linting
make lint-mypy # Run MyPy type checking
make lint-any # Fail on Any-typed values on changed lines
make check-circular-imports # Check for circular imports
make check-import-safety # Check import safety
```

View file

@ -5,7 +5,8 @@
test-unit-integrations test-unit-core-utils test-unit-other test-unit-root \
test-proxy-unit-a test-proxy-unit-b test-integration test-unit-helm \
info lint lint-dev format \
lint-strict-budget lint-strict-budget-update \
lint-mypy lint-mypy-budget-update lint-basedpyright lint-basedpyright-budget-update \
lint-ruff-budget lint-ruff-budget-update lint-budget-update lint-any \
install-dev install-proxy-dev install-test-deps install-hooks \
install-helm-unittest check-circular-imports check-import-safety
@ -23,10 +24,15 @@ help:
@echo " make format-check - Check Black code formatting (matches CI)"
@echo " make lint - Run all linting (Ruff, MyPy, Black check, circular imports, import safety)"
@echo " make lint-ruff - Run Ruff linting only"
@echo " make lint-mypy - Run MyPy type checking only"
@echo " make lint-mypy - Run MyPy (disallow_untyped_defs), gated by per-rule error counts"
@echo " make lint-mypy-budget-update - Re-capture the MyPy per-rule budget (ratchet)"
@echo " make lint-basedpyright - Run basedpyright strict, gated by per-rule error counts"
@echo " make lint-basedpyright-budget-update - Re-capture the basedpyright per-rule budget (ratchet)"
@echo " make lint-black - Check Black formatting (matches CI)"
@echo " make lint-strict-budget - Gate the codebase total of each strict ruff rule against its ceiling"
@echo " make lint-strict-budget-update - Re-capture per-rule baselines in ruff-strict-budget.json (ratchet)"
@echo " make lint-ruff-budget - Gate the codebase total of each strict ruff rule against its ceiling"
@echo " make lint-ruff-budget-update - Re-capture per-rule baselines in ruff-strict-budget.json (ratchet)"
@echo " make lint-budget-update - Re-capture all three ratchet budgets (ruff + mypy + basedpyright)"
@echo " make lint-any - Fail if changed lines under litellm/ hold an Any-typed value"
@echo " make check-circular-imports - Check for circular imports"
@echo " make check-import-safety - Check import safety"
@echo " make test - Run all tests"
@ -121,16 +127,31 @@ lint-ruff-FULL-dev: install-dev
else echo "No changed .py files to check."; fi
lint-mypy: install-dev
cd litellm && $(UV_RUN) mypy . --ignore-missing-imports && cd ..
cd litellm && ($(UV_RUN) mypy . || true) | $(UV_RUN) python ../scripts/type_check_gate.py --tool mypy
lint-mypy-budget-update: install-dev
cd litellm && ($(UV_RUN) mypy . || true) | $(UV_RUN) python ../scripts/type_check_gate.py --tool mypy --update
lint-basedpyright: install-dev
($(UV_RUN) basedpyright --outputjson || true) | $(UV_RUN) python scripts/type_check_gate.py --tool basedpyright
lint-basedpyright-budget-update: install-dev
($(UV_RUN) basedpyright --outputjson || true) | $(UV_RUN) python scripts/type_check_gate.py --tool basedpyright --update
lint-black: format-check
lint-strict-budget: install-dev
lint-ruff-budget: install-dev
$(UV_RUN) python scripts/ruff_strict_gate.py
lint-strict-budget-update: install-dev
lint-ruff-budget-update: install-dev
$(UV_RUN) python scripts/ruff_strict_gate.py --update
# Ratchet all three budgets in one shot (ruff strict + mypy + basedpyright)
lint-budget-update: lint-ruff-budget-update lint-mypy-budget-update lint-basedpyright-budget-update
lint-any: install-dev
$(UV_RUN) python scripts/check_any_discipline.py --changed
check-circular-imports: install-dev
cd litellm && $(UV_RUN) python ../tests/documentation_tests/test_circular_imports.py && cd ..
@ -138,10 +159,10 @@ check-import-safety: install-dev
@$(UV_RUN) python -c "from litellm import *; print('[from litellm import *] OK! no issues!');" || (echo '🚨 import failed, this means you introduced unprotected imports! 🚨'; exit 1)
# Combined linting (matches test-linting.yml workflow)
lint: format-check lint-ruff lint-mypy check-circular-imports check-import-safety lint-strict-budget
lint: format-check lint-ruff lint-mypy lint-basedpyright check-circular-imports check-import-safety lint-ruff-budget lint-any
# Faster linting for local development (only checks changed code)
lint-dev: lint-format-changed lint-mypy check-circular-imports check-import-safety
lint-dev: lint-format-changed lint-mypy lint-any check-circular-imports check-import-safety
# Testing targets
test: install-test-deps

View file

@ -0,0 +1,194 @@
{
"reportAny": {
"baseline": 24954,
"slack": 10
},
"reportArgumentType": {
"baseline": 1863,
"slack": 3
},
"reportAssignmentType": {
"baseline": 220,
"slack": 3
},
"reportAttributeAccessIssue": {
"baseline": 335,
"slack": 3
},
"reportCallIssue": {
"baseline": 77,
"slack": 10
},
"reportConstantRedefinition": {
"baseline": 39,
"slack": 3
},
"reportDeprecated": {
"baseline": 217,
"slack": 10
},
"reportDuplicateImport": {
"baseline": 28,
"slack": 3
},
"reportExplicitAny": {
"baseline": 6931,
"slack": 10
},
"reportFunctionMemberAccess": {
"baseline": 7,
"slack": 3
},
"reportGeneralTypeIssues": {
"baseline": 151,
"slack": 3
},
"reportIncompatibleMethodOverride": {
"baseline": 52,
"slack": 10
},
"reportIncompatibleVariableOverride": {
"baseline": 8,
"slack": 3
},
"reportInconsistentOverload": {
"baseline": 12,
"slack": 3
},
"reportIndexIssue": {
"baseline": 26,
"slack": 3
},
"reportInvalidTypeForm": {
"baseline": 23,
"slack": 3
},
"reportInvalidTypeVarUse": {
"baseline": 2,
"slack": 3
},
"reportMatchNotExhaustive": {
"baseline": 1,
"slack": 3
},
"reportMissingParameterType": {
"baseline": 3933,
"slack": 10
},
"reportMissingTypeArgument": {
"baseline": 10612,
"slack": 10
},
"reportMissingTypeStubs": {
"baseline": 27,
"slack": 10
},
"reportOperatorIssue": {
"baseline": 6,
"slack": 3
},
"reportOptionalCall": {
"baseline": 4,
"slack": 3
},
"reportOptionalIterable": {
"baseline": 3,
"slack": 3
},
"reportOptionalMemberAccess": {
"baseline": 724,
"slack": 10
},
"reportOptionalOperand": {
"baseline": 3,
"slack": 3
},
"reportOptionalSubscript": {
"baseline": 11,
"slack": 3
},
"reportPossiblyUnboundVariable": {
"baseline": 52,
"slack": 10
},
"reportPrivateUsage": {
"baseline": 1625,
"slack": 10
},
"reportRedeclaration": {
"baseline": 8,
"slack": 3
},
"reportReturnType": {
"baseline": 118,
"slack": 10
},
"reportTypedDictNotRequiredAccess": {
"baseline": 20,
"slack": 3
},
"reportUndefinedVariable": {
"baseline": 2,
"slack": 3
},
"reportUnknownArgumentType": {
"baseline": 30603,
"slack": 10
},
"reportUnknownLambdaType": {
"baseline": 76,
"slack": 10
},
"reportUnknownMemberType": {
"baseline": 27322,
"slack": 10
},
"reportUnknownParameterType": {
"baseline": 13636,
"slack": 10
},
"reportUnknownVariableType": {
"baseline": 21776,
"slack": 10
},
"reportUnnecessaryCast": {
"baseline": 118,
"slack": 10
},
"reportUnnecessaryComparison": {
"baseline": 680,
"slack": 10
},
"reportUnnecessaryContains": {
"baseline": 4,
"slack": 3
},
"reportUnnecessaryIsInstance": {
"baseline": 807,
"slack": 10
},
"reportUntypedBaseClass": {
"baseline": 110,
"slack": 3
},
"reportUntypedFunctionDecorator": {
"baseline": 22,
"slack": 3
},
"reportUnusedClass": {
"baseline": 22,
"slack": 3
},
"reportUnusedFunction": {
"baseline": 137,
"slack": 10
},
"reportUnusedImport": {
"baseline": 670,
"slack": 10
},
"reportUnusedVariable": {
"baseline": 865,
"slack": 10
}
}

View file

@ -1,13 +1,16 @@
[mypy]
warn_return_any = False
warn_return_any = True
ignore_missing_imports = True
disallow_untyped_defs = True
mypy_path = litellm/stubs
namespace_packages = True
disable_error_code =
valid-type,
annotation-unchecked,
import-untyped
[mypy-litellm.*]
ignore_missing_imports = False
[mypy-google.*]
ignore_missing_imports = True

18
mypy-code-budget.json Normal file
View file

@ -0,0 +1,18 @@
{
"import-not-found": {
"baseline": 8,
"slack": 3
},
"no-any-return": {
"baseline": 902,
"slack": 10
},
"no-untyped-def": {
"baseline": 4888,
"slack": 10
},
"valid-type": {
"baseline": 1,
"slack": 3
}
}

View file

@ -149,6 +149,7 @@ dev = [
"flake8==7.3.0",
"black==26.3.1",
"mypy==1.19.0",
"basedpyright==1.39.7",
"pytest==9.0.3",
"pytest-mock==3.15.1",
"pytest-asyncio==1.3.0",
@ -220,7 +221,6 @@ ci = [
"blockbuster==1.5.26",
"beautifulsoup4==4.14.3",
"pylint==4.0.5",
"pyright==1.1.408",
"langchain-mcp-adapters==0.2.1",
"langchain-openai==1.1.14",
"langgraph==1.0.10",

View file

@ -1,7 +1,12 @@
{
"include": ["litellm"],
"ignore": [],
"exclude": ["**/node_modules", "**/__pycache__", "litellm/types/utils.py", "litellm/proxy/_types.py"],
"pythonVersion": "3.12",
"typeCheckingMode": "strict",
"enableTypeIgnoreComments": false,
"reportMissingImports": false,
"reportPrivateImportUsage": false
"reportPrivateImportUsage": false,
"reportExplicitAny": "error",
"reportAny": "error"
}

View file

@ -1,12 +1,494 @@
{
"ANN001": { "baseline": 2865, "slack": 10 },
"ANN002": { "baseline": 64, "slack": 3 },
"ANN003": { "baseline": 759, "slack": 10 },
"ANN401": { "baseline": 1885, "slack": 10 },
"B006": { "baseline": 180, "slack": 3 },
"C901": { "baseline": 301, "slack": 3 },
"PLR0913": { "baseline": 1813, "slack": 3 },
"PLW0603": { "baseline": 183, "slack": 3 },
"RUF012": { "baseline": 158, "slack": 3 },
"TID251": { "baseline": 2404, "slack": 10 }
"ANN001": {
"baseline": 2865,
"slack": 10
},
"ANN002": {
"baseline": 64,
"slack": 3
},
"ANN003": {
"baseline": 759,
"slack": 10
},
"ANN201": {
"baseline": 1944,
"slack": 10
},
"ANN202": {
"baseline": 858,
"slack": 10
},
"ANN204": {
"baseline": 658,
"slack": 10
},
"ANN205": {
"baseline": 117,
"slack": 10
},
"ANN206": {
"baseline": 120,
"slack": 10
},
"ANN401": {
"baseline": 1886,
"slack": 10
},
"ASYNC230": {
"baseline": 11,
"slack": 3
},
"B004": {
"baseline": 1,
"slack": 3
},
"B006": {
"baseline": 180,
"slack": 3
},
"B008": {
"baseline": 490,
"slack": 10
},
"B009": {
"baseline": 79,
"slack": 10
},
"B010": {
"baseline": 187,
"slack": 10
},
"B018": {
"baseline": 2,
"slack": 3
},
"B019": {
"baseline": 1,
"slack": 3
},
"B021": {
"baseline": 1,
"slack": 3
},
"B026": {
"baseline": 3,
"slack": 3
},
"B033": {
"baseline": 1,
"slack": 3
},
"BLE001": {
"baseline": 2854,
"slack": 10
},
"C401": {
"baseline": 8,
"slack": 3
},
"C404": {
"baseline": 1,
"slack": 3
},
"C405": {
"baseline": 20,
"slack": 3
},
"C408": {
"baseline": 11,
"slack": 3
},
"C414": {
"baseline": 4,
"slack": 3
},
"C419": {
"baseline": 1,
"slack": 3
},
"C901": {
"baseline": 301,
"slack": 3
},
"D419": {
"baseline": 6,
"slack": 3
},
"DTZ001": {
"baseline": 2,
"slack": 3
},
"DTZ003": {
"baseline": 30,
"slack": 3
},
"DTZ005": {
"baseline": 229,
"slack": 10
},
"DTZ006": {
"baseline": 10,
"slack": 3
},
"DTZ007": {
"baseline": 20,
"slack": 3
},
"DTZ011": {
"baseline": 3,
"slack": 3
},
"EXE001": {
"baseline": 4,
"slack": 3
},
"EXE002": {
"baseline": 3,
"slack": 3
},
"F401": {
"baseline": 20,
"slack": 3
},
"FURB136": {
"baseline": 1,
"slack": 3
},
"FURB168": {
"baseline": 1,
"slack": 3
},
"FURB188": {
"baseline": 49,
"slack": 3
},
"I001": {
"baseline": 258,
"slack": 10
},
"LOG015": {
"baseline": 5,
"slack": 3
},
"N999": {
"baseline": 1,
"slack": 3
},
"PERF102": {
"baseline": 27,
"slack": 3
},
"PERF401": {
"baseline": 136,
"slack": 10
},
"PERF402": {
"baseline": 6,
"slack": 3
},
"PERF403": {
"baseline": 69,
"slack": 10
},
"PIE790": {
"baseline": 263,
"slack": 10
},
"PIE800": {
"baseline": 1,
"slack": 3
},
"PIE804": {
"baseline": 21,
"slack": 3
},
"PIE810": {
"baseline": 41,
"slack": 3
},
"PLC0206": {
"baseline": 28,
"slack": 3
},
"PLC0208": {
"baseline": 1,
"slack": 3
},
"PLC0414": {
"baseline": 35,
"slack": 3
},
"PLR0124": {
"baseline": 1,
"slack": 3
},
"PLR0206": {
"baseline": 1,
"slack": 3
},
"PLR0402": {
"baseline": 6,
"slack": 3
},
"PLR0913": {
"baseline": 1813,
"slack": 3
},
"PLR1704": {
"baseline": 3,
"slack": 3
},
"PLR1711": {
"baseline": 31,
"slack": 3
},
"PLR1714": {
"baseline": 252,
"slack": 10
},
"PLR1730": {
"baseline": 7,
"slack": 3
},
"PLR2044": {
"baseline": 1,
"slack": 3
},
"PLW0127": {
"baseline": 41,
"slack": 3
},
"PLW0133": {
"baseline": 1,
"slack": 3
},
"PLW0602": {
"baseline": 215,
"slack": 10
},
"PLW0603": {
"baseline": 183,
"slack": 3
},
"PLW1508": {
"baseline": 188,
"slack": 10
},
"PLW1510": {
"baseline": 2,
"slack": 3
},
"PYI030": {
"baseline": 2,
"slack": 3
},
"PYI036": {
"baseline": 2,
"slack": 3
},
"PYI041": {
"baseline": 9,
"slack": 3
},
"PYI064": {
"baseline": 2,
"slack": 3
},
"RET501": {
"baseline": 35,
"slack": 3
},
"RET504": {
"baseline": 709,
"slack": 10
},
"RUF010": {
"baseline": 844,
"slack": 10
},
"RUF012": {
"baseline": 158,
"slack": 3
},
"RUF015": {
"baseline": 8,
"slack": 3
},
"RUF019": {
"baseline": 38,
"slack": 3
},
"RUF022": {
"baseline": 80,
"slack": 10
},
"RUF023": {
"baseline": 2,
"slack": 3
},
"RUF046": {
"baseline": 5,
"slack": 3
},
"RUF051": {
"baseline": 3,
"slack": 3
},
"RUF059": {
"baseline": 69,
"slack": 10
},
"RUF100": {
"baseline": 465,
"slack": 10
},
"S110": {
"baseline": 222,
"slack": 10
},
"S112": {
"baseline": 21,
"slack": 3
},
"SIM101": {
"baseline": 58,
"slack": 10
},
"SIM102": {
"baseline": 311,
"slack": 10
},
"SIM103": {
"baseline": 119,
"slack": 10
},
"SIM113": {
"baseline": 3,
"slack": 3
},
"SIM114": {
"baseline": 103,
"slack": 10
},
"SIM115": {
"baseline": 2,
"slack": 3
},
"SIM117": {
"baseline": 7,
"slack": 3
},
"SIM118": {
"baseline": 104,
"slack": 10
},
"SIM201": {
"baseline": 1,
"slack": 3
},
"SIM210": {
"baseline": 9,
"slack": 3
},
"SIM211": {
"baseline": 1,
"slack": 3
},
"SIM222": {
"baseline": 1,
"slack": 3
},
"SIM401": {
"baseline": 9,
"slack": 3
},
"TC004": {
"baseline": 5,
"slack": 3
},
"TC005": {
"baseline": 6,
"slack": 3
},
"TID251": {
"baseline": 2405,
"slack": 10
},
"TRY002": {
"baseline": 528,
"slack": 10
},
"TRY004": {
"baseline": 93,
"slack": 10
},
"TRY201": {
"baseline": 409,
"slack": 10
},
"TRY203": {
"baseline": 113,
"slack": 10
},
"TRY300": {
"baseline": 853,
"slack": 10
},
"UP006": {
"baseline": 12941,
"slack": 10
},
"UP007": {
"baseline": 2520,
"slack": 10
},
"UP008": {
"baseline": 2,
"slack": 3
},
"UP012": {
"baseline": 4,
"slack": 3
},
"UP018": {
"baseline": 18,
"slack": 3
},
"UP024": {
"baseline": 12,
"slack": 3
},
"UP028": {
"baseline": 2,
"slack": 3
},
"UP031": {
"baseline": 2,
"slack": 3
},
"UP032": {
"baseline": 609,
"slack": 10
},
"UP034": {
"baseline": 1,
"slack": 3
},
"UP035": {
"baseline": 2250,
"slack": 10
},
"UP036": {
"baseline": 1,
"slack": 3
},
"UP037": {
"baseline": 100,
"slack": 10
},
"UP045": {
"baseline": 18417,
"slack": 10
}
}

View file

@ -1,7 +1,8 @@
extend = "ruff.toml"
[lint]
select = ["ANN001", "ANN002", "ANN003", "ANN401", "B006", "C901", "PLR0913", "PLW0603", "RUF012", "TID251"]
preview = true
select = ["ANN", "ASYNC230", "B004", "B006", "B008", "B009", "B010", "B018", "B019", "B021", "B026", "B033", "BLE", "C401", "C404", "C405", "C408", "C414", "C419", "C901", "D419", "DTZ001", "DTZ003", "DTZ005", "DTZ006", "DTZ007", "DTZ011", "EXE001", "EXE002", "F401", "FURB136", "FURB168", "FURB188", "I001", "LOG015", "N999", "PERF102", "PERF401", "PERF402", "PERF403", "PIE790", "PIE800", "PIE804", "PIE810", "PLC0206", "PLC0208", "PLC0414", "PLR0124", "PLR0206", "PLR0402", "PLR0913", "PLR1704", "PLR1711", "PLR1714", "PLR1730", "PLR2044", "PLW0127", "PLW0133", "PLW0602", "PLW0603", "PLW1508", "PLW1510", "PYI030", "PYI036", "PYI041", "PYI064", "RET501", "RET504", "RUF010", "RUF012", "RUF015", "RUF019", "RUF022", "RUF023", "RUF046", "RUF051", "RUF059", "RUF100", "S110", "S112", "SIM101", "SIM102", "SIM103", "SIM113", "SIM114", "SIM115", "SIM117", "SIM118", "SIM201", "SIM210", "SIM211", "SIM222", "SIM401", "TC004", "TC005", "TID251", "TRY002", "TRY004", "TRY201", "TRY203", "TRY300", "UP006", "UP007", "UP008", "UP012", "UP018", "UP024", "UP028", "UP031", "UP032", "UP034", "UP035", "UP036", "UP037", "UP045"]
extend-select = []
[lint.mccabe]

View file

@ -0,0 +1,556 @@
#!/usr/bin/env python3
"""Any-discipline gate: fail when a *changed* file holds a value typed `Any`.
Where ruff, `mypy --strict`, and even basedpyright's `reportAny` stop short, this
catches the case that actually bites: a *union* hiding an `Any`. For example
`re.Match.group()` -> `str | Any`, `json.loads()` -> `Any`, and bare `list`/`dict`
-> `list[Any]`/`dict[..., Any]`. Any value whose inferred type *contains* `Any`
(recursively, through unions / generics / tuples) is reported.
Scope: changed-only, changed-lines
----------------------------------
litellm already contains a large amount of pre-existing `Any` (a single legacy
file can have >100 findings), and a whole-tree scan would have to re-export types
for litellm's entire import closure on every run (~2 min, ~3 GB). So this gate is
*changed-only* and reports a finding only on a line that the diff against
`--base` actually adds or edits (untracked files count as wholly new). A brand
new file is therefore checked in full, while editing a legacy file only requires
*your* lines to be clean -- you can't introduce an `X | Any`, but you aren't
forced to clean the file's existing debt. This mirrors how `ruff_strict_gate.py`
blames a change only for the violations it introduces; cold legacy code is left
to the ratchet gates (mypy/basedpyright/ruff budgets).
How it works
------------
It loads `litellm/mypy.ini` (the same config `make lint-mypy` uses, so findings
match what developers already see), builds the changed files with mypy asking for
its exported expression->type map, and walks each file's AST applying a recursive
"contains Any" predicate -- the test `mypy --disallow-any-expr` uses internally
but applies inconsistently (python/mypy#12856).
mypy only re-exports types for modules it re-type-checks, so for each target we
invalidate just its cached hash (deps stay warm) to force a fast re-check against
a persisted incremental cache (.mypy_cache_any).
Rules
-----
Codes share the `LIT***` namespace with `scripts/check_type_discipline.py` (PR
#30500), which owns LIT001/002/003/004/006/007/008. This gate claims the rest:
LIT009 A value expression's inferred type is, or contains, `Any`.
Suppress with `# any-ok: <reason>` on the offending line.
LIT005 An `# any-ok` suppression without a reason (the shared
suppression-needs-a-reason code, same as `# cast-ok` / `# guard-ok`).
LIT000 Setup failure: mypy could not build, or a target file could not be read.
`Any`s produced purely by an already-reported error, and the special-form /
implementation-artifact internal `Any`s, are ignored. A bound method *reference*
whose signature mentions `Any` is not flagged -- only the value its call produces.
Usage
-----
# gate mode (CI / pre-push): check changed lines under litellm/
uv run --no-sync python scripts/check_any_discipline.py --changed --base origin/litellm_internal_staging
# whole-file spot-check (no line filter), paths relative to repo root
uv run --no-sync python scripts/check_any_discipline.py litellm/budget_manager.py
Exit code 1 if any Any-tainted value is found, 2 on a setup/usage error.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
import tokenize
from collections.abc import Iterable, Sequence
from pathlib import Path
from typing import NamedTuple
try:
from mypy import build
from mypy.config_parser import parse_config_file
from mypy.find_sources import create_source_list
from mypy.fscache import FileSystemCache
from mypy.modulefinder import BuildSource
from mypy.nodes import AssignmentStmt, Expression, NameExpr, Node
from mypy.options import Options
from mypy.types import (
AnyType,
CallableType,
Instance,
Overloaded,
TupleType,
Type,
TypeOfAny,
UnionType,
get_proper_type,
)
except ImportError: # pragma: no cover - environment guard
sys.stderr.write(
"check_any_discipline: mypy is not importable in this interpreter.\n"
"Run it through the project environment, e.g.\n"
" uv run --no-sync python scripts/check_any_discipline.py --changed\n"
)
raise SystemExit(2)
REPO_ROOT = Path(__file__).resolve().parent.parent
LITELLM_DIR = REPO_ROOT / "litellm"
MYPY_INI = LITELLM_DIR / "mypy.ini"
CACHE_DIR = REPO_ROOT / ".mypy_cache_any"
PY_TAG = f"{sys.version_info.major}.{sys.version_info.minor}"
DEFAULT_BASE = "origin/litellm_internal_staging"
MIN_REASON_LEN = 3
ANY_OK_RE = re.compile(r"#\s*any-ok(?::\s*(?P<reason>.*))?")
_HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
# Files allowed to surface `Any` (the typed/untyped boundary). A finding is
# skipped if any fragment below is a substring of the file's posix path. Keep
# this tight -- prefer a line-level `# any-ok: <reason>` over a blanket exemption.
BOUNDARY_PATHS: frozenset[str] = frozenset()
# `Any` kinds that are not actionable: produced by an already-reported error, or
# an internal placeholder that never corresponds to a concrete runtime value.
# NOTE: `special_form` is deliberately NOT here. In mypy 1.19 the `Any` in
# typeshed unions like `re.Match.group() -> str | Any` is tagged `special_form`,
# and that union is the headline case this gate exists to catch.
_HARMLESS_ANY = frozenset(
kind
for kind in (
TypeOfAny.from_error,
getattr(TypeOfAny, "implementation_artifact", None),
)
if kind is not None
)
# AST attributes that point OUTSIDE the syntactic subtree (a RefExpr's resolved
# definition, a node's TypeInfo). Skipping exactly these two makes a generic
# child-walk equivalent to mypy's TraverserVisitor -- validated to the node
# against ExtendedTraverserVisitor across the full grammar (see commit notes).
_NON_SYNTACTIC_ATTRS = frozenset({"node", "info"})
class Violation(NamedTuple):
path: Path
line: int
col: int
code: str
message: str
def render(self) -> str:
return f"{self.path}:{self.line}:{self.col}: {self.code} {self.message}"
# --------------------------------------------------------------------------- #
# The "contains Any" predicate
# --------------------------------------------------------------------------- #
def contains_any(t: Type, _seen: set[int] | None = None) -> bool:
"""True if a *value* of type ``t`` carries `Any` anywhere meaningful."""
seen = _seen if _seen is not None else set()
p = get_proper_type(t)
if id(p) in seen:
return False
seen.add(id(p))
# A function/method *reference* whose signature mentions Any is not itself an
# unsafe value -- only its eventual call result is. Don't recurse into it.
if isinstance(p, (CallableType, Overloaded)):
return False
if isinstance(p, AnyType):
return p.type_of_any not in _HARMLESS_ANY
if isinstance(p, UnionType):
return any(contains_any(item, seen) for item in p.items)
if isinstance(p, Instance):
return any(contains_any(arg, seen) for arg in p.args)
if isinstance(p, TupleType):
return any(contains_any(item, seen) for item in p.items)
return False
# --------------------------------------------------------------------------- #
# Generic, leak-free AST walk (works under a mypyc-compiled mypy, which forbids
# subclassing TraverserVisitor)
# --------------------------------------------------------------------------- #
def _walk_file(tree: Node) -> tuple[list[Expression], set[int]]:
"""Return (every Expression in `tree`, ids of simple assignment-target names).
The walk follows only syntactic children (every attribute except the two
non-syntactic back-references), so it never escapes the module. Simple
``x = <expr>`` name targets are collected separately so we don't double-report
the assigned name as an echo of an Any rvalue.
"""
exprs: list[Expression] = []
skip_lvalues: set[int] = set()
stack: list[object] = [tree]
seen: set[int] = set()
while stack:
n = stack.pop()
if isinstance(n, Node):
if id(n) in seen:
continue
seen.add(id(n))
if isinstance(n, Expression):
exprs.append(n)
if isinstance(n, AssignmentStmt):
for lvalue in n.lvalues:
if isinstance(lvalue, NameExpr):
skip_lvalues.add(id(lvalue))
for name in dir(n):
if name.startswith("__") or name in _NON_SYNTACTIC_ATTRS:
continue
try:
val = getattr(n, name)
except Exception:
continue
if callable(val):
continue
if isinstance(val, (Node, list, tuple)):
stack.append(val)
elif isinstance(n, (list, tuple)):
stack.extend(n)
return exprs, skip_lvalues
def find_any_in_tree(tree: Node, idmap: dict[int, Type]) -> list[tuple[int, int, str]]:
exprs, skip_lvalues = _walk_file(tree)
findings: list[tuple[int, int, str]] = []
for expr in exprs:
if id(expr) in skip_lvalues:
continue
t = idmap.get(id(expr))
if t is not None and contains_any(t):
findings.append((expr.line, expr.column, str(get_proper_type(t))))
out: list[tuple[int, int, str]] = []
seen_pos: set[tuple[int, int]] = set()
for line, col, typ in sorted(findings):
if line < 1 or (line, col) in seen_pos:
continue
seen_pos.add((line, col))
out.append((line, col, typ))
return out
# --------------------------------------------------------------------------- #
# Comment scanning (LIT005 + any-ok suppression)
# --------------------------------------------------------------------------- #
def _reason_ok(reason: str | None) -> bool:
return reason is not None and len(reason.strip()) >= MIN_REASON_LEN
def scan_any_ok(
path: Path, source: str
) -> tuple[frozenset[int], tuple[Violation, ...]]:
"""Return (lines with a valid any-ok suppression, LIT005 violations)."""
try:
tokens = tokenize.generate_tokens(
iter(source.splitlines(keepends=True)).__next__
)
comments = tuple(
(t.start[0], t.string) for t in tokens if t.type == tokenize.COMMENT
)
except tokenize.TokenError:
return frozenset(), ()
ok_lines: set[int] = set()
violations: list[Violation] = []
for line, text in comments:
m = ANY_OK_RE.search(text)
if m is None:
continue
if _reason_ok(m.group("reason")):
ok_lines.add(line)
else:
violations.append(
Violation(
path,
line,
0,
"LIT005",
"any-ok requires a reason: `# any-ok: <reason>`",
)
)
return frozenset(ok_lines), tuple(violations)
# --------------------------------------------------------------------------- #
# mypy build (parity with `make lint-mypy`) + forced target re-check
# --------------------------------------------------------------------------- #
def _build_options() -> Options:
opts = Options()
if MYPY_INI.exists():
parse_config_file(opts, lambda: None, str(MYPY_INI), sys.stdout, sys.stderr)
opts.export_types = True
opts.preserve_asts = True
opts.incremental = True
opts.cache_dir = str(CACHE_DIR)
opts.show_traceback = False
return opts
def _meta_path(module: str) -> Path:
return CACHE_DIR / PY_TAG / (module.replace(".", os.sep) + ".meta.json")
def _force_recheck(sources: Sequence[BuildSource]) -> None:
"""Invalidate each target's cached entry so mypy re-type-checks (and thus
re-exports types + preserves the AST for) exactly these modules, while their
dependencies stay warm. A missing entry is a cold build for that module.
mypy trusts a cache entry whenever the source mtime matches the cached one
(it never re-hashes on that fast path), so we must break BOTH: zero the
cached mtime to force a re-hash, and corrupt the cached hash so the re-hash
mismatches and the module is treated as changed."""
for src in sources:
if not src.module:
continue
meta = _meta_path(src.module)
if not meta.exists():
continue
try:
data = json.loads(meta.read_text())
data["hash"] = "0" * 40
data["mtime"] = 0
meta.write_text(json.dumps(data))
except (OSError, ValueError):
continue
def check_files(rel_paths: Sequence[str]) -> tuple[Violation, ...]:
"""`rel_paths` are relative to the litellm package dir (the build cwd)."""
prev_cwd = Path.cwd()
os.chdir(LITELLM_DIR)
try:
opts = _build_options()
fscache = FileSystemCache()
sources = create_source_list(list(rel_paths), opts, fscache)
_force_recheck(sources)
try:
res = build.build(sources, options=opts, fscache=fscache)
except build.CompileError as exc:
joined = "; ".join(exc.messages[:3]) or "blocking error"
return (
Violation(
Path(rel_paths[0]),
0,
0,
"LIT000",
f"mypy could not build: {joined}",
),
)
idmap = {id(expr): t for expr, t in res.types.items()}
# Resolve trees to absolute source paths while cwd is the build dir, since
# mypy stores the paths it was given (relative to this cwd).
trees: dict[str, Node] = {}
for state in res.graph.values():
if state.path and state.tree is not None:
trees[os.path.realpath(state.path)] = state.tree
finally:
os.chdir(prev_cwd)
out: list[Violation] = []
for rel in rel_paths:
abs_path = (LITELLM_DIR / rel).resolve()
report_path = abs_path.relative_to(REPO_ROOT)
if _is_boundary(report_path):
continue
try:
source = abs_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as exc:
out.append(
Violation(report_path, 0, 0, "LIT000", f"could not read file: {exc}")
)
continue
ok_lines, ok_violations = scan_any_ok(report_path, source)
out.extend(ok_violations)
tree = trees.get(os.path.realpath(abs_path))
if tree is None:
continue
for line, col, typ in find_any_in_tree(tree, idmap):
if line in ok_lines:
continue
out.append(
Violation(
report_path,
line,
col,
"LIT009",
f"value type contains Any -> {typ}",
)
)
return tuple(out)
# --------------------------------------------------------------------------- #
# File selection (changed-only, changed-lines) + driver
# --------------------------------------------------------------------------- #
class _AllLines:
"""Sentinel: a wholly new / untracked file -- every line is in scope.
A distinct object, not None, so that `line_map.get(path)` returning None for
a path absent from the map is never mistaken for "whole file in scope"."""
# A changed file's in-scope lines: a specific set, or every line.
LineScope = set[int] | _AllLines
ALL_LINES = _AllLines()
def _is_boundary(path: Path) -> bool:
posix = path.as_posix()
return any(frag in posix for frag in BOUNDARY_PATHS)
def _git(*args: str) -> list[str]:
result = subprocess.run(
["git", "-C", str(REPO_ROOT), *args],
capture_output=True,
text=True,
check=True,
)
return result.stdout.splitlines()
def _parse_added_lines(diff_text: str) -> dict[str, set[int]]:
"""Map repo-relative path -> set of new-file line numbers the diff adds/edits."""
changed: dict[str, set[int]] = {}
path: str | None = None
for line in diff_text.splitlines():
if line.startswith("+++ b/"):
path = line[6:]
elif path and (m := _HUNK_RE.match(line)):
start = int(m.group(1))
count = int(m.group(2)) if m.group(2) is not None else 1
if count:
changed.setdefault(path, set()).update(range(start, start + count))
return changed
def changed_line_map(base: str) -> dict[str, LineScope] | None:
"""Repo-relative `.py` path under litellm/ -> changed line numbers (or
ALL_LINES for untracked files). Compares the working tree to the merge-base
with `base`, so it covers committed-on-branch + unstaged edits. None if git
is unavailable / not a repo."""
try:
merge_base = _git("merge-base", base, "HEAD")
point = merge_base[0].strip() if merge_base else base
diff = "\n".join(
_git(
"diff",
"--unified=0",
"--no-color",
"--diff-filter=d",
point,
"--",
"litellm",
)
)
untracked = _git("ls-files", "--others", "--exclude-standard", "--", "litellm")
except (subprocess.CalledProcessError, FileNotFoundError):
return None
out: dict[str, LineScope] = {}
for name, lines in _parse_added_lines(diff).items():
if name.endswith(".py") and (REPO_ROOT / name).exists():
out[name] = lines
for name in untracked:
if name.endswith(".py") and (REPO_ROOT / name).exists():
out[name] = ALL_LINES
return out
def _to_litellm_relative(paths: Iterable[Path]) -> list[str]:
rels: list[str] = []
for p in sorted(paths):
try:
rels.append(p.resolve().relative_to(LITELLM_DIR).as_posix())
except ValueError:
continue
return rels
def _in_scope(v: Violation, line_map: dict[str, LineScope] | None) -> bool:
"""A finding survives if line filtering is off (explicit paths), it's a build
error, or its line is one the diff added/edited."""
if line_map is None or v.code == "LIT000":
return True
lines = line_map.get(v.path.as_posix())
return lines is ALL_LINES or (lines is not None and v.line in lines)
def main(argv: Sequence[str]) -> int:
parser = argparse.ArgumentParser(
description="Any-discipline gate (changed-only, changed-lines)."
)
parser.add_argument(
"paths",
nargs="*",
help="explicit files (repo-root relative); whole-file, no line filter",
)
parser.add_argument(
"--changed",
action="store_true",
help="check changed lines under litellm/ vs --base",
)
parser.add_argument("--base", default=os.environ.get("ANY_GATE_BASE", DEFAULT_BASE))
args = parser.parse_args(list(argv))
line_map: dict[str, LineScope] | None = None
if args.changed:
line_map = changed_line_map(args.base)
if line_map is None:
print(
"check_any_discipline: not a git repository; nothing to check",
file=sys.stderr,
)
return 0
rel_paths = _to_litellm_relative(
(REPO_ROOT / name).resolve() for name in line_map
)
elif args.paths:
rel_paths = _to_litellm_relative((REPO_ROOT / p).resolve() for p in args.paths)
else:
parser.error("pass --changed or explicit file paths")
return 2
if not rel_paths:
print("OK: no changed Python lines under litellm/ to check")
return 0
violations = tuple(v for v in check_files(rel_paths) if _in_scope(v, line_map))
for v in sorted(violations):
print(v.render())
if violations:
n = len(violations)
print(
f"\nFAIL: {n} Any-discipline violation(s) on changed lines.\n"
"Give the value a concrete type, or annotate the line `# any-ok: <reason>`.",
file=sys.stderr,
)
return 1
print(
f"OK: {len(rel_paths)} changed file(s) under litellm/ have no Any-typed values on changed lines"
)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

196
scripts/type_check_gate.py Normal file
View file

@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""Per-rule count gate for mypy and basedpyright.
Each tool's output is reduced to a count of errors per *rule* (mypy error codes
like ``arg-type``, basedpyright rules like ``reportAny``) and checked against a
committed budget of the form ``{rule: {baseline, slack}}``, the same shape as
``ruff-strict-budget.json``. A rule fails when its codebase-wide total exceeds
``baseline + slack``. Counts ignore file, line, and column, so a violation
moving anywhere in the tree is invisible; only the per-rule total moves the
needle.
Unlike ``ruff_strict_gate.py`` this does *not* re-run the tool on the merge base
to compute a delta: a second mypy/basedpyright pass is minutes and gigabytes,
whereas ruff is milliseconds. The committed budget is the baseline instead --
exactly how the previous per-file gate worked -- so keep it fresh with
``--update`` (ratchet), which re-captures every rule's count from the current
tree while preserving each rule's slack. Tool output is read from stdin, so the
caller decides how to invoke the tool (and from which cwd).
mypy is parsed from its text output (one error per line, the rule code in a
trailing ``[bracket]``). basedpyright is parsed from ``--outputjson``: its text
diagnostics routinely wrap across lines, leaving the ``(reportRule)`` on a
continuation line away from the ``- error:`` marker, so line parsing
mis-attributes ~60% of errors -- the JSON carries an unambiguous ``rule`` field.
"""
import argparse
import json
import re
import sys
from collections import Counter
from pathlib import Path
from typing import Iterable, Mapping, NamedTuple
REPO_ROOT = Path(__file__).resolve().parent.parent
# mypy: one error per line, e.g. `path:12: error: msg [arg-type]`. ERROR_LINE
# recognizes the line; MYPY_CODE pulls the trailing [code]. Kept separate so an
# error emitted without a code is still counted (under UNCODED), never dropped.
MYPY_ERROR = re.compile(r"^(?P<file>.+?):\d+: error:")
MYPY_CODE = re.compile(r"\[(?P<code>[a-z][a-z0-9-]*)\]\s*$")
# Bucket for an error whose rule code we couldn't read (a mypy error with no
# code, or a basedpyright diagnostic with no `rule`). Counted so it's gated.
UNCODED = "<uncoded>"
# Ceiling for a rule that shows up at HEAD but isn't in the budget at all -- a
# brand-new error category (new construct, or a tool/version change). baseline
# is treated as 0, so the rule fails once it clears this much slack.
DEFAULT_SLACK = 10
class Breach(NamedTuple):
code: str
total: int
cap: int
def _seed_slack(baseline: int) -> int:
"""Slack written for a rule first captured into a budget; busy rules get
more headroom, mirroring the tiering in ruff-strict-budget.json. Existing
rules keep whatever slack their JSON already declares."""
return 10 if baseline >= 50 else 3
def _to_repo_relative(raw: str) -> str | None:
path = Path(raw)
absolute = path if path.is_absolute() else Path.cwd() / path
try:
return absolute.resolve().relative_to(REPO_ROOT).as_posix()
except ValueError:
return None
def count_mypy(lines: Iterable[str]) -> dict[str, int]:
"""Count in-repo mypy errors per rule code from text output. Errors for
files outside the repo (third-party stubs) are ignored, as before."""
counts: Counter[str] = Counter()
for raw in lines:
line = raw.rstrip("\n")
match = MYPY_ERROR.match(line)
if match is None or _to_repo_relative(match.group("file")) is None:
continue
code = MYPY_CODE.search(line)
counts[code.group("code") if code else UNCODED] += 1
return dict(counts)
def count_basedpyright(payload: str) -> dict[str, int]:
"""Count in-repo basedpyright errors per rule from `--outputjson`. Warnings
and information are ignored; only `severity == "error"` is gated."""
try:
data = json.loads(payload or "{}")
except json.JSONDecodeError as exc:
sys.stderr.write(
f"basedpyright did not emit valid JSON ({exc}); it likely crashed or "
f"printed text before the JSON. First 500 chars of its output:\n"
f"{payload[:500]}\n"
)
raise SystemExit(1) from exc
counts: Counter[str] = Counter()
for diag in data.get("generalDiagnostics", []):
if diag.get("severity") != "error":
continue
if _to_repo_relative(diag.get("file", "")) is None:
continue
counts[diag.get("rule") or UNCODED] += 1
return dict(counts)
def count_errors(stdin_text: str, tool: str) -> dict[str, int]:
if tool == "basedpyright":
return count_basedpyright(stdin_text)
return count_mypy(stdin_text.splitlines())
def evaluate(
counts: Mapping[str, int], budget: Mapping[str, Mapping[str, int]]
) -> list[Breach]:
breaches = []
for code, total in counts.items():
spec = budget.get(code)
cap = spec["baseline"] + spec["slack"] if spec else DEFAULT_SLACK
if total > cap:
breaches.append(Breach(code, total, cap))
return sorted(breaches)
def is_vacuous_run(
counts: Mapping[str, int], budget: Mapping[str, Mapping[str, int]]
) -> bool:
"""True when nothing was parsed but the budget expects errors -- the
signature of a type checker that crashed or produced no output. The CI pipe
swallows the tool's exit code (`tool || true`), so without this guard an
empty run would clear every ceiling and pass silently."""
return not counts and any(spec["baseline"] for spec in budget.values())
def budget_path(tool: str) -> Path:
return REPO_ROOT / f"{tool}-code-budget.json"
def cmd_update(tool: str, counts: Mapping[str, int]) -> None:
path = budget_path(tool)
existing = json.loads(path.read_text()) if path.exists() else {}
budget = {
code: {
"baseline": count,
"slack": (
existing[code]["slack"] if code in existing else _seed_slack(count)
),
}
for code, count in sorted(counts.items())
}
path.write_text(json.dumps(budget, indent=2, sort_keys=True) + "\n")
print(
f"Re-captured {tool} per-rule budget: {len(budget)} rules, {sum(counts.values())} errors total"
)
def cmd_check(tool: str, counts: Mapping[str, int]) -> None:
budget = json.loads(budget_path(tool).read_text())
if is_vacuous_run(counts, budget):
expected = sum(spec["baseline"] for spec in budget.values())
print(
f"FAIL: {tool} produced no errors, but {budget_path(tool).name} expects "
f"~{expected}. The type checker almost certainly crashed or emitted "
f"nothing; refusing to certify a vacuous run."
)
raise SystemExit(1)
breaches = evaluate(counts, budget)
if not breaches:
print(
f"OK: every rule is within its {tool} ceiling ({sum(counts.values())} errors total)"
)
return
print(f"FAIL: {tool} errors exceed the per-rule ceiling:")
for breach in breaches:
print(f" {breach.code}: {breach.total} errors over cap {breach.cap}")
print(
f"Resolve the new errors, or run 'make lint-{tool}-budget-update' if the ceiling should move."
)
raise SystemExit(1)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--tool", choices=("mypy", "basedpyright"), required=True)
parser.add_argument("--update", action="store_true")
args = parser.parse_args()
counts = count_errors(sys.stdin.read(), args.tool)
cmd_update(args.tool, counts) if args.update else cmd_check(args.tool, counts)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,41 @@
import importlib.util
from pathlib import Path
_MODULE_PATH = (
Path(__file__).resolve().parents[2] / "scripts" / "check_any_discipline.py"
)
_spec = importlib.util.spec_from_file_location("check_any_discipline", _MODULE_PATH)
mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(mod)
Violation = mod.Violation
def _v(path="litellm/x.py", line=10, code="LIT009"):
return Violation(Path(path), line, 0, code, "Any-typed value")
def test_violation_on_a_changed_line_is_in_scope():
assert mod._in_scope(_v(line=10), {"litellm/x.py": {10, 11}}) is True
def test_violation_on_an_unchanged_line_of_a_changed_file_is_out_of_scope():
assert mod._in_scope(_v(line=99), {"litellm/x.py": {10, 11}}) is False
def test_whole_new_file_puts_every_line_in_scope():
assert mod._in_scope(_v(line=99999), {"litellm/x.py": mod.ALL_LINES}) is True
def test_file_absent_from_line_map_is_out_of_scope():
# Regression: ALL_LINES is a distinct sentinel, so a path missing from the map
# (line_map.get -> None) is NOT mistaken for "whole file in scope".
assert mod._in_scope(_v(path="litellm/other.py"), {"litellm/x.py": {1}}) is False
def test_no_line_map_means_no_line_filtering():
assert mod._in_scope(_v(line=12345), None) is True
def test_build_error_is_always_in_scope():
assert mod._in_scope(_v(code="LIT000", line=1), {"litellm/x.py": {2}}) is True

View file

@ -0,0 +1,133 @@
import importlib.util
import json
from pathlib import Path
_MODULE_PATH = Path(__file__).resolve().parents[2] / "scripts" / "type_check_gate.py"
_spec = importlib.util.spec_from_file_location("type_check_gate", _MODULE_PATH)
gate = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(gate)
ROOT = gate.REPO_ROOT
def test_mypy_counts_per_code_ignoring_lines_notes_and_summary():
text = "\n".join(
[
f"{ROOT}/litellm/utils.py:10: error: missing annotation [no-untyped-def]",
f"{ROOT}/litellm/utils.py:9999: error: missing annotation [no-untyped-def]",
f"{ROOT}/litellm/main.py:5: error: Returning Any [no-any-return]",
f"{ROOT}/litellm/main.py:5: note: see here",
"Found 3 errors in 2 files (checked 100 source files)",
]
)
assert gate.count_errors(text, "mypy") == {
"no-untyped-def": 2,
"no-any-return": 1,
}
def _bpr(file, severity, rule):
diag = {"file": str(file), "severity": severity, "message": "msg"}
if rule is not None:
diag["rule"] = rule
return diag
def test_basedpyright_counts_per_rule_from_json_not_warnings():
# basedpyright wraps long messages across lines, so the (reportRule) lands on
# a continuation line away from the `- error:` marker; --outputjson avoids it.
payload = json.dumps(
{
"generalDiagnostics": [
_bpr(f"{ROOT}/litellm/utils.py", "error", "reportUnknownVariableType"),
_bpr(f"{ROOT}/litellm/utils.py", "error", "reportUnknownVariableType"),
_bpr(f"{ROOT}/litellm/main.py", "error", "reportArgumentType"),
_bpr(f"{ROOT}/litellm/main.py", "warning", "reportUnusedImport"),
]
}
)
assert gate.count_errors(payload, "basedpyright") == {
"reportUnknownVariableType": 2,
"reportArgumentType": 1,
}
def test_basedpyright_error_without_a_rule_is_bucketed():
payload = json.dumps(
{"generalDiagnostics": [_bpr(f"{ROOT}/litellm/x.py", "error", None)]}
)
assert gate.count_errors(payload, "basedpyright") == {gate.UNCODED: 1}
def test_mypy_error_without_a_code_is_bucketed_so_it_is_still_gated():
text = f"{ROOT}/litellm/x.py:1: error: something broke with no code"
assert gate.count_errors(text, "mypy") == {gate.UNCODED: 1}
def test_paths_outside_repo_are_skipped():
text = "/tmp/elsewhere.py:1: error: missing annotation [no-untyped-def]"
assert gate.count_errors(text, "mypy") == {}
payload = json.dumps(
{
"generalDiagnostics": [
_bpr("/tmp/elsewhere.py", "error", "reportArgumentType")
]
}
)
assert gate.count_errors(payload, "basedpyright") == {}
def test_at_or_under_ceiling_passes():
budget = {"no-any-return": {"baseline": 5, "slack": 0}}
assert gate.evaluate({"no-any-return": 5}, budget) == []
def test_one_more_error_than_ceiling_fails():
budget = {"no-any-return": {"baseline": 5, "slack": 0}}
assert gate.evaluate({"no-any-return": 6}, budget) == [
gate.Breach("no-any-return", 6, 5)
]
def test_slack_absorbs_small_increase_then_fails_past_it():
budget = {"arg-type": {"baseline": 5, "slack": 5}}
assert gate.evaluate({"arg-type": 10}, budget) == []
assert gate.evaluate({"arg-type": 11}, budget) == [gate.Breach("arg-type", 11, 10)]
def test_unbudgeted_new_code_uses_default_slack():
assert gate.evaluate({"brand-new": gate.DEFAULT_SLACK}, {}) == []
assert gate.evaluate({"brand-new": gate.DEFAULT_SLACK + 1}, {}) == [
gate.Breach("brand-new", gate.DEFAULT_SLACK + 1, gate.DEFAULT_SLACK)
]
def test_no_output_against_a_nonempty_budget_is_a_vacuous_run():
# A crashed type checker emits nothing; the gate must not certify it as clean.
budget = {"no-untyped-def": {"baseline": 4888, "slack": 10}}
assert gate.is_vacuous_run({}, budget) is True
def test_genuine_zero_and_empty_budget_are_not_vacuous():
assert gate.is_vacuous_run({}, {}) is False
assert (
gate.is_vacuous_run({}, {"no-untyped-def": {"baseline": 0, "slack": 3}})
is False
)
assert (
gate.is_vacuous_run({"arg-type": 1}, {"arg-type": {"baseline": 9, "slack": 1}})
is False
)
def test_malformed_basedpyright_json_exits_loudly_not_as_zero_errors():
import pytest
with pytest.raises(SystemExit):
gate.count_errors("startup warning\n{not json", "basedpyright")
def test_empty_basedpyright_payload_counts_zero():
# Empty (not malformed) output parses to zero; the vacuous-run guard, not the
# parser, is what rejects an empty run.
assert gate.count_errors("", "basedpyright") == {}

47
uv.lock generated
View file

@ -9,7 +9,7 @@ resolution-markers = [
]
[options]
exclude-newer = "2026-06-10T00:35:00.40525Z"
exclude-newer = "2026-06-11T06:56:06.940919973Z"
exclude-newer-span = "P3D"
[manifest]
@ -540,6 +540,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
]
[[package]]
name = "basedpyright"
version = "1.39.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodejs-wheel-binaries" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2f/e5/0d685b5808436c628ab8b9edad6810b889d11044a962bc42b128543910ea/basedpyright-1.39.7.tar.gz", hash = "sha256:688d913a19c417870c164c630ed9cdd83a8d8b484b30ab8e99f5dec4ae9604a6", size = 25503256, upload-time = "2026-06-07T11:33:27.266Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/f4/5b1e8ea279ce8f97a6bb1518c84fa25f5794022053ce10eab22ad3f0b51b/basedpyright-1.39.7-py3-none-any.whl", hash = "sha256:81266deb6044c9be98fb4555e4b7b1a521d8aee06b66e80858d183b0e3991140", size = 13182666, upload-time = "2026-06-07T11:33:24.119Z" },
]
[[package]]
name = "beautifulsoup4"
version = "4.14.3"
@ -3416,13 +3428,13 @@ ci = [
{ name = "pyarrow" },
{ name = "pygithub" },
{ name = "pylint" },
{ name = "pyright" },
{ name = "pytest-codspeed" },
{ name = "pytest-retry" },
{ name = "tenacity" },
{ name = "traceloop-sdk" },
]
dev = [
{ name = "basedpyright" },
{ name = "black" },
{ name = "diff-cover" },
{ name = "fakeredis" },
@ -3584,13 +3596,13 @@ ci = [
{ name = "pyarrow", specifier = "==23.0.1" },
{ name = "pygithub", specifier = "==2.8.1" },
{ name = "pylint", specifier = "==4.0.5" },
{ name = "pyright", specifier = "==1.1.408" },
{ name = "pytest-codspeed", specifier = "==4.3.0" },
{ name = "pytest-retry", specifier = "==1.7.0" },
{ name = "tenacity", specifier = "==8.5.0" },
{ name = "traceloop-sdk", specifier = "==0.33.12" },
]
dev = [
{ name = "basedpyright", specifier = "==1.39.7" },
{ name = "black", specifier = "==26.3.1" },
{ name = "diff-cover", specifier = "==9.7.2" },
{ name = "fakeredis", specifier = "==2.34.1" },
@ -4270,6 +4282,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
]
[[package]]
name = "nodejs-wheel-binaries"
version = "24.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/22/2a5beb4e21417c73233d9f65cf6f3e96e891b80d2f550a8f630ebc6b88c6/nodejs_wheel_binaries-24.16.0.tar.gz", hash = "sha256:c973cb69dc5fd16e6f6dc6e579e2c3d5534e2a1f57619dddf5ba070efa7dde37", size = 8056, upload-time = "2026-05-30T16:52:09.807Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/83/d1/68b43b53cd0fa83ae6fd406705023ca988d9e0ca41c724d82e66fbeb2ef6/nodejs_wheel_binaries-24.16.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:d9f8f677dcf30e37ac244f07869726abe043f01eb0f45722b1df31cc2af7093c", size = 55666374, upload-time = "2026-05-30T16:51:39.588Z" },
{ url = "https://files.pythonhosted.org/packages/e9/b2/40a989159599080da485de966c4c2d207e852ac7aa7864702626d96c8bf5/nodejs_wheel_binaries-24.16.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:3d0370fe7120ce9697a4f60d40480d2bd8808d9f30131458d5afc0040d4e5a51", size = 55838487, upload-time = "2026-05-30T16:51:43.383Z" },
{ url = "https://files.pythonhosted.org/packages/d7/a7/cd42174fb5ff6faff7fa8d326a18914d8f232098ab5de055b57c16fa13ca/nodejs_wheel_binaries-24.16.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:85dc92bbb79c851569c5925dcc2a4c915a034efab375f99e4e7e6bbe9cca8342", size = 60179540, upload-time = "2026-05-30T16:51:47.036Z" },
{ url = "https://files.pythonhosted.org/packages/2b/95/c8a1f9ae140aa28df8744d984d01d4b3af7cdd6555af12127f40ceb45a7d/nodejs_wheel_binaries-24.16.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:2f3036292811514ba847b3708492644764f88a833ac425c5f55007014308ddfd", size = 60716262, upload-time = "2026-05-30T16:51:50.711Z" },
{ url = "https://files.pythonhosted.org/packages/64/c9/7c35b3737f59e36d0249c265397b7bff570519b95301d6e16ea361e904ad/nodejs_wheel_binaries-24.16.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:db8a8a76ebd2b28ecbfc9ad464baa3707241b9e050a30e2efdf6f60c0f886502", size = 62230592, upload-time = "2026-05-30T16:51:55Z" },
{ url = "https://files.pythonhosted.org/packages/04/96/d931255cf9d11a84d6b54d882dba7434646467d568ccf070ea3418638df3/nodejs_wheel_binaries-24.16.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f1a3d8f7b4491cbbd023ba3fc4e901fcca2d9fb80d57f24ba3890de8b1dbac03", size = 62841759, upload-time = "2026-05-30T16:51:59.407Z" },
{ url = "https://files.pythonhosted.org/packages/a2/7b/8b7a3f41bc255411be30b6d7d288aab8ffd9ea2055db8555ced3548007b9/nodejs_wheel_binaries-24.16.0-py2.py3-none-win_amd64.whl", hash = "sha256:bb136be9944f0662dcf1120f45193a6b75b13fac378971a95cc42c9f879a81aa", size = 42027734, upload-time = "2026-05-30T16:52:03.348Z" },
{ url = "https://files.pythonhosted.org/packages/17/66/1ed71f1f529b8ca727d42c7ceb9db0bef145ce4a13dfc86fb50aa44f3be6/nodejs_wheel_binaries-24.16.0-py2.py3-none-win_arm64.whl", hash = "sha256:8308940b5edd0a50dc5267ea36ba21c9f668e83fe0d9f293937174d3a7e31c36", size = 39714528, upload-time = "2026-05-30T16:52:06.421Z" },
]
[[package]]
name = "numpy"
version = "1.26.4"
@ -6082,19 +6110,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" },
]
[[package]]
name = "pyright"
version = "1.1.408"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodeenv" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" },
]
[[package]]
name = "pyroscope-io"
version = "0.8.16"