[ty] Override __file__ to str when applicable on imported modules (#22333)

This commit is contained in:
Rob Hand
2026-01-15 17:08:50 +00:00
committed by GitHub
parent aa9f1b27fc
commit eca58ca1d3
3 changed files with 139 additions and 15 deletions

View File

@@ -0,0 +1,84 @@
# The `__file__` attribute on imported modules
## Module successfully resolved
```py
from b import __file__ as module_path
from stub import __file__ as stub_path
from override import __file__ as overidden_path
reveal_type(__file__) # revealed: str
reveal_type(module_path) # revealed: str
reveal_type(stub_path) # revealed: str
reveal_type(overidden_path) # revealed: None
# NOTE: This import fails at runtime as this is a C Extension
# with no `__file__` global. It's hard for us to determine this
# right now, however (all we know is it comes from a stub file),
# and if it did exist then it would be of type `str` since `sys`
# is not a namespace packages. This behaviour also matches other
# type checkers.
from sys import __file__ as no_path
reveal_type(no_path) # revealed: str
```
`b.py`:
```py
```
`override.py`:
```py
__file__ = None
```
`stub.pyi`:
```pyi
```
## Module resolution failed
```py
from bar import __file__ as module_path # error: "Cannot resolve imported module `bar`"
reveal_type(module_path) # revealed: Unknown
```
## Non-namespace packages have `__file__` available
`a/__init__.py`:
```py
```
`a/b.py`:
```py
```
```py
import a
reveal_type(a.__file__) # revealed: str
```
## `__file__` is set to `None` for namespace packages
`namespace/c.py`:
```py
```
```py
import namespace
# TODO: `__file__` does exist on namespace packages but is set to `None`;
# this is a false positive
#
# error: [unresolved-attribute] "Module `namespace` has no member `__file__`"
reveal_type(namespace.__file__) # revealed: Unknown
```

View File

@@ -99,9 +99,14 @@ import typing
reveal_type(typing.__name__) # revealed: str
reveal_type(typing.__init__) # revealed: bound method ModuleType.__init__(name: str, doc: str | None = ...) -> None
# For a stub module, we don't know that `__file__` is a string (at runtime it may be entirely
# unset, but we follow typeshed here):
reveal_type(typing.__file__) # revealed: str | None
# Note that since the source for the `typing` module is a stub file,
# we can't know for sure that it's not a C extension at runtime,
# and C extensions don't necessarily have a `__file__` global attribute
# at all (in which case this attribute access would fail). However, we
# *do* know that `typing` is not a namespace package, so if `__file__`
# does exist, it will be of type `str` (`__file__` is only `None` for
# namespace packages).
reveal_type(typing.__file__) # revealed: str
# These come from `builtins.object`, not `types.ModuleType`:
reveal_type(typing.__eq__) # revealed: bound method ModuleType.__eq__(value: object, /) -> bool

View File

@@ -1098,6 +1098,18 @@ fn symbol_impl<'db>(
considered_definitions: ConsideredDefinitions,
) -> PlaceAndQualifiers<'db> {
let _span = tracing::trace_span!("symbol", ?name).entered();
let place = place_table(db, scope)
.symbol_id(name)
.map(|symbol| {
place_by_id(
db,
scope,
symbol.into(),
requires_explicit_reexport,
considered_definitions,
)
})
.unwrap_or_default();
if name == "platform"
&& file_to_module(db, scope.file(db))
@@ -1111,20 +1123,43 @@ fn symbol_impl<'db>(
// Fall through to the looked up type
}
}
} else if name == "__file__"
&& let Some(module) = file_to_module(db, scope.file(db))
{
// We special-case `__file__` here because we know that for a successfully imported
// non-namespace-package Python module, that hasn't been explicitly overridden it
// is always a string, even though typeshed says `str | None`. For a namespace package,
// meanwhile, it will always be `None`.
//
// Note that C-extension modules (stdlib examples include `sys`, `itertools`, etc.)
// may not have a `__file__` attribute at runtime at all, but that doesn't really
// affect the *type* of the attribute, just the *boundness*. There's no way for us
// to know right now whether a stub represents a C extension or not, so for now we
// do not attempt to detect this; we just infer `str` still. This matches the
// behaviour of other major type checkers.
let default_type = if module.file(db).is_some() {
KnownClass::Str.to_instance(db)
} else {
Type::none(db)
};
match place.place {
Place::Defined(defined_place) => match defined_place.definedness {
Definedness::AlwaysDefined => return place,
Definedness::PossiblyUndefined => {
let new_type = UnionType::from_elements(db, [defined_place.ty, default_type]);
let def_place = DefinedPlace::new(new_type)
.with_definedness(Definedness::AlwaysDefined)
.with_widening(defined_place.widening)
.with_origin(defined_place.origin);
return Place::Defined(def_place).into();
}
},
Place::Undefined => return Place::bound(default_type).into(),
}
}
place_table(db, scope)
.symbol_id(name)
.map(|symbol| {
place_by_id(
db,
scope,
symbol.into(),
requires_explicit_reexport,
considered_definitions,
)
})
.unwrap_or_default()
place
}
fn place_impl<'db>(