Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[red-knot] Report classes inheriting from bases with incompatible __slots__ #15129

Merged
merged 6 commits into from
Dec 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 184 additions & 0 deletions crates/red_knot_python_semantic/resources/mdtest/slots.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
# `__slots__`

## Not specified and empty

```py
class A: ...

class B:
__slots__ = ()

class C:
__slots__ = ("lorem", "ipsum")

class AB(A, B): ... # fine
class AC(A, C): ... # fine
class BC(B, C): ... # fine
class ABC(A, B, C): ... # fine
```

## Incompatible tuples

```py
class A:
__slots__ = ("a", "b")

class B:
__slots__ = ("c", "d")

class C(
A, # error: [incompatible-slots]
B, # error: [incompatible-slots]
): ...
```

## Same value

```py
class A:
__slots__ = ("a", "b")

class B:
__slots__ = ("a", "b")

class C(
A, # error: [incompatible-slots]
B, # error: [incompatible-slots]
): ...
```

## Strings

```py
class A:
__slots__ = "abc"

class B:
__slots__ = ("abc",)

class AB(
A, # error: [incompatible-slots]
B, # error: [incompatible-slots]
): ...
```

## Invalid

TODO: Emit diagnostics

```py
class NonString1:
__slots__ = 42

class NonString2:
__slots__ = b"ar"

class NonIdentifier1:
__slots__ = "42"

class NonIdentifier2:
__slots__ = ("lorem", "42")

class NonIdentifier3:
__slots__ = (e for e in ("lorem", "42"))
```

## Inheritance

```py
class A:
__slots__ = ("a", "b")

class B(A): ...

class C:
__slots__ = ("c", "d")

class D(C): ...
class E(
B, # error: [incompatible-slots]
D, # error: [incompatible-slots]
): ...
```

## Single solid base

```py
class A:
__slots__ = ("a", "b")

class B(A): ...
class C(A): ...
class D(B, A): ... # fine
class E(B, C, A): ... # fine
```

## False negatives

### Possibly unbound

```py
def _(flag: bool):
class A:
if flag:
__slots__ = ("a", "b")

class B:
__slots__ = ("c", "d")

# Might or might not be fine at runtime
class C(A, B): ...
```

### Bound but with different types

```py
def _(flag: bool):
class A:
if flag:
__slots__ = ("a", "b")
else:
__slots__ = ()

class B:
__slots__ = ("c", "d")

# Might or might not be fine at runtime
class C(A, B): ...
```

### Non-tuples

```py
class A:
__slots__ = ["a", "b"] # This is treated as "dynamic"

class B:
__slots__ = ("c", "d")

# False negative: [incompatible-slots]
class C(A, B): ...
```

### Post-hoc modifications

```py
class A:
__slots__ = ()
__slots__ += ("a", "b")

reveal_type(A.__slots__) # revealed: @Todo(Support for more binary expressions)

class B:
__slots__ = ("c", "d")

# False negative: [incompatible-slots]
class C(A, B): ...
```

### Built-ins with implicit layouts

```py
# False negative: [incompatible-slots]
class A(int, str): ...
```
1 change: 1 addition & 0 deletions crates/red_knot_python_semantic/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ mod infer;
mod mro;
mod narrow;
mod signatures;
mod slots;
mod string_annotation;
mod unpacker;

Expand Down
67 changes: 67 additions & 0 deletions crates/red_knot_python_semantic/src/types/diagnostic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ pub(crate) fn register_lints(registry: &mut LintRegistryBuilder) {
registry.register_lint(&CYCLIC_CLASS_DEFINITION);
registry.register_lint(&DIVISION_BY_ZERO);
registry.register_lint(&DUPLICATE_BASE);
registry.register_lint(&INCOMPATIBLE_SLOTS);
registry.register_lint(&INCONSISTENT_MRO);
registry.register_lint(&INDEX_OUT_OF_BOUNDS);
registry.register_lint(&INVALID_ASSIGNMENT);
Expand Down Expand Up @@ -148,6 +149,64 @@ declare_lint! {
}
}

declare_lint! {
/// ## What it does
/// Checks for classes whose bases define incompatible `__slots__`.
///
/// ## Why is this bad?
/// Inheriting from bases with incompatible `__slots__`s
/// will lead to a `TypeError` at runtime.
///
/// Classes with no or empty `__slots__` are always compatible:
///
/// ```python
/// class A: ...
/// class B:
/// __slots__ = ()
/// class C:
/// __slots__ = ("a", "b")
///
/// # fine
/// class D(A, B, C): ...
/// ```
///
/// Multiple inheritance from more than one different class
/// defining non-empty `__slots__` is not allowed:
///
/// ```python
/// class A:
/// __slots__ = ("a", "b")
///
/// class B:
/// __slots__ = ("a", "b") # Even if the values are the same
///
/// # TypeError: multiple bases have instance lay-out conflict
/// class C(A, B): ...
/// ```
///
/// ## Known problems
/// Dynamic (not tuple or string literal) `__slots__` are not checked.
/// Additionally, classes inheriting from built-in classes with implicit layouts
/// like `str` or `int` are also not checked.
///
/// ```pycon
/// >>> hasattr(int, "__slots__")
/// False
/// >>> hasattr(str, "__slots__")
/// False
/// >>> class A(int, str): ...
/// Traceback (most recent call last):
/// File "<python-input-0>", line 1, in <module>
/// class A(int, str): ...
/// TypeError: multiple bases have instance lay-out conflict
/// ```
pub(crate) static INCOMPATIBLE_SLOTS = {
summary: "detects class definitions whose MRO has conflicting `__slots__`",
status: LintStatus::preview("1.0.0"),
default_level: Level::Error,
}
}

declare_lint! {
/// TODO #14889
pub(crate) static INCONSISTENT_MRO = {
Expand Down Expand Up @@ -813,3 +872,11 @@ pub(crate) fn report_invalid_exception_cause(context: &InferContext, node: &ast:
),
);
}

pub(crate) fn report_base_with_incompatible_slots(context: &InferContext, node: &ast::Expr) {
context.report_lint(
&INCOMPATIBLE_SLOTS,
node.into(),
format_args!("Class base has incompatible `__slots__`"),
);
}
66 changes: 35 additions & 31 deletions crates/red_knot_python_semantic/src/types/infer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ use super::diagnostic::{
report_possibly_unresolved_reference, report_slice_step_size_zero, report_unresolved_reference,
SUBCLASS_OF_FINAL_CLASS,
};
use super::slots::check_class_slots;
use super::string_annotation::{
parse_string_annotation, BYTE_STRING_TYPE_ANNOTATION, FSTRING_TYPE_ANNOTATION,
};
Expand Down Expand Up @@ -585,41 +586,44 @@ impl<'db> TypeInferenceBuilder<'db> {
}

// (3) Check that the class's MRO is resolvable
if let Err(mro_error) = class.try_mro(self.db()).as_ref() {
match mro_error.reason() {
MroErrorKind::DuplicateBases(duplicates) => {
let base_nodes = class_node.bases();
for (index, duplicate) in duplicates {
self.context.report_lint(
&DUPLICATE_BASE,
(&base_nodes[*index]).into(),
format_args!("Duplicate base class `{}`", duplicate.name(self.db())),
);
match class.try_mro(self.db()).as_ref() {
Err(mro_error) => {
match mro_error.reason() {
MroErrorKind::DuplicateBases(duplicates) => {
let base_nodes = class_node.bases();
for (index, duplicate) in duplicates {
self.context.report_lint(
&DUPLICATE_BASE,
(&base_nodes[*index]).into(),
format_args!("Duplicate base class `{}`", duplicate.name(self.db())),
);
}
}
}
MroErrorKind::InvalidBases(bases) => {
let base_nodes = class_node.bases();
for (index, base_ty) in bases {
self.context.report_lint(
&INVALID_BASE,
(&base_nodes[*index]).into(),
format_args!(
"Invalid class base with type `{}` (all bases must be a class, `Any`, `Unknown` or `Todo`)",
base_ty.display(self.db())
),
);
MroErrorKind::InvalidBases(bases) => {
let base_nodes = class_node.bases();
for (index, base_ty) in bases {
self.context.report_lint(
&INVALID_BASE,
(&base_nodes[*index]).into(),
format_args!(
"Invalid class base with type `{}` (all bases must be a class, `Any`, `Unknown` or `Todo`)",
base_ty.display(self.db())
),
);
}
}
MroErrorKind::UnresolvableMro { bases_list } => self.context.report_lint(
&INCONSISTENT_MRO,
class_node.into(),
format_args!(
"Cannot create a consistent method resolution order (MRO) for class `{}` with bases list `[{}]`",
class.name(self.db()),
bases_list.iter().map(|base| base.display(self.db())).join(", ")
),
)
}
MroErrorKind::UnresolvableMro { bases_list } => self.context.report_lint(
&INCONSISTENT_MRO,
class_node.into(),
format_args!(
"Cannot create a consistent method resolution order (MRO) for class `{}` with bases list `[{}]`",
class.name(self.db()),
bases_list.iter().map(|base| base.display(self.db())).join(", ")
),
)
}
Ok(_) => check_class_slots(&self.context, class, class_node)
}

// (4) Check that the class's metaclass can be determined without error.
Expand Down
Loading
Loading