diff --git a/crates/ty_python_semantic/resources/mdtest/import/dunder_file_attribute.md b/crates/ty_python_semantic/resources/mdtest/import/dunder_file_attribute.md new file mode 100644 index 0000000000..05a2e328c0 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/import/dunder_file_attribute.md @@ -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 +``` diff --git a/crates/ty_python_semantic/resources/mdtest/scopes/moduletype_attrs.md b/crates/ty_python_semantic/resources/mdtest/scopes/moduletype_attrs.md index 2b1419f19d..af3f7dc892 100644 --- a/crates/ty_python_semantic/resources/mdtest/scopes/moduletype_attrs.md +++ b/crates/ty_python_semantic/resources/mdtest/scopes/moduletype_attrs.md @@ -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 diff --git a/crates/ty_python_semantic/src/place.rs b/crates/ty_python_semantic/src/place.rs index 312a782e68..5267984154 100644 --- a/crates/ty_python_semantic/src/place.rs +++ b/crates/ty_python_semantic/src/place.rs @@ -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>(