Skip to content
Open
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
193 changes: 119 additions & 74 deletions crates/ty/docs/rules.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,49 @@ GenericWithOrder[int](1) < GenericWithOrder[int](1)
GenericWithOrder[int](1) < GenericWithOrder[str]("a") # error: [unsupported-operator]
```

Subclassing a dataclass with `order=True` is problematic because comparing instances of different
classes in the inheritance hierarchy will raise a `TypeError` at runtime. This violates the Liskov
Substitution Principle:
Comment on lines +353 to +354
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
classes in the inheritance hierarchy will raise a `TypeError` at runtime. This violates the Liskov
Substitution Principle:
classes in the inheritance hierarchy will raise a `TypeError` at runtime. The design of the stdlib
feature therefore violates the Liskov Substitution Principle:


```py
from dataclasses import dataclass

@dataclass(order=True)
class Parent:
value: int

class Child(Parent): # error: [subclass-of-dataclass-with-order]
pass

# The comparison methods generated by @dataclass(order=True) compare instances
# as tuples of their fields. At runtime, this raises TypeError when comparing
# instances of different classes in the hierarchy:
# Child(42) < Parent(42) # TypeError!
```

This also applies when the child class is also a dataclass:

```py
@dataclass(order=True)
class OrderedParent:
x: int

@dataclass
class OrderedChild(OrderedParent): # error: [subclass-of-dataclass-with-order]
y: str
```

If the parent dataclass does not have `order=True`, no warning is emitted:

```py
@dataclass
class UnorderedParent:
x: int

class UnorderedChild(UnorderedParent): # No warning
pass
```

If a class already defines one of the comparison methods, a `TypeError` is raised at runtime.
Ideally, we would emit a diagnostic in that case:

Expand Down
31 changes: 9 additions & 22 deletions crates/ty_python_semantic/resources/mdtest/liskov.md
Original file line number Diff line number Diff line change
Expand Up @@ -475,37 +475,24 @@ from typing import NamedTuple
class Foo:
x: int

class Bar(Foo):
class Bar(Foo): # error: [subclass-of-dataclass-with-order]
def __lt__(self, other: Bar) -> bool: ... # error: [invalid-method-override]

# TODO: specifying `order=True` on the subclass means that a `__lt__` method is
# generated that is incompatible with the generated `__lt__` method on the superclass.
# We could consider detecting this and emitting a diagnostic, though maybe it shouldn't
# be `invalid-method-override` since we'd emit it on the class definition rather than
# on any method definition. Note also that no other type checker complains about this
# as of 2025-11-21.
# Specifying `order=True` on the subclass means that a `__lt__` method is generated that
# is incompatible with the generated `__lt__` method on the superclass. We emit a diagnostic
# on the class definition because the design of `order=True` dataclasses themselves violates
# the Liskov Substitution Principle.
@dataclass(order=True)
class Bar2(Foo):
class Bar2(Foo): # error: [subclass-of-dataclass-with-order]
y: str
Comment on lines -481 to 487
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should suppress the subclass-of-dataclass-with-order diagnostic if all the comparison methods are overridden on the subclass? (That's the case here, since Bar2 is also decorated with order=True.) This case here does feel like it should be flagged using the error code we use for Liskov Subsitution Principle violations, because by specifying order=True the user has in a sense explicitly asked for an incompatible override.

And something like the following also seems safe, if the user has taken care to override all the unsound methods from the superclass:

from dataclasses import dataclass

@dataclass(order=True)
class A:
    int

class B(A):
    def __lt__(self, other: A) -> bool:
        return True

    def __le__(self, other: A) -> bool:
        return True

    __gt__ = __lt__
    __ge__ = __le__


# TODO: Although this class does not override any methods of `Foo`, the design of the
# `order=True` stdlib dataclasses feature itself arguably violates the Liskov Substitution
# Although this class does not override any methods of `Foo`, the design of the
# `order=True` stdlib dataclasses feature itself violates the Liskov Substitution
# Principle! Instances of `Bar3` cannot be substituted wherever an instance of `Foo` is
# expected, because the generated `__lt__` method on `Foo` raises an error unless the r.h.s.
# and `l.h.s.` have exactly the same `__class__` (it does not permit instances of `Foo` to
# be compared with instances of subclasses of `Foo`).
#
# Many users would probably like their type checkers to alert them to cases where instances
# of subclasses cannot be substituted for instances of superclasses, as this violates many
# assumptions a type checker will make and makes it likely that a type checker will fail to
# catch type errors elsewhere in the user's code. We could therefore consider treating all
# `order=True` dataclasses as implicitly `@final` in order to enforce soundness. However,
# this probably shouldn't be reported with the same error code as Liskov violations, since
# the error does not stem from any method signatures written by the user. The example is
# only included here for completeness.
#
# Note that no other type checker catches this error as of 2025-11-21.
class Bar3(Foo): ...
class Bar3(Foo): ... # error: [subclass-of-dataclass-with-order]

class Eggs:
def __lt__(self, other: Eggs) -> bool: ...
Expand Down
2 changes: 1 addition & 1 deletion crates/ty_python_semantic/resources/mdtest/override.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ from dataclasses import dataclass
class ParentDataclass:
x: int

class Child(ParentDataclass):
class Child(ParentDataclass): # error: [subclass-of-dataclass-with-order]
@override
def __lt__(self, other: ParentDataclass) -> bool: ... # fine

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
source: crates/ty_test/src/lib.rs
assertion_line: 523
expression: snapshot
---
---
Expand All @@ -19,66 +20,67 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/liskov.md
5 | class Foo:
6 | x: int
7 |
8 | class Bar(Foo):
8 | class Bar(Foo): # error: [subclass-of-dataclass-with-order]
9 | def __lt__(self, other: Bar) -> bool: ... # error: [invalid-method-override]
10 |
11 | # TODO: specifying `order=True` on the subclass means that a `__lt__` method is
12 | # generated that is incompatible with the generated `__lt__` method on the superclass.
13 | # We could consider detecting this and emitting a diagnostic, though maybe it shouldn't
14 | # be `invalid-method-override` since we'd emit it on the class definition rather than
15 | # on any method definition. Note also that no other type checker complains about this
16 | # as of 2025-11-21.
17 | @dataclass(order=True)
18 | class Bar2(Foo):
19 | y: str
20 |
21 | # TODO: Although this class does not override any methods of `Foo`, the design of the
22 | # `order=True` stdlib dataclasses feature itself arguably violates the Liskov Substitution
23 | # Principle! Instances of `Bar3` cannot be substituted wherever an instance of `Foo` is
24 | # expected, because the generated `__lt__` method on `Foo` raises an error unless the r.h.s.
25 | # and `l.h.s.` have exactly the same `__class__` (it does not permit instances of `Foo` to
26 | # be compared with instances of subclasses of `Foo`).
27 | #
28 | # Many users would probably like their type checkers to alert them to cases where instances
29 | # of subclasses cannot be substituted for instances of superclasses, as this violates many
30 | # assumptions a type checker will make and makes it likely that a type checker will fail to
31 | # catch type errors elsewhere in the user's code. We could therefore consider treating all
32 | # `order=True` dataclasses as implicitly `@final` in order to enforce soundness. However,
33 | # this probably shouldn't be reported with the same error code as Liskov violations, since
34 | # the error does not stem from any method signatures written by the user. The example is
35 | # only included here for completeness.
36 | #
37 | # Note that no other type checker catches this error as of 2025-11-21.
38 | class Bar3(Foo): ...
11 | # Specifying `order=True` on the subclass means that a `__lt__` method is generated that
12 | # is incompatible with the generated `__lt__` method on the superclass. We emit a diagnostic
13 | # on the class definition because the design of `order=True` dataclasses themselves violates
14 | # the Liskov Substitution Principle.
15 | @dataclass(order=True)
16 | class Bar2(Foo): # error: [subclass-of-dataclass-with-order]
17 | y: str
18 |
19 | # Although this class does not override any methods of `Foo`, the design of the
20 | # `order=True` stdlib dataclasses feature itself violates the Liskov Substitution
21 | # Principle! Instances of `Bar3` cannot be substituted wherever an instance of `Foo` is
22 | # expected, because the generated `__lt__` method on `Foo` raises an error unless the r.h.s.
23 | # and `l.h.s.` have exactly the same `__class__` (it does not permit instances of `Foo` to
24 | # be compared with instances of subclasses of `Foo`).
25 | class Bar3(Foo): ... # error: [subclass-of-dataclass-with-order]
26 |
27 | class Eggs:
28 | def __lt__(self, other: Eggs) -> bool: ...
29 |
30 | # TODO: the generated `Ham.__lt__` method here incompatibly overrides `Eggs.__lt__`.
31 | # We could consider emitting a diagnostic here. As of 2025-11-21, mypy reports a
32 | # diagnostic here but pyright and pyrefly do not.
33 | @dataclass(order=True)
34 | class Ham(Eggs):
35 | x: int
36 |
37 | class Baz(NamedTuple):
38 | x: int
39 |
40 | class Eggs:
41 | def __lt__(self, other: Eggs) -> bool: ...
42 |
43 | # TODO: the generated `Ham.__lt__` method here incompatibly overrides `Eggs.__lt__`.
44 | # We could consider emitting a diagnostic here. As of 2025-11-21, mypy reports a
45 | # diagnostic here but pyright and pyrefly do not.
46 | @dataclass(order=True)
47 | class Ham(Eggs):
48 | x: int
49 |
50 | class Baz(NamedTuple):
51 | x: int
52 |
53 | class Spam(Baz):
54 | def _asdict(self) -> tuple[int, ...]: ... # error: [invalid-method-override]
40 | class Spam(Baz):
41 | def _asdict(self) -> tuple[int, ...]: ... # error: [invalid-method-override]
```

# Diagnostics

```
warning[subclass-of-dataclass-with-order]: Class `Bar` inherits from dataclass `Foo` which has `order=True`
--> src/mdtest_snippet.pyi:8:11
|
6 | x: int
7 |
8 | class Bar(Foo): # error: [subclass-of-dataclass-with-order]
| ^^^
9 | def __lt__(self, other: Bar) -> bool: ... # error: [invalid-method-override]
|
info: rule `subclass-of-dataclass-with-order` is enabled by default

```

```
error[invalid-method-override]: Invalid override of method `__lt__`
--> src/mdtest_snippet.pyi:9:9
|
8 | class Bar(Foo):
8 | class Bar(Foo): # error: [subclass-of-dataclass-with-order]
9 | def __lt__(self, other: Bar) -> bool: ... # error: [invalid-method-override]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Definition is incompatible with `Foo.__lt__`
10 |
11 | # TODO: specifying `order=True` on the subclass means that a `__lt__` method is
11 | # Specifying `order=True` on the subclass means that a `__lt__` method is generated that
|
info: This violates the Liskov Substitution Principle
info: `Foo.__lt__` is a generated method created because `Foo` is a dataclass
Expand All @@ -93,23 +95,52 @@ info: rule `invalid-method-override` is enabled by default

```

```
warning[subclass-of-dataclass-with-order]: Class `Bar2` inherits from dataclass `Foo` which has `order=True`
--> src/mdtest_snippet.pyi:16:12
|
14 | # the Liskov Substitution Principle.
15 | @dataclass(order=True)
16 | class Bar2(Foo): # error: [subclass-of-dataclass-with-order]
| ^^^
17 | y: str
|
info: rule `subclass-of-dataclass-with-order` is enabled by default

```

```
warning[subclass-of-dataclass-with-order]: Class `Bar3` inherits from dataclass `Foo` which has `order=True`
--> src/mdtest_snippet.pyi:25:12
|
23 | # and `l.h.s.` have exactly the same `__class__` (it does not permit instances of `Foo` to
24 | # be compared with instances of subclasses of `Foo`).
25 | class Bar3(Foo): ... # error: [subclass-of-dataclass-with-order]
| ^^^
26 |
27 | class Eggs:
|
info: rule `subclass-of-dataclass-with-order` is enabled by default

```

```
error[invalid-method-override]: Invalid override of method `_asdict`
--> src/mdtest_snippet.pyi:54:9
--> src/mdtest_snippet.pyi:41:9
|
53 | class Spam(Baz):
54 | def _asdict(self) -> tuple[int, ...]: ... # error: [invalid-method-override]
40 | class Spam(Baz):
41 | def _asdict(self) -> tuple[int, ...]: ... # error: [invalid-method-override]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Definition is incompatible with `Baz._asdict`
|
info: This violates the Liskov Substitution Principle
info: `Baz._asdict` is a generated method created because `Baz` inherits from `typing.NamedTuple`
--> src/mdtest_snippet.pyi:50:7
--> src/mdtest_snippet.pyi:37:7
|
48 | x: int
49 |
50 | class Baz(NamedTuple):
35 | x: int
36 |
37 | class Baz(NamedTuple):
| ^^^^^^^^^^^^^^^ Definition of `Baz`
51 | x: int
38 | x: int
|
info: rule `invalid-method-override` is enabled by default

Expand Down
40 changes: 40 additions & 0 deletions crates/ty_python_semantic/src/types/diagnostic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ pub(crate) fn register_lints(registry: &mut LintRegistryBuilder) {
registry.register_lint(&INVALID_METHOD_OVERRIDE);
registry.register_lint(&INVALID_EXPLICIT_OVERRIDE);
registry.register_lint(&SUPER_CALL_IN_NAMED_TUPLE_METHOD);
registry.register_lint(&SUBCLASS_OF_DATACLASS_WITH_ORDER);

// String annotations
registry.register_lint(&BYTE_STRING_TYPE_ANNOTATION);
Expand Down Expand Up @@ -1643,6 +1644,45 @@ declare_lint! {
}
}

declare_lint! {
/// ## What it does
/// Checks for classes that inherit from a dataclass with `order=True`.
///
/// ## Why is this bad?
/// When a dataclass has `order=True`, comparison methods (`__lt__`, `__le__`, `__gt__`, `__ge__`)
/// are generated that compare instances as tuples of their fields. These methods raise a
/// `TypeError` at runtime when comparing instances of different classes in the inheritance
/// hierarchy, even if one is a subclass of the other.
///
/// This violates the [Liskov Substitution Principle] because child class instances cannot be
/// used in all contexts where parent class instances are expected.
///
/// ## Example
///
/// ```python
/// from dataclasses import dataclass
///
/// @dataclass(order=True)
/// class Parent:
/// value: int
///
/// class Child(Parent): # Error raised here
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// class Child(Parent): # Error raised here
/// class Child(Parent): # Ty emits an error here

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...ty

/// pass
///
/// # At runtime, this raises TypeError:
/// # Child(1) < Parent(2)
/// ```
///
/// Consider using `functools.total_ordering` instead, which does not have this limitation.
///
/// [Liskov Substitution Principle]: https://en.wikipedia.org/wiki/Liskov_substitution_principle
Comment on lines +1676 to +1678
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// Consider using `functools.total_ordering` instead, which does not have this limitation.
///
/// [Liskov Substitution Principle]: https://en.wikipedia.org/wiki/Liskov_substitution_principle
/// Consider using [`functools.total_ordering`][total_ordering] instead, which does not have this limitation.
///
/// [Liskov Substitution Principle]: https://en.wikipedia.org/wiki/Liskov_substitution_principle
/// [total_ordering]: https://docs.python.org/3/library/functools.html#functools.total_ordering

pub(crate) static SUBCLASS_OF_DATACLASS_WITH_ORDER = {
summary: "detects subclasses of dataclasses with `order=True`",
status: LintStatus::preview("0.0.1-alpha.30"),
default_level: Level::Warn,
}
}

declare_lint! {
/// ## What it does
/// Checks for methods that are decorated with `@override` but do not override any method in a superclass.
Expand Down
37 changes: 27 additions & 10 deletions crates/ty_python_semantic/src/types/infer/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,9 @@ use crate::types::diagnostic::{
INVALID_PARAMETER_DEFAULT, INVALID_PARAMSPEC, INVALID_PROTOCOL, INVALID_TYPE_ARGUMENTS,
INVALID_TYPE_FORM, INVALID_TYPE_GUARD_CALL, INVALID_TYPE_VARIABLE_CONSTRAINTS,
IncompatibleBases, NON_SUBSCRIPTABLE, POSSIBLY_MISSING_ATTRIBUTE,
POSSIBLY_MISSING_IMPLICIT_CALL, POSSIBLY_MISSING_IMPORT, SUBCLASS_OF_FINAL_CLASS,
UNDEFINED_REVEAL, UNRESOLVED_ATTRIBUTE, UNRESOLVED_GLOBAL, UNRESOLVED_IMPORT,
UNRESOLVED_REFERENCE, UNSUPPORTED_OPERATOR, USELESS_OVERLOAD_BODY,
POSSIBLY_MISSING_IMPLICIT_CALL, POSSIBLY_MISSING_IMPORT, SUBCLASS_OF_DATACLASS_WITH_ORDER,
SUBCLASS_OF_FINAL_CLASS, UNDEFINED_REVEAL, UNRESOLVED_ATTRIBUTE, UNRESOLVED_GLOBAL,
UNRESOLVED_IMPORT, UNRESOLVED_REFERENCE, UNSUPPORTED_OPERATOR, USELESS_OVERLOAD_BODY,
hint_if_stdlib_attribute_exists_on_other_versions,
hint_if_stdlib_submodule_exists_on_other_versions, report_attempted_protocol_instantiation,
report_bad_dunder_set_call, report_cannot_pop_required_field_on_typed_dict,
Expand Down Expand Up @@ -103,13 +103,14 @@ use crate::types::typed_dict::{
use crate::types::visitor::any_over_type;
use crate::types::{
CallDunderError, CallableBinding, CallableType, CallableTypes, ClassLiteral, ClassType,
DataclassParams, DynamicType, InternedType, IntersectionBuilder, IntersectionType, KnownClass,
KnownInstanceType, LintDiagnosticGuard, MemberLookupPolicy, MetaclassCandidate,
PEP695TypeAliasType, ParameterForm, SpecialFormType, SubclassOfType, TrackedConstraintSet,
Truthiness, Type, TypeAliasType, TypeAndQualifiers, TypeContext, TypeQualifiers,
TypeVarBoundOrConstraints, TypeVarBoundOrConstraintsEvaluation, TypeVarDefaultEvaluation,
TypeVarIdentity, TypeVarInstance, TypeVarKind, TypeVarVariance, TypedDictType, UnionBuilder,
UnionType, UnionTypeInstance, binding_type, infer_scope_types, overrides, todo_type,
DataclassFlags, DataclassParams, DynamicType, InternedType, IntersectionBuilder,
IntersectionType, KnownClass, KnownInstanceType, LintDiagnosticGuard, MemberLookupPolicy,
MetaclassCandidate, PEP695TypeAliasType, ParameterForm, SpecialFormType, SubclassOfType,
TrackedConstraintSet, Truthiness, Type, TypeAliasType, TypeAndQualifiers, TypeContext,
TypeQualifiers, TypeVarBoundOrConstraints, TypeVarBoundOrConstraintsEvaluation,
TypeVarDefaultEvaluation, TypeVarIdentity, TypeVarInstance, TypeVarKind, TypeVarVariance,
TypedDictType, UnionBuilder, UnionType, UnionTypeInstance, binding_type, infer_scope_types,
overrides, todo_type,
};
use crate::types::{ClassBase, add_inferred_python_version_hint_to_diagnostic};
use crate::unpack::{EvaluationMode, UnpackPosition};
Expand Down Expand Up @@ -743,6 +744,22 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
));
}
}

let (base_class_literal, _) = base_class.class_literal(self.db());
if let Some(base_params) = base_class_literal.dataclass_params(self.db()) {
if base_params.flags(self.db()).contains(DataclassFlags::ORDER) {
if let Some(builder) = self
.context
.report_lint(&SUBCLASS_OF_DATACLASS_WITH_ORDER, &class_node.bases()[i])
{
builder.into_diagnostic(format_args!(
"Class `{}` inherits from dataclass `{}` which has `order=True`",
class.name(self.db()),
base_class.name(self.db()),
));
}
}
}
Comment on lines +749 to +762
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we add an info subdiagnostic here explaining why this is problematic?

}

// (4) Check that the class's MRO is resolvable
Expand Down
Loading
Loading