From be8eb92946bb6bcc83674be050f118aa00d92d6a Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sat, 13 Dec 2025 17:10:25 -0500 Subject: [PATCH] [ty] Add support for `__qualname__` and other implicit class attributes (#21966) ## Summary Closes https://github.com/astral-sh/ty/issues/1873 --- .../mdtest/scopes/class_implicit_attrs.md | 120 ++++++++++++++++++ crates/ty_python_semantic/src/place.rs | 30 +++++ .../src/types/infer/builder.rs | 25 +++- 3 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 crates/ty_python_semantic/resources/mdtest/scopes/class_implicit_attrs.md diff --git a/crates/ty_python_semantic/resources/mdtest/scopes/class_implicit_attrs.md b/crates/ty_python_semantic/resources/mdtest/scopes/class_implicit_attrs.md new file mode 100644 index 0000000000..7130538acf --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/scopes/class_implicit_attrs.md @@ -0,0 +1,120 @@ +# Implicit class body attributes + +## Class body implicit attributes + +Python makes certain names available implicitly inside class body scopes. These are `__qualname__`, +`__module__`, and `__doc__`, as documented at +. + +```py +class Foo: + reveal_type(__qualname__) # revealed: str + reveal_type(__module__) # revealed: str + reveal_type(__doc__) # revealed: str | None +``` + +## `__firstlineno__` (Python 3.13+) + +Python 3.13 added `__firstlineno__` to the class body namespace. + +### Available in Python 3.13+ + +```toml +[environment] +python-version = "3.13" +``` + +```py +class Foo: + reveal_type(__firstlineno__) # revealed: int +``` + +### Not available in Python 3.12 and earlier + +```toml +[environment] +python-version = "3.12" +``` + +```py +class Foo: + # error: [unresolved-reference] + __firstlineno__ +``` + +## Nested classes + +These implicit attributes are also available in nested classes, and refer to the nested class: + +```py +class Outer: + class Inner: + reveal_type(__qualname__) # revealed: str + reveal_type(__module__) # revealed: str +``` + +## Class body implicit attributes have priority over globals + +If a global variable with the same name exists, the class body implicit attribute takes priority +within the class body: + +```py +__qualname__ = 42 +__module__ = 42 + +class Foo: + # Inside the class body, these are the implicit class attributes + reveal_type(__qualname__) # revealed: str + reveal_type(__module__) # revealed: str + +# Outside the class, the globals are visible +reveal_type(__qualname__) # revealed: Literal[42] +reveal_type(__module__) # revealed: Literal[42] +``` + +## `__firstlineno__` has priority over globals (Python 3.13+) + +The same applies to `__firstlineno__` on Python 3.13+: + +```toml +[environment] +python-version = "3.13" +``` + +```py +__firstlineno__ = "not an int" + +class Foo: + reveal_type(__firstlineno__) # revealed: int + +reveal_type(__firstlineno__) # revealed: Literal["not an int"] +``` + +## Class body implicit attributes are not visible in methods + +The implicit class body attributes are only available directly in the class body, not in nested +function scopes (methods): + +```py +class Foo: + # Available directly in the class body + x = __qualname__ + reveal_type(x) # revealed: str + + def method(self): + # Not available in methods - falls back to builtins/globals + # error: [unresolved-reference] + __qualname__ +``` + +## Real-world use case: logging + +A common use case is defining a logger with the class name: + +```py +import logging + +class MyClass: + logger = logging.getLogger(__qualname__) + reveal_type(logger) # revealed: Logger +``` diff --git a/crates/ty_python_semantic/src/place.rs b/crates/ty_python_semantic/src/place.rs index a1319a3250..21dfb955a9 100644 --- a/crates/ty_python_semantic/src/place.rs +++ b/crates/ty_python_semantic/src/place.rs @@ -1,4 +1,5 @@ use ruff_db::files::File; +use ruff_python_ast::PythonVersion; use crate::dunder_all::dunder_all_names; use crate::module_resolver::{KnownModule, file_to_module, resolve_module_confident}; @@ -1633,6 +1634,35 @@ mod implicit_globals { } } +/// Looks up the type of an "implicit class body symbol". Returns [`Place::Undefined`] if +/// `name` is not present as an implicit symbol in class bodies. +/// +/// Implicit class body symbols are symbols such as `__qualname__`, `__module__`, `__doc__`, +/// and `__firstlineno__` that Python implicitly makes available inside a class body during +/// class creation. +/// +/// See +pub(crate) fn class_body_implicit_symbol<'db>( + db: &'db dyn Db, + name: &str, +) -> PlaceAndQualifiers<'db> { + match name { + "__qualname__" => Place::bound(KnownClass::Str.to_instance(db)).into(), + "__module__" => Place::bound(KnownClass::Str.to_instance(db)).into(), + // __doc__ is `str` if there's a docstring, `None` if there isn't + "__doc__" => Place::bound(UnionType::from_elements( + db, + [KnownClass::Str.to_instance(db), Type::none(db)], + )) + .into(), + // __firstlineno__ was added in Python 3.13 + "__firstlineno__" if Program::get(db).python_version(db) >= PythonVersion::PY313 => { + Place::bound(KnownClass::Int.to_instance(db)).into() + } + _ => Place::Undefined.into(), + } +} + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] pub(crate) enum RequiresExplicitReExport { Yes, diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 43940a4d88..9ae325a9ae 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -28,9 +28,9 @@ use crate::module_resolver::{ use crate::node_key::NodeKey; use crate::place::{ ConsideredDefinitions, Definedness, LookupError, Place, PlaceAndQualifiers, TypeOrigin, - builtins_module_scope, builtins_symbol, explicit_global_symbol, global_symbol, - module_type_implicit_global_declaration, module_type_implicit_global_symbol, place, - place_from_bindings, place_from_declarations, typing_extensions_symbol, + builtins_module_scope, builtins_symbol, class_body_implicit_symbol, explicit_global_symbol, + global_symbol, module_type_implicit_global_declaration, module_type_implicit_global_symbol, + place, place_from_bindings, place_from_declarations, typing_extensions_symbol, }; use crate::semantic_index::ast_ids::node_key::ExpressionNodeKey; use crate::semantic_index::ast_ids::{HasScopedUseId, ScopedUseId}; @@ -9210,6 +9210,25 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } PlaceAndQualifiers::from(Place::Undefined) + // If we're in a class body, check for implicit class body symbols first. + // These take precedence over globals. + .or_fall_back_to(db, || { + if scope.node(db).scope_kind().is_class() + && let Some(symbol) = place_expr.as_symbol() + { + let implicit = class_body_implicit_symbol(db, symbol.name()); + if implicit.place.is_definitely_bound() { + return implicit.map_type(|ty| { + self.narrow_place_with_applicable_constraints( + place_expr, + ty, + &constraint_keys, + ) + }); + } + } + Place::Undefined.into() + }) // No nonlocal binding? Check the module's explicit globals. // Avoid infinite recursion if `self.scope` already is the module's global scope. .or_fall_back_to(db, || {