Skip to content

feat(lint): exempt TypedDict-annotated dict literals from LIT002 - #36869

Merged
mateo-berri merged 3 commits into
litellm_internal_stagingfrom
litellm_lit002_typeddict_dict_literals
Aug 15, 2026
Merged

feat(lint): exempt TypedDict-annotated dict literals from LIT002#36869
mateo-berri merged 3 commits into
litellm_internal_stagingfrom
litellm_lit002_typeddict_dict_literals

Conversation

@mateo-berri

@mateo-berri mateo-berri commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Building a TypedDict value as a dict literal tripped LIT002
  • Escapes were MyTD(a=1) calls or # mutable-ok suppressions
  • Constructor calls cannot express keys that are not identifiers

How it solves it:

  • Exempts dict literals assigned with a TypedDict annotation, like x: Final[MyTD] = {...}
  • Bare x: Final = {...} names no type and stays flagged
  • Nested dict literals share the exemption; other construction inside still counts
  • Detection is name-based: Final/ClassVar/Optional/Annotated unwrap, PEP 604 unions qualify through either arm, mutable heads and Mapping/Any/object never qualify
  • Ratchets the LIT002 budget down by the 234 violations this clears

User Flow

Before: a contributor who builds a fixed-shape TypedDict payload with a dict literal gets an LIT002 lint failure and must suppress or rewrite

  1. They add x: Final[MyTD] = {"a": 1} to a file under litellm/, where MyTD is a TypedDict
  2. They stage the file and run make check
  3. The type-discipline gate fails with LIT002 mutable dict literal: this builds a collection that can be grown or rewritten, pointing at the annotated line; pushing anyway turns the CI lint job red with the same message
  4. To land the change they either append # mutable-ok: <reason> to the line or rewrite the payload as MyTD(a=1), which cannot express keys that are not valid identifiers

After: the same annotated dict literal passes lint, while an unannotated Final dict literal still fails

  1. They add x: Final[MyTD] = {"a": 1} to a file under litellm/, where MyTD is a TypedDict
  2. They stage the file and run make check
  3. The type-discipline gate reports OK: every LIT rule is within its codebase ceiling, and the CI lint job stays green
  4. A sibling line y: Final = {"a": 1} still fails with LIT002, whose message now also names the fix: a TypedDict-annotated dict literal (x: Final[MyTD] = {...})

Relevant issues

Linear ticket

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

Screenshots / Proof of Fix

Demo file exercising the exemption and its negative case:

from typing import Final, ReadOnly, TypedDict


class MyTD(TypedDict):
    a: ReadOnly[int]


x: Final[MyTD] = {"a": 1}
y: Final = {"a": 1}
z: Final[MyTD | None] = {"a": 1}

Before, checker at base 9d069f2 flags all three assignments:

$ python scripts/check_type_discipline.py demo_typeddict.py
demo_typeddict.py:8: LIT002 mutable dict literal: this builds a collection that can be grown or rewritten. Build it in one shot and freeze it -- a tuple/frozenset wrapping a generator (`tuple(f(x) for x in xs)`), a tuple literal, a frozen dataclass / NamedTuple / ReadOnly TypedDict, or (if it really must be dynamic) a MappingProxyType wrapping a dict literal or comprehension (suppress: `# mutable-ok: <reason>`)
demo_typeddict.py:9: LIT002 mutable dict literal: this builds a collection that can be grown or rewritten. Build it in one shot and freeze it -- a tuple/frozenset wrapping a generator (`tuple(f(x) for x in xs)`), a tuple literal, a frozen dataclass / NamedTuple / ReadOnly TypedDict, or (if it really must be dynamic) a MappingProxyType wrapping a dict literal or comprehension (suppress: `# mutable-ok: <reason>`)
demo_typeddict.py:10: LIT002 mutable dict literal: this builds a collection that can be grown or rewritten. Build it in one shot and freeze it -- a tuple/frozenset wrapping a generator (`tuple(f(x) for x in xs)`), a tuple literal, a frozen dataclass / NamedTuple / ReadOnly TypedDict, or (if it really must be dynamic) a MappingProxyType wrapping a dict literal or comprehension (suppress: `# mutable-ok: <reason>`)

3 violation(s).

After, checker at 316732b passes both annotated builds and still flags the bare Final, with the fix message now naming the TypedDict escape:

$ python scripts/check_type_discipline.py demo_typeddict.py
demo_typeddict.py:9: LIT002 mutable dict literal: this builds a collection that can be grown or rewritten. Build it in one shot and freeze it -- a tuple/frozenset wrapping a generator (`tuple(f(x) for x in xs)`), a tuple literal, a frozen dataclass / NamedTuple, a TypedDict-annotated dict literal (`x: Final[MyTD] = {...}`), or (if it really must be dynamic) a MappingProxyType wrapping a dict literal or comprehension (suppress: `# mutable-ok: <reason>`)

1 violation(s).

Gate over the whole tree at 316732b, with the ratcheted budget:

$ python scripts/type_discipline_gate.py
OK: every LIT rule is within its codebase ceiling (base origin/litellm_internal_staging)

Type

🆕 New Feature

Caveats (if any)

  • Detection is name-based; basedpyright stays the backstop for annotation truth
  • Assignments only: a return {...} into a TypedDict return type still trips
  • Mapping/Any/object annotations stay flagged toward MappingProxyType

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

Note

Low Risk
Scope is limited to the type-discipline AST linter and its violation budget; runtime behavior is unchanged and false positives are bounded by existing basedpyright checks.

Overview
LIT002 no longer flags dict literals on assignments whose annotation is treated as a TypedDict (e.g. x: Final[MyTD] = {...}), so contributors can use literal syntax—including non-identifier keys—without # mutable-ok or MyTD(...) constructor calls.

The checker adds a name-based annotation walk (Final / ClassVar / Optional / Annotated unwrap, PEP 604 unions, string forward refs) and skips those dict literals plus nested dict literals inside the value; bare Final / ClassVar, dict/Mapping/Any/object, and non-literal builds (dict(), comprehensions, mutable values inside the tree) still trip LIT002. Rule docs and the violation message now describe the TypedDict escape hatch instead of only “ReadOnly TypedDict.”

type-discipline-budget.json lowers the LIT002 ceiling by 238 (27139 → 26901) to match cleared violations; new unit tests cover positive and negative cases.

Reviewed by Cursor Bugbot for commit 3120f26. Bugbot is set up for automated code reviews on this repo. Configure here.

@greptile-apps

greptile-apps Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR exempts dict literals assigned to TypedDict-annotated values from LIT002 while retaining checks for untyped and explicitly mutable annotations.

  • Adds name-based handling for wrappers, forward references, qualified names, and PEP 604 unions.
  • Exempts nested dict literals within qualifying TypedDict builds while continuing to inspect other mutable constructions.
  • Adds focused regression tests and lowers the LIT002 budget to reflect the newly exempted violations.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
scripts/check_type_discipline.py Adds the TypedDict annotation classifier and dict-literal exemption, including the correction for PEP 604 optional unions.
tests/test_litellm/test_check_type_discipline.py Covers qualifying TypedDict annotations, wrappers, negative annotation cases, nested values, and the prior PEP 604 regression.
type-discipline-budget.json Ratchets the LIT002 ceiling from 27139 to 26901 to match the intended reduction.

Reviews (4): Last reviewed commit: "Merge remote-tracking branch 'origin/lit..." | Re-trigger Greptile

Comment thread scripts/check_type_discipline.py
Comment thread scripts/check_type_discipline.py
Comment thread scripts/check_type_discipline.py
Comment thread tests/test_litellm/test_check_type_discipline.py Outdated
@CLAassistant

CLAassistant commented Aug 14, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri
mateo-berri force-pushed the litellm_lit002_typeddict_dict_literals branch from c943949 to 316732b Compare August 14, 2026 03:01
…itellm_lit002_typeddict_dict_literals

# Conflicts:
#	type-discipline-budget.json
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread scripts/check_type_discipline.py
@codecov

codecov Bot commented Aug 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix is ON. A cloud agent has been kicked off to fix the reported issue. You can view the agent here.

Reviewed by Cursor Bugbot for commit 3120f26. Configure here.

if head == "Annotated":
first = annotation.slice.elts[0] if isinstance(annotation.slice, ast.Tuple) and annotation.slice.elts else None
return first is not None and _is_typeddict_annotation(first)
return head is not None and head not in NON_TYPEDDICT_HEADS

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Union annotations falsely exempt Mapping

Medium Severity

_is_typeddict_annotation treats a Union[...] subscript as a TypedDict head and never inspects its arms. Optional and PEP 604 | unwrap correctly, so Mapping/Any/object stay flagged there, but the same annotations under typing.Union wrongly earn the LIT002 exemption even though basedpyright accepts those assignments.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3120f26. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ruff hard-enforces UP007/UP045 repo-wide at zero violations, so a typing.Union annotation can't land here. The PEP 604 and Optional spellings are already checked

@mateo-berri
mateo-berri merged commit 13d94ec into litellm_internal_staging Aug 15, 2026
80 of 81 checks passed
@mateo-berri
mateo-berri deleted the litellm_lit002_typeddict_dict_literals branch August 15, 2026 23:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants