Add a ScopeKind for the __class__ cell (#20048)

Summary
--

This PR aims to resolve (or help to resolve) #18442 and #19357 by
encoding the CPython semantics around the `__class__` cell in our
semantic model. Namely,

> `__class__` is an implicit closure reference created by the compiler
if any methods in a class body refer to either `__class__` or super.

from the Python
[docs](https://docs.python.org/3/reference/datamodel.html#creating-the-class-object).

As noted in the variant docs by @AlexWaygood, we don't fully model this
behavior, opting always to create the `__class__` cell binding in a new
`ScopeKind::DunderClassCell` around each method definition, without
checking if any method in the class body actually refers to `__class__`
or `super`.

As such, this PR fixes #18442 but not #19357.

Test Plan
--

Existing tests, plus the tests from #19783, which now pass without any
rule-specific code.

Note that we opted not to alter the behavior of F841 here because
flagging `__class__` in these cases still seems helpful. See the
discussion in
https://github.com/astral-sh/ruff/pull/20048#discussion_r2296252395 and
in the test comments for more information.

---------

Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
Co-authored-by: Mikko Leppänen <mleppan23@gmail.com>
This commit is contained in:
Brent Westbrook 2025-08-26 09:49:08 -04:00 committed by GitHub
parent 911d5cc973
commit bc7274d148
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 287 additions and 37 deletions

View file

@ -446,7 +446,7 @@ impl Ranged for Binding<'_> {
/// ID uniquely identifying a [Binding] in a program.
///
/// Using a `u32` to identify [Binding]s should be sufficient because Ruff only supports documents with a
/// size smaller than or equal to `u32::max`. A document with the size of `u32::max` must have fewer than `u32::max`
/// size smaller than or equal to `u32::MAX`. A document with the size of `u32::MAX` must have fewer than `u32::MAX`
/// bindings because bindings must be separated by whitespace (and have an assignment).
#[newtype_index]
pub struct BindingId;
@ -672,6 +672,24 @@ pub enum BindingKind<'a> {
/// Stores the ID of the binding that was shadowed in the enclosing
/// scope, if any.
UnboundException(Option<BindingId>),
/// A binding to `__class__` in the implicit closure created around every method in a class
/// body, if any method refers to either `__class__` or `super`.
///
/// ```python
/// class C:
/// __class__ # NameError: name '__class__' is not defined
///
/// def f():
/// print(__class__) # allowed
///
/// def g():
/// nonlocal __class__ # also allowed because the scope is *not* the function scope
/// ```
///
/// See <https://docs.python.org/3/reference/datamodel.html#creating-the-class-object> for more
/// details.
DunderClassCell,
}
bitflags! {

View file

@ -404,22 +404,11 @@ impl<'a> SemanticModel<'a> {
}
}
let mut seen_function = false;
let mut import_starred = false;
let mut class_variables_visible = true;
for (index, scope_id) in self.scopes.ancestor_ids(self.scope_id).enumerate() {
let scope = &self.scopes[scope_id];
if scope.kind.is_class() {
// Allow usages of `__class__` within methods, e.g.:
//
// ```python
// class Foo:
// def __init__(self):
// print(__class__)
// ```
if seen_function && matches!(name.id.as_str(), "__class__") {
return ReadResult::ImplicitGlobal;
}
// Do not allow usages of class symbols unless it is the immediate parent
// (excluding type scopes), e.g.:
//
@ -442,7 +431,13 @@ impl<'a> SemanticModel<'a> {
// Allow class variables to be visible for an additional scope level
// when a type scope is seen — this covers the type scope present between
// function and class definitions and their parent class scope.
class_variables_visible = scope.kind.is_type() && index == 0;
//
// Also allow an additional level beyond that to cover the implicit
// `__class__` closure created around methods and enclosing the type scope.
class_variables_visible = matches!(
(scope.kind, index),
(ScopeKind::Type, 0) | (ScopeKind::DunderClassCell, 1)
);
if let Some(binding_id) = scope.get(name.id.as_str()) {
// Mark the binding as used.
@ -614,7 +609,6 @@ impl<'a> SemanticModel<'a> {
}
}
seen_function |= scope.kind.is_function();
import_starred = import_starred || scope.uses_star_imports();
}
@ -658,21 +652,19 @@ impl<'a> SemanticModel<'a> {
}
}
let mut seen_function = false;
let mut class_variables_visible = true;
for (index, scope_id) in self.scopes.ancestor_ids(scope_id).enumerate() {
let scope = &self.scopes[scope_id];
if scope.kind.is_class() {
if seen_function && matches!(symbol, "__class__") {
return None;
}
if !class_variables_visible {
continue;
}
}
class_variables_visible = scope.kind.is_type() && index == 0;
seen_function |= scope.kind.is_function();
class_variables_visible = matches!(
(scope.kind, index),
(ScopeKind::Type, 0) | (ScopeKind::DunderClassCell, 1)
);
if let Some(binding_id) = scope.get(symbol) {
match self.bindings[binding_id].kind {
@ -786,15 +778,15 @@ impl<'a> SemanticModel<'a> {
}
if scope.kind.is_class() {
if seen_function && matches!(symbol, "__class__") {
return None;
}
if !class_variables_visible {
continue;
}
}
class_variables_visible = scope.kind.is_type() && index == 0;
class_variables_visible = matches!(
(scope.kind, index),
(ScopeKind::Type, 0) | (ScopeKind::DunderClassCell, 1)
);
seen_function |= scope.kind.is_function();
if let Some(binding_id) = scope.get(symbol) {
@ -1353,11 +1345,12 @@ impl<'a> SemanticModel<'a> {
self.scopes[scope_id].parent
}
/// Returns the first parent of the given [`Scope`] that is not of [`ScopeKind::Type`], if any.
/// Returns the first parent of the given [`Scope`] that is not of [`ScopeKind::Type`] or
/// [`ScopeKind::DunderClassCell`], if any.
pub fn first_non_type_parent_scope(&self, scope: &Scope) -> Option<&Scope<'a>> {
let mut current_scope = scope;
while let Some(parent) = self.parent_scope(current_scope) {
if parent.kind.is_type() {
if matches!(parent.kind, ScopeKind::Type | ScopeKind::DunderClassCell) {
current_scope = parent;
} else {
return Some(parent);
@ -1366,11 +1359,15 @@ impl<'a> SemanticModel<'a> {
None
}
/// Returns the first parent of the given [`ScopeId`] that is not of [`ScopeKind::Type`], if any.
/// Returns the first parent of the given [`ScopeId`] that is not of [`ScopeKind::Type`] or
/// [`ScopeKind::DunderClassCell`], if any.
pub fn first_non_type_parent_scope_id(&self, scope_id: ScopeId) -> Option<ScopeId> {
let mut current_scope_id = scope_id;
while let Some(parent_id) = self.parent_scope_id(current_scope_id) {
if self.scopes[parent_id].kind.is_type() {
if matches!(
self.scopes[parent_id].kind,
ScopeKind::Type | ScopeKind::DunderClassCell
) {
current_scope_id = parent_id;
} else {
return Some(parent_id);
@ -2649,16 +2646,16 @@ pub enum ReadResult {
/// The `x` in `print(x)` is resolved to the binding of `x` in `x = 1`.
Resolved(BindingId),
/// The read reference is resolved to a context-specific, implicit global (e.g., `__class__`
/// The read reference is resolved to a context-specific, implicit global (e.g., `__qualname__`
/// within a class scope).
///
/// For example, given:
/// ```python
/// class C:
/// print(__class__)
/// print(__qualname__)
/// ```
///
/// The `__class__` in `print(__class__)` is resolved to the implicit global `__class__`.
/// The `__qualname__` in `print(__qualname__)` is resolved to the implicit global `__qualname__`.
ImplicitGlobal,
/// The read reference is unresolved, but at least one of the containing scopes contains a

View file

@ -166,9 +166,49 @@ bitflags! {
}
}
#[derive(Debug, is_macro::Is)]
#[derive(Clone, Copy, Debug, is_macro::Is)]
pub enum ScopeKind<'a> {
Class(&'a ast::StmtClassDef),
/// The implicit `__class__` scope surrounding a method which allows code in the
/// method to access `__class__` at runtime. The closure sits in between the class
/// scope and the function scope.
///
/// Parameter defaults in methods cannot access `__class__`:
///
/// ```pycon
/// >>> class Bar:
/// ... def method(self, x=__class__): ...
/// ...
/// Traceback (most recent call last):
/// File "<python-input-6>", line 1, in <module>
/// class Bar:
/// def method(self, x=__class__): ...
/// File "<python-input-6>", line 2, in Bar
/// def method(self, x=__class__): ...
/// ^^^^^^^^^
/// NameError: name '__class__' is not defined
/// ```
///
/// However, type parameters in methods *can* access `__class__`:
///
/// ```pycon
/// >>> class Foo:
/// ... def bar[T: __class__](): ...
/// ...
/// >>> Foo.bar.__type_params__[0].__bound__
/// <class '__main__.Foo'>
/// ```
///
/// Note that this is still not 100% accurate! At runtime, the implicit `__class__`
/// closure is only added if the name `super` (has to be a name -- `builtins.super`
/// and similar don't count!) or the name `__class__` is used in any method of the
/// class. However, accurately emulating that would be both complex and probably
/// quite expensive unless we moved to a double-traversal of each scope similar to
/// ty. It would also only matter in extreme and unlikely edge cases. So we ignore
/// that subtlety for now.
///
/// See <https://docs.python.org/3/reference/datamodel.html#creating-the-class-object>.
DunderClassCell,
Function(&'a ast::StmtFunctionDef),
Generator {
kind: GeneratorKind,