Skip to content

fix(python): avoid TypeVar collisions with generated class names - #4307

Open
Vaibhav701161 wants to merge 1 commit into
BoundaryML:canaryfrom
Vaibhav701161:fix/python-typevar-class-name-collision
Open

fix(python): avoid TypeVar collisions with generated class names#4307
Vaibhav701161 wants to merge 1 commit into
BoundaryML:canaryfrom
Vaibhav701161:fix/python-typevar-class-name-collision

Conversation

@Vaibhav701161

@Vaibhav701161 Vaibhav701161 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Closes #4083.

The bug

A generic parameter and an ordinary definition can share a name. When they do, the generated Python Pydantic v2 leaf declared the TypeVar first and then rebound the same identifier as a class, so every generic reference in the file resolved to the class instead of the parameter:

class T {
  label string
}

class Box<T> {
  item T
}

function echo<T>(value: T) -> T { ... }
T = typing.TypeVar("T")


class T(pydantic.BaseModel):          # rebinds T; the TypeVar is unreachable
    label: str


class Box(pydantic.BaseModel, typing.Generic[T]):   # T is the model now
    item: T

This is not cosmetic - the generated SDK does not import at all:

TypeError: Parameters to Generic[...] must all be type variables
           or parameter specification variables.

Root cause

Two layers had to agree, and neither did.

1. Name resolution (baml_compiler2_tir). lower_path only consulted resolve_type_var inside its Err fallback - i.e. only once concrete-type resolution had already failed. With a class T in the same package that lookup succeeds, so the T in function echo<T>(value: T) lowered to Ty::Class, not Ty::TypeVar. The generic parameter was being captured by the class before codegen ever ran.

2. Binding allocation (sdkgen_python_pydantic2). Leaf TypeVars were emitted under their source spelling with nothing checking it against the rest of the module namespace.

The change

  • lower_path now resolves a bare, unparameterized in-scope generic parameter before resolve_type, so the parameter shadows a same-named concrete type. Qualified paths (pkg.T) still reach the class through the namespace, and a parameterized spelling (T<int>) still falls through to the existing path.
  • New LeafBody::allocated_typevars() maps each BAML generic name to a collision-free Python identifier, appending _ until the name is free. The reserved set is the leaf's own symbols, its cross-leaf import anchors, and MODULE_BINDINGS — the stdlib / baml_bridge / builtins names a leaf can bind on its own, so a parameter named typing can't rebind the typing import either.
  • TranslateCtx carries that map so declarations and every reference render from the same allocation. .py and .pyi compute the reserved set identically - it is deliberately independent of which file is being rendered — so the two files can never disagree on a spelling.

Runtime-facing names are untouched: the binding is still typing.TypeVar("T") and type_params=["T"] still carries the BAML spelling. Only the local Python identifier moves, and only when it would otherwise collide.

-T = typing.TypeVar("T")
+T_ = typing.TypeVar("T")


 class T(pydantic.BaseModel):
     label: str


-class Box(pydantic.BaseModel, typing.Generic[T]):
-    item: T
+class Box(pydantic.BaseModel, typing.Generic[T_]):
+    item: T_

Tests

  • typevar_binding_avoids_same_leaf_symbol_name - the issue's scenario end to end; asserts the class keeps the plain name, .py and .pyi both follow the allocated one, and type_params stays the BAML spelling.
  • typevar_binding_escapes_chained_and_import_collisions - allocation walks a chain (T and T_ both taken, so the parameter lands on T__) and escapes an import name no symbol owns (a parameter named typing becomes typing_).
  • test_bare_generic_param_shadows_same_named_class_in_codegen - covers the resolution half: with class T and function echo<T> in one file, the argument and return types lower to Ty::TypeVar, with no diagnostics.

Both scenarios were also checked by importing the generated package under real Pydantic v2. The pre-fix output raises TypeError on import; the post-fix output imports cleanly and keeps the BAML spelling at runtime:

scenario parameters after import instantiation
class T + Box<T> (~T,) Box[int](item=3)
T/T_ taken, params T + typing (~T, ~typing) Box[int, str](item=1, tag='x')

Note on scope

lower_type_expr.rs and client_codegen.rs sit outside the issue's stated affected area. They are in scope because renaming alone would not have fixed anything: the type checker was handing codegen a Ty::Class where the schema said Ty::TypeVar, so the wrong entity was being referenced regardless of what it was called.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed generic type parameters being incorrectly shadowed by same-named concrete types.
    • Improved generated Python typing when generic names conflict with imports, symbols, or other type variables.
    • Ensured generic classes, aliases, callbacks, and annotations consistently reference their allocated type-variable names.
  • Tests

    • Added coverage for generic shadowing and type-variable name collisions in generated .py and .pyi files.

@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown

@Vaibhav701161 is attempting to deploy a commit to the Boundary Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The compiler now resolves bare generic parameters before same-named concrete types. The Python Pydantic v2 generator allocates unique TypeVar identifiers and propagates them through runtime code, stubs, annotations, aliases, and protocol rendering.

Changes

Generic type resolution and Python TypeVar rendering

Layer / File(s) Summary
Compiler generic resolution
baml_language/crates/baml_compiler2_tir/src/lower_type_expr.rs, baml_language/crates/baml_project/src/client_codegen.rs
Bare in-scope generic parameters now take precedence over same-named concrete types. Regression coverage verifies generated argument and return types use the generic parameter.
Collision-free TypeVar allocation and translation context
baml_language/sdks/python/rust/sdkgen_python_pydantic2/src/leaf.rs, baml_language/sdks/python/rust/sdkgen_python_pydantic2/src/translate_ty.rs
The generator reserves module bindings and maps source generic names to unique emitted identifiers. Type translation uses this mapping when available.
Runtime and stub rendering
baml_language/sdks/python/rust/sdkgen_python_pydantic2/src/leaf.rs
Allocated names now apply to .py and .pyi class bases, annotations, aliases, symbols, callback protocols, and callable-child protocols. Runtime TypeVar arguments retain source names.
Collision regression tests
baml_language/sdks/python/rust/sdkgen_python_pydantic2/src/lib.rs
Tests cover collisions with symbols, previously allocated names, and imports in generated .py and .pyi files.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant BAMLCompiler
  participant LeafBody
  participant TranslateCtx
  participant GeneratedPython
  BAMLCompiler->>BAMLCompiler: resolve bare generic parameter before concrete type
  BAMLCompiler->>LeafBody: provide generic source names
  LeafBody->>LeafBody: allocate collision-free identifiers
  LeafBody->>TranslateCtx: pass TypeVar mapping
  TranslateCtx->>GeneratedPython: render mapped annotations and bases
  LeafBody->>GeneratedPython: emit TypeVar declarations with source names
Loading

Possibly related PRs

Suggested reviewers: sxlijin

Poem

A rabbit finds a TypeVar name,
And keeps each binding clear,
T_ hops past a class called T,
While T stays true and dear.
Python and stubs match their steps.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the primary change: preventing Python TypeVar name collisions with generated class names.
Linked Issues check ✅ Passed The changes satisfy [#4083] by allocating collision-free TypeVar names and using them consistently in generated .py and .pyi output with regression tests.
Out of Scope Changes check ✅ Passed All changes support [#4083], including generic resolution, TypeVar allocation, translated references, generated output, and regression coverage.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Vaibhav701161
Vaibhav701161 marked this pull request as ready for review July 31, 2026 18:10

@coderabbitai coderabbitai 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.

🧹 Nitpick comments (1)
baml_language/sdks/python/rust/sdkgen_python_pydantic2/src/translate_ty.rs (1)

94-99: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a direct unit test for the typevars substitution path.

The substitution logic here is correct, but no Case in this file's translate_ty_covers_phase_g3_matrix exercises a populated ctx.typevars map. Coverage for the substitution behavior currently comes only from full-pipeline tests in lib.rs (typevar_binding_avoids_same_leaf_symbol_name, typevar_binding_escapes_chained_and_import_collisions).

Add a Case that builds a ctx with typevars: Some(Rc::new(map)) containing a "T" -> "T_" entry, asserting translate_ty emits "T_", plus a case where the map is present but lacks the key, asserting the fallback to the raw source name. This keeps the substitution behavior covered at the unit level, matching this file's existing test style.

As per path instructions, "**/*.rs: Prefer writing Rust unit tests over integration tests where possible."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@baml_language/sdks/python/rust/sdkgen_python_pydantic2/src/translate_ty.rs`
around lines 94 - 99, Add direct unit-test cases to
translate_ty_covers_phase_g3_matrix for Ty::TypeVar: one with ctx.typevars
containing “T” mapped to “T_” and asserting the substituted output, and another
with a present map missing the key and asserting the raw source-name fallback.
Follow the existing Case/test style and construct the context using the file’s
established Rc map setup.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@baml_language/sdks/python/rust/sdkgen_python_pydantic2/src/translate_ty.rs`:
- Around line 94-99: Add direct unit-test cases to
translate_ty_covers_phase_g3_matrix for Ty::TypeVar: one with ctx.typevars
containing “T” mapped to “T_” and asserting the substituted output, and another
with a present map missing the key and asserting the raw source-name fallback.
Follow the existing Case/test style and construct the context using the file’s
established Rc map setup.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: dc2c385b-c4f8-4ce5-8c0c-ca88f279b1a8

📥 Commits

Reviewing files that changed from the base of the PR and between e26ee02 and a635a0f.

📒 Files selected for processing (5)
  • baml_language/crates/baml_compiler2_tir/src/lower_type_expr.rs
  • baml_language/crates/baml_project/src/client_codegen.rs
  • baml_language/sdks/python/rust/sdkgen_python_pydantic2/src/leaf.rs
  • baml_language/sdks/python/rust/sdkgen_python_pydantic2/src/lib.rs
  • baml_language/sdks/python/rust/sdkgen_python_pydantic2/src/translate_ty.rs

@2kai2kai2 2kai2kai2 self-assigned this Aug 12, 2026
@addiplus

Copy link
Copy Markdown

Disclosure first: I have an open PR in the same area (#4070), so discount this
accordingly. The lower_path hoist here is the right fix and it landed in this PR
first; I am not claiming otherwise.

This no longer applies to canary, and the reason is worth knowing. a0f4605e8
("hir_ty: rust-analyzer-style type inference foundations", #4301) deleted
baml_compiler2_tir/src/lower_type_expr.rs and moved path lowering to
baml_compiler2_hir_ty/src/lower.rs. Merging your head into canary right now gives
exactly two conflicts:

  • baml_compiler2_tir/src/lower_type_expr.rs - modify/delete
  • baml_project/src/client_codegen.rs - content conflict, in the mod tests block

leaf.rs, lib.rs and translate_ty.rs all auto-merge clean, so the Python half of
this PR survives the refactor untouched. It is only the compiler half that needs a
new home.

Where the hoist goes now. In hir_ty/src/lower.rs, lower_path opens at :594
with if let Some(def) = self.resolve_type(segments) at :604, and the in-scope
generic-param check is Fallback 1 at :608 - i.e. the same ordering your hoist fixes:
resolve_type consults only package_items, never generic_params, so a bare T
with a class T present returns the class and never reaches :608.

One thing to check when you port it: the new file already gives generic params first
refusal on the projection path. The projection_head closure at :658 searches
generic_params before probe_projection_prefix (:414) is ever called to resolve a
prefix as a type. So the placement question is genuinely different from the old file,
and worth re-deriving rather than transplanting.

T_ = typing.TypeVar("T") will not pass pyright. I ran it - pyright 1.1.412, the
same range the harness pins (harness_setup/src/templates/pyproject.toml:14 requires
pyright>=1.1.410):

error: TypeVar must be assigned to a variable named "T" (reportGeneralTypeIssues)

The identical file with T_ = typing.TypeVar("T_") is clean. This matters here
because harness_setup/src/python_pydantic2.rs:197 generates a per-fixture pyright
test that runs uv run pyright, so it surfaces as soon as any generic fixture exists.
Emitting typing.TypeVar("{emitted}") is safe for the wire: both
render_generic_kwargs call sites (leaf.rs:1255, :1282) pass f.generic_params /
m.generic_params, the declaration-side BAML names, and your patch does not touch
either - which is what your own doc comment says too ("Keys stay the BAML generic
name").

MODULE_BINDINGS misses four kinds of binding the generator writes into the same
file.
At canary lib.rs:225-231, render_package_init and render_leaf_body render
into one file, and that file opens with from __future__ import annotations
(:333, and :390 for the root), so annotations is a live binding.
render_package_init_pyi emits from . import <child> per child at :354, so child
package names are live in the .pyi. render_root_init binds BamlRuntime,
set_type_map, _inlinedbaml and _TYPE_MAP at :391-393. And render_leaf_body
emits __all__ (leaf.rs:1574, :2250). A parameter spelled like any of those still
rebinds them.

Smaller note on the bump rule: generic_typevars() returns names alphabetically
(leaf.rs:402), and the allocator does while reserved.contains(&emitted) { emitted.push('_') } with a reserved.insert(emitted) per iteration. So allocation is
order-dependent - what a given parameter ends up spelled depends on what earlier names
claimed. Seeding reserved with all the raw parameter spellings up front makes it
order-independent.

One thing your PR does better than mine. Pinning this at the codegen boundary
(client_codegen.rs, asserting function.arguments[0].ty is a TypeVar) is a more
durable anchor than a compiler snapshot. The 04_tir snapshot stage I had been
relying on was deleted wholesale by a0f4605e8 - every *__04_tir.snap in the repo
is gone. A behavioral assertion at the symbol-pool boundary would have survived that
refactor. I am going to move mine.

Offer. I have the wider reserved set and a typevar_shadowing fixture on a branch.
Happy to paste them here as hunks, or open a follow-up PR once this lands - whichever
is less work. I have no interest in racing this.

Last thing: discover_fixtures (harness_setup/src/lib.rs:164-186) enrolls any
directory containing a baml_src/ across every SDK, with no per-SDK opt-out - the only
two filters are path.is_dir() and path.join("baml_src").is_dir(). When I added a
small reserved-keywords fixture in #4070 it exercised the TypeScript and C++ generators
too and turned up declaration-name escape gaps that nothing had covered before. Worth
knowing before you add a fixture here, in both directions: it is cheap coverage, and it
can go red in generators you were not thinking about.

@sxlijin

sxlijin commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Hi Vaibhav! I'm going to close this PR and file a separate issue internally for this, I talked with @2kai2kai2 and we both agree that this shadowing should not be allowed.

@sxlijin

sxlijin commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

While working through the implications of the alternative fix, we changed our mind. The original reason we were thinking of banning shadowing was that in BAML we were concerned about not having a way to reference class T in a context with typevar T, but you can bypass the shadowed T with root.T

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.

Python Pydantic v2: avoid ordinary class and module TypeVar name shadowing

4 participants