Compare commits

...

1 Commits

Author SHA1 Message Date
Claude
47f29b9250 Add root cause analysis for issue #2438
Document the root cause of incorrect type inference when a try-except
block precedes a relative import. The bug is in how declaration
reachability affects the code path in place_by_id - when reachability
is AlwaysTrue, the code shortcuts directly to declared type without
consulting bindings, causing submodule bindings to be silently ignored.
2026-01-14 00:31:58 +00:00

View File

@@ -0,0 +1,106 @@
# Root Cause Analysis: Issue #2438
## Summary
**Issue**: A `try-except` block before an import causes incorrect type inference in function scopes. With `from .demo import demo` where `demo.py` contains `demo = 42`, the function scope incorrectly shows `<module 'repro.demo'> | Literal[42]` instead of just `Literal[42]`.
## Root Cause
The bug is caused by an interaction between how submodule import bindings are created and how declaration reachability affects the type resolution path in `place_by_id`.
### Key Finding
When processing `from .demo import demo` in an `__init__.py`:
1. **Two bindings are created for symbol `demo`**:
- `ImportFromSubmoduleDefinitionNodeRef` - creates only a **binding** (type: module)
- `ImportFromDefinitionNodeRef` - creates **both** a declaration and a binding (type: Literal[42])
2. **Reachability affects which path is taken**:
- **Without try-except**: All declarations have `AlwaysTrue` reachability
- **With try-except before import**: All declarations have `Ambiguous` reachability
3. **Declaration definedness calculation** (`place.rs:1520-1530`):
```rust
let boundness = match boundness_analysis {
BoundnessAnalysis::AssumeBound => {
if all_declarations_definitely_reachable {
Definedness::AlwaysDefined // When reachability is AlwaysTrue
} else {
Definedness::PossiblyUndefined // When reachability is Ambiguous
}
}
...
};
```
Where `all_declarations_definitely_reachable` is `true` only when ALL declarations have `AlwaysTrue` reachability (via `is_always_true()`).
4. **Dispatch in `place_by_id`** (`place.rs:949-956`):
```rust
// Place is declared, trust the declared type
place_and_quals @ PlaceAndQualifiers {
place: Place::Defined(DefinedPlace {
definedness: Definedness::AlwaysDefined, // Case 1 matches here
..
}),
qualifiers: _,
} => place_and_quals, // Returns declaration type directly, ignoring bindings
```
### Result
- **Without try-except** (AlwaysTrue): Declaration is `AlwaysDefined`, so declared type (`Literal[42]`) is returned directly. The submodule binding (module type) is never consulted.
- **With try-except** (Ambiguous): Declaration is `PossiblyUndefined`, so code falls through to union declarations with bindings. Both the module type and `Literal[42]` are included in the result.
## Why This Is a Bug
The intent of submodule import tracking is to ensure that `demo` can be accessed as both:
- The actual imported value (`Literal[42]`)
- The submodule itself (for cases like `package.demo`)
However, when declaration reachability is `AlwaysTrue`, the code shortcuts directly to the declared type without considering bindings. This means the submodule binding is silently ignored, which works correctly for module-level access but breaks when accessed from function scope (which uses `AllReachable` bindings).
## Affected Code Paths
- `crates/ty_python_semantic/src/place.rs`:
- `place_by_id` (lines 949-956): Early return for `AlwaysDefined` declarations
- `place_from_declarations_impl` (lines 1520-1530): Boundness calculation based on reachability
- `crates/ty_python_semantic/src/semantic_index/builder.rs`:
- Lines 1531-1546: `ImportFromSubmoduleDefinitionNodeRef` creation (binding only)
- Lines 1677-1686: `ImportFromDefinitionNodeRef` creation (declaration + binding)
## Potential Fixes
1. **Make ImportFromSubmodule also create a declaration**: This would ensure the submodule type is included in the declared type computation.
2. **Special-case submodule imports**: When resolving a symbol that has both a submodule binding and another binding, always consider both.
3. **Reconsider the `AlwaysDefined` shortcut**: Perhaps the early return for `AlwaysDefined` declarations should still consider certain bindings (like submodule imports).
## Test Case
A failing test that reproduces this issue:
```python
# package/__init__.py - Without try-except (BUG: returns only Literal[42])
from .demo import demo
def foo():
reveal_type(demo) # Should be Literal[42], NOT module | Literal[42]
# package2/__init__.py - With try-except (Incorrectly returns union)
try:
pass
except:
pass
from .demo import demo
def foo():
reveal_type(demo) # Shows: <module 'package2.demo'> | Literal[42]
```
Both should produce the same result (`Literal[42]`), but currently Case 1 works correctly while Case 2 incorrectly includes the module type.