[ty] Validate positional class patterns against __match_args__ (#26195)

## Summary

Positional class patterns use `__match_args__` to map each subpattern to
an attribute. We previously only diagnosed positional subpatterns for
`collections.abc.Callable`; ordinary classes could supply too many
positional subpatterns, omit `__match_args__`, or expose a definitely
non-tuple value without an `invalid-match-pattern` diagnostic.

This PR derives positional limits from the semantic `__match_args__`
member type. Exact fixed-length tuples provide their length, absent
source-class members accept zero positional subpatterns, and Python's
match-self builtins accept one. Definitely non-tuple member types
produce a dedicated diagnostic instead of being interpreted as an arity.
Fixed tuple information is preserved through variables, typed function
calls, explicit annotations, PEP 695 aliases, inheritance, descriptors,
dataclasses, and named tuples.

```python
class Point:
    __match_args__ = ("x", "y")

class Missing: ...

class Invalid:
    __match_args__ = ["x"]

def describe(point: Point, missing: Missing, invalid: Invalid) -> None:
    match point:
        case Point(_, _, _):  # error: expected 2, got 3
            pass

    match missing:
        case Missing(_):  # error: expected 0, got 1
            pass

    match invalid:
        case Invalid(_):  # error: __match_args__ must be an exact tuple
            pass
```

Closes https://github.com/astral-sh/ty/issues/3738.

---------

Co-authored-by: Charlie Marsh <charlie.r.marsh@gmail.com>
This commit is contained in:
Felix ScherzandCharlie Marsh authored and GitHub committed 2026-06-26 16:09:38 -04:00
1 parent a582555cd7
commit 1d2041ca19
9 files changed
+512 -33

No files matched your search

+21 -3
View File
@@ -1997,17 +1997,35 @@ Checks for invalid match patterns.
**Why is this bad?**
Matching on invalid patterns will lead to a runtime error.
Invalid match patterns can cause a `TypeError` at runtime. This includes:
- Using a non-type object in a class pattern.
- Providing positional subpatterns when `__match_args__` is missing or has an invalid static type.
- Matching against `collections.abc.Callable` with positional subpatterns.
- Matching against a non-runtime-checkable protocol.
- Matching against a `TypedDict`.
**Examples**
```python
class Point:
__match_args__ = ("x", "y")
def describe(p: Point) -> None:
match p:
# TypeError at runtime: Point() accepts 2 positional sub-patterns (3 given)
case Point(x, y, z): # error: [invalid-match-pattern]
...
```
```python
NotAClass = 42
match object():
# TypeError at runtime: must be a class
case NotAClass(): # error
# TypeError at runtime: called match pattern must be a class
case NotAClass(): # error: [invalid-match-pattern]
...
```
@@ -4,15 +4,33 @@ Checks for invalid match patterns.
## Why is this bad?
Matching on invalid patterns will lead to a runtime error.
Invalid match patterns can cause a `TypeError` at runtime. This includes:
- Using a non-type object in a class pattern.
- Providing positional subpatterns when `__match_args__` is missing or has an invalid static type.
- Matching against `collections.abc.Callable` with positional subpatterns.
- Matching against a non-runtime-checkable protocol.
- Matching against a `TypedDict`.
## Examples
```python
class Point:
__match_args__ = ("x", "y")
def describe(p: Point) -> None:
match p:
# TypeError at runtime: Point() accepts 2 positional sub-patterns (3 given)
case Point(x, y, z): # error: [invalid-match-pattern]
...
```
```python
NotAClass = 42
match object():
# TypeError at runtime: must be a class
case NotAClass(): # error
# TypeError at runtime: called match pattern must be a class
case NotAClass(): # error: [invalid-match-pattern]
...
```
@@ -0,0 +1,337 @@
# Invalid match pattern
## Too many positional subpatterns
The number of positional subpatterns must not exceed the length of a statically known fixed-length
`__match_args__` tuple. The tuple type can come from an inferred value, an annotation, or another
expression.
```toml
[environment]
python-version = "3.12"
```
```py
from typing import Literal
class Point:
__match_args__ = ("x", "y")
one_arg = ("value",)
class FromVariable:
__match_args__ = one_arg
def make_args() -> tuple[Literal["value"]]:
return ("value",)
class FromCall:
__match_args__ = make_args()
class Annotated:
__match_args__: tuple[Literal["value"]] = make_args()
type MatchArgs = tuple[Literal["value"]]
class Aliased:
__match_args__: MatchArgs = ("value",)
def describe(
point: Point,
from_variable: FromVariable,
from_call: FromCall,
annotated: Annotated,
aliased: Aliased,
) -> None:
match point:
case Point(_, _):
pass
match point:
# error: [invalid-match-pattern] "Too many positional subpatterns for `<class 'Point'>`: expected 2, got 3"
case Point(_, _, _):
pass
match from_variable:
case FromVariable(_, _): # error: [invalid-match-pattern] "expected 1, got 2"
pass
match from_call:
case FromCall(_, _): # error: [invalid-match-pattern] "expected 1, got 2"
pass
match annotated:
case Annotated(_, _): # error: [invalid-match-pattern] "expected 1, got 2"
pass
match aliased:
case Aliased(_, _): # error: [invalid-match-pattern] "expected 1, got 2"
pass
```
An absent `__match_args__` accepts no positional subpatterns for source classes. Builtins with
match-self behavior accept one; other known builtins accept none.
```py
class Missing: ...
def describe(missing: Missing, integer: int, complex_number: complex) -> None:
match missing:
case Missing(_): # error: [invalid-match-pattern] "expected 0, got 1"
pass
match integer:
case int(_):
pass
match integer:
case int(_, _): # error: [invalid-match-pattern] "expected 1, got 2"
pass
match complex_number:
case complex(_): # error: [invalid-match-pattern] "expected 0, got 1"
pass
```
## Generated match arguments
Dataclasses and named tuples synthesize `__match_args__`. Keyword-only dataclass fields are omitted.
```py
from dataclasses import dataclass, field
from typing import NamedTuple
@dataclass
class Point:
x: int
y: int
@dataclass(match_args=False)
class NoMatch:
x: int
y: int
@dataclass(kw_only=True)
class KeywordOnly:
x: int
@dataclass
class PartlyKeywordOnly:
x: int
y: int = field(kw_only=True)
class NamedPoint(NamedTuple):
x: int
y: int
def describe(
point: Point,
no_match: NoMatch,
keyword_only: KeywordOnly,
partly_keyword_only: PartlyKeywordOnly,
named_point: NamedPoint,
) -> None:
match point:
case Point(_, _, _): # error: [invalid-match-pattern] "expected 2, got 3"
pass
match no_match:
case NoMatch(_, _): # error: [invalid-match-pattern] "expected 0, got 2"
pass
match keyword_only:
case KeywordOnly(_): # error: [invalid-match-pattern] "expected 0, got 1"
pass
match partly_keyword_only:
case PartlyKeywordOnly(_, _): # error: [invalid-match-pattern] "expected 1, got 2"
pass
match named_point:
case NamedPoint(_, _, _): # error: [invalid-match-pattern] "expected 2, got 3"
pass
```
## Invalid `__match_args__` type
A definitely non-tuple `__match_args__` type cannot support positional subpatterns.
```py
from typing_extensions import LiteralString
bad_args = ["value"]
class FromVariable:
__match_args__ = bad_args
def make_args() -> str:
return "value"
class FromCall:
__match_args__ = make_args()
class Annotated:
__match_args__: int = 1
class Position:
__match_args__: LiteralString = "field"
def describe(
from_variable: FromVariable,
from_call: FromCall,
annotated: Annotated,
position: Position,
) -> None:
match from_variable:
# error: [invalid-match-pattern] "must be an exact tuple, not `list[str]`"
case FromVariable(_):
pass
match from_call:
# error: [invalid-match-pattern] "must be an exact tuple, not `str`"
case FromCall(_):
pass
match annotated:
# error: [invalid-match-pattern] "must be an exact tuple, not `int`"
case Annotated(_):
pass
match position:
# error: [invalid-match-pattern] "must be an exact tuple, not `LiteralString`"
case Position(_):
pass
```
## Semantic member lookup
The check uses the resolved member type, including inherited and descriptor-provided values.
```py
from typing import Literal, final
class Base:
__match_args__ = ("value",)
class Derived(Base): ...
@final
class MatchArgsDescriptor:
def __get__(self, instance: object | None, owner: type[object]) -> tuple[Literal["value"]]:
return ("value",)
class Descriptor:
__match_args__ = MatchArgsDescriptor()
def describe(derived: Derived, descriptor: Descriptor) -> None:
match derived:
case Derived(_, _): # error: [invalid-match-pattern] "expected 1, got 2"
pass
match descriptor:
case Descriptor(_):
pass
match descriptor:
case Descriptor(_, _): # error: [invalid-match-pattern] "expected 1, got 2"
pass
```
## Unknown limits
No diagnostic is emitted when the member type does not establish a fixed tuple length or a definite
non-tuple value.
```py
from typing import Literal
class Variadic:
__match_args__: tuple[str, ...] = ()
class Mixed:
__match_args__: tuple[Literal["value"]] | list[str] = ("value",)
class TupleSubclass(tuple[str]): ...
class SubclassValue:
__match_args__: TupleSubclass
def describe(
variadic: Variadic,
mixed: Mixed,
subclass_value: SubclassValue,
) -> None:
match variadic:
case Variadic(_, _):
pass
match mixed:
case Mixed(_, _):
pass
match subclass_value:
case SubclassValue(_):
pass
```
## Missing stub member
An omitted stub member does not establish a runtime limit.
```py
from lib import Model
def describe(model: Model) -> None:
match model:
case Model(_):
pass
```
`lib.pyi`:
```pyi
class Model: ...
```
## Patterns without positional subpatterns
Patterns without positional subpatterns do not inspect `__match_args__`.
```py
class Model:
__match_args__ = ["value"]
value: int = 0
def describe(value: Model) -> None:
match value:
case Model():
pass
match value:
case Model(value=_):
pass
```
## Invalid pattern classes do not cascade
The invalid-class diagnostic takes precedence over positional validation.
```py
from typing import Protocol, TypedDict
class Payload(TypedDict):
value: int
class HasValue(Protocol):
value: int
def describe(value: object) -> None:
match value:
# error: [isinstance-against-typed-dict]
case Payload(_):
pass
match value:
# error: [isinstance-against-protocol]
case HasValue(_):
pass
```
@@ -201,7 +201,7 @@ match 56:
case x if something_unresolvable: # error: [unresolved-reference]
...
case object(S):
case object(S): # error: [invalid-match-pattern] "expected 0, got 1"
pass
match 12345:
@@ -298,7 +298,7 @@ match 42:
pass
case object(foo=G):
pass
case object(H):
case object(H): # error: [invalid-match-pattern] "expected 0, got 1"
pass
case I:
pass
@@ -368,7 +368,7 @@ match 42:
pass
case object(foo=G):
pass
case object(H):
case object(H): # error: [invalid-match-pattern] "expected 0, got 1"
pass
case I:
pass
@@ -1897,7 +1897,7 @@ def builtin_positional_behavior_comes_from_pattern_class(
# error: [invalid-return-type]
) -> int:
match value:
case PlainBase(_):
case PlainBase(_): # error: [invalid-match-pattern]
return 1
```
@@ -2490,18 +2490,36 @@ pub(crate) fn report_invalid_class_match_pattern<T: Ranged>(
diagnostic.set_primary_message("This will raise `TypeError` at runtime");
}
pub(crate) fn report_too_many_positional_patterns_for_callable_class_pattern<T: Ranged>(
pub(crate) fn report_too_many_positional_patterns_for_class_pattern<T: Ranged>(
context: &InferContext,
first_excess_pattern: T,
positional_limit: usize,
positional_count: usize,
class_display: impl std::fmt::Display,
) {
let Some(builder) = context.report_lint(&INVALID_MATCH_PATTERN, first_excess_pattern) else {
return;
};
let mut diagnostic = builder.into_diagnostic(format_args!(
"Too many positional subpatterns for `collections.abc.Callable`: expected 0, got {positional_count}"
builder.into_diagnostic(format_args!(
"Too many positional subpatterns for `{class_display}`: expected {positional_limit}, got {positional_count}"
));
}
pub(crate) fn report_invalid_match_args_type<T: Ranged>(
context: &InferContext,
pattern: T,
match_args_ty: Type,
cls_ty: Type,
) {
let Some(builder) = context.report_lint(&INVALID_MATCH_PATTERN, pattern) else {
return;
};
let db = context.db();
let class_display = cls_ty.display(db);
let match_args_display = match_args_ty.display(db);
builder.into_diagnostic(format_args!(
"`__match_args__` for `{class_display}` must be an exact tuple, not `{match_args_display}`"
));
diagnostic.set_primary_message("This will raise `TypeError` at runtime");
}
pub(crate) fn add_type_expression_reference_link<'db, 'ctx>(
@@ -61,12 +61,12 @@ use crate::types::diagnostic::{
report_invalid_exception_caught, report_invalid_exception_cause,
report_invalid_exception_raised, report_invalid_exception_tuple_caught,
report_invalid_generator_yield_type, report_invalid_key_on_typed_dict,
report_invalid_type_checking_constant,
report_invalid_match_args_type, report_invalid_type_checking_constant,
report_match_pattern_against_non_runtime_checkable_protocol,
report_match_pattern_against_typed_dict, report_mismatched_type_name,
report_possibly_missing_attribute, report_possibly_unresolved_reference,
report_too_many_positional_patterns_for_callable_class_pattern,
report_unsupported_augmented_assignment, report_unsupported_comparison,
report_too_many_positional_patterns_for_class_pattern, report_unsupported_augmented_assignment,
report_unsupported_comparison,
};
use crate::types::enums::{enum_ignored_names, is_enum_class_by_inheritance};
use crate::types::function::{
@@ -85,6 +85,7 @@ use crate::types::infer::{
TypeExpressionFlags, infer_statement_types, nearest_enclosing_class,
nearest_enclosing_function, original_class_type,
};
use crate::types::match_pattern::{ClassPatternPositionalResult, class_pattern_positional_result};
use crate::types::narrow::NarrowingEvaluatorExtension;
use crate::types::narrow::pattern_success_types;
use crate::types::newtype::NewType;
@@ -2444,10 +2445,12 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
fn validate_class_pattern(&mut self, pattern: &ast::PatternMatchClass, cls_ty: Type<'db>) {
if let Type::SpecialForm(SpecialFormType::CollectionsAbcCallable) = cls_ty {
if let Some(first_excess_pattern) = pattern.arguments.patterns.first() {
report_too_many_positional_patterns_for_callable_class_pattern(
report_too_many_positional_patterns_for_class_pattern(
&self.context,
first_excess_pattern,
0,
pattern.arguments.patterns.len(),
"collections.abc.Callable",
);
}
return;
@@ -2456,7 +2459,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
if let Type::ClassLiteral(class) = cls_ty {
if class.is_typed_dict(self.db()) {
report_match_pattern_against_typed_dict(&self.context, &*pattern.cls, class);
} else if let Some(protocol_class) = class.into_protocol_class(self.db())
return;
}
if let Some(protocol_class) = class.into_protocol_class(self.db())
&& !protocol_class.is_runtime_checkable(self.db())
{
report_match_pattern_against_non_runtime_checkable_protocol(
@@ -2464,6 +2469,34 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
&*pattern.cls,
protocol_class,
);
return;
}
let positional_patterns = &pattern.arguments.patterns;
if let [first_positional_pattern, ..] = positional_patterns.as_slice()
&& let Some(result) = class_pattern_positional_result(self.db(), class)
{
match result {
ClassPatternPositionalResult::Limit(limit) => {
if let Some(first_excess_pattern) = positional_patterns.get(limit) {
report_too_many_positional_patterns_for_class_pattern(
&self.context,
first_excess_pattern,
limit,
positional_patterns.len(),
cls_ty.display(self.db()),
);
}
}
ClassPatternPositionalResult::InvalidType(match_args_ty) => {
report_invalid_match_args_type(
&self.context,
first_positional_pattern,
match_args_ty,
cls_ty,
);
}
}
}
} else if !cls_ty.is_assignable_to(self.db(), KnownClass::Type.to_instance(self.db())) {
report_invalid_class_match_pattern(&self.context, &*pattern.cls, cls_ty);
@@ -277,8 +277,13 @@ fn class_pattern_is_exhaustive(
enum ClassMatchArgs<'db> {
/// The class and its metaclass hierarchy do not define `__match_args__`.
Undefined,
/// `__match_args__` is definitely bound with this type.
Defined(Type<'db>),
/// `__match_args__` is definitely bound.
Defined {
/// The semantic member type used to validate positional subpatterns.
member_type: Type<'db>,
/// The type used to resolve positional attribute names.
positional_source_type: Type<'db>,
},
/// `__match_args__` is defined along some control-flow paths but not others.
PossiblyUndefined,
}
@@ -296,9 +301,10 @@ pub(crate) enum ClassPatternPositionalSource {
/// Resolve `__match_args__` through the pattern class, including its metaclass.
///
/// Inferred assignments retain their literal binding type, while an explicit annotation remains
/// authoritative. `PossiblyUndefined` is distinct from `Undefined` because only a truly absent
/// `__match_args__` enables match-self behavior.
/// The semantic member type is used for validation. When mapping positional subpatterns to
/// attributes, inferred assignments retain their literal binding type while an explicit annotation
/// remains authoritative. `PossiblyUndefined` is distinct from `Undefined` because only a truly
/// absent `__match_args__` enables match-self behavior.
fn class_match_args_type<'db>(db: &'db dyn Db, class: ClassLiteral<'db>) -> ClassMatchArgs<'db> {
match Type::ClassLiteral(class).member(db, "__match_args__").place {
Place::Defined(
@@ -308,13 +314,16 @@ fn class_match_args_type<'db>(db: &'db dyn Db, class: ClassLiteral<'db>) -> Clas
provenance,
..
},
) if place.is_definitely_defined() => ClassMatchArgs::Defined(if origin.is_declared() {
ty
} else {
provenance
.definition()
.map_or(ty, |definition| binding_type(db, definition))
}),
) if place.is_definitely_defined() => ClassMatchArgs::Defined {
member_type: ty,
positional_source_type: if origin.is_declared() {
ty
} else {
provenance
.definition()
.map_or(ty, |definition| binding_type(db, definition))
},
},
Place::Defined(_) => ClassMatchArgs::PossiblyUndefined,
Place::Undefined => ClassMatchArgs::Undefined,
}
@@ -349,6 +358,49 @@ fn class_has_match_self_flag(db: &dyn Db, class: ClassLiteral<'_>) -> bool {
})
}
/// The statically known result of validating positional subpatterns for a class pattern.
pub(crate) enum ClassPatternPositionalResult<'db> {
/// The maximum number of positional subpatterns accepted by the class.
Limit(usize),
/// A statically known non-tuple value used for `__match_args__`.
InvalidType(Type<'db>),
}
/// Validate positional subpatterns against a statically known `__match_args__` type.
pub(crate) fn class_pattern_positional_result<'db>(
db: &'db dyn Db,
class: ClassLiteral<'db>,
) -> Option<ClassPatternPositionalResult<'db>> {
match class_match_args_type(db, class) {
ClassMatchArgs::Undefined if class_has_match_self_flag(db, class) => {
Some(ClassPatternPositionalResult::Limit(1))
}
ClassMatchArgs::Undefined
if class.known(db).is_some()
|| class
.as_static()
.is_some_and(|class| !class.body_scope(db).file(db).is_stub(db)) =>
{
Some(ClassPatternPositionalResult::Limit(0))
}
ClassMatchArgs::Defined { member_type, .. } => {
let match_args = member_type.resolve_type_alias(db);
if let Some(limit) = match_args.exact_tuple_instance_spec(db).and_then(|tuple| {
tuple
.as_fixed_length()
.map(super::tuple::FixedLengthTuple::len)
}) {
Some(ClassPatternPositionalResult::Limit(limit))
} else {
match_args
.is_disjoint_from(db, Type::homogeneous_tuple(db, Type::unknown()))
.then_some(ClassPatternPositionalResult::InvalidType(match_args))
}
}
ClassMatchArgs::Undefined | ClassMatchArgs::PossiblyUndefined => None,
}
}
/// Resolve the value supplied to each positional subpattern, preserving source order and length.
///
/// A fixed tuple of string literals in `__match_args__` maps positions to subject attributes.
@@ -386,7 +438,10 @@ pub(crate) fn class_pattern_positional_sources(
})
.collect();
}
ClassMatchArgs::Defined(match_args) => match_args
ClassMatchArgs::Defined {
positional_source_type,
..
} => positional_source_type
.exact_tuple_instance_spec(db)
.and_then(|tuple| tuple.as_fixed_length().cloned()),
ClassMatchArgs::Undefined | ClassMatchArgs::PossiblyUndefined => None,
+1 -1
View File
@@ -792,7 +792,7 @@
},
"invalid-match-pattern": {
"title": "detect invalid match patterns",
"description": "## What it does\n\nChecks for invalid match patterns.\n\n## Why is this bad?\n\nMatching on invalid patterns will lead to a runtime error.\n\n## Examples\n\n```python\nNotAClass = 42\n\nmatch object():\n # TypeError at runtime: must be a class\n case NotAClass(): # error\n ...\n```",
"description": "## What it does\n\nChecks for invalid match patterns.\n\n## Why is this bad?\n\nInvalid match patterns can cause a `TypeError` at runtime. This includes:\n\n- Using a non-type object in a class pattern.\n- Providing positional subpatterns when `__match_args__` is missing or has an invalid static type.\n- Matching against `collections.abc.Callable` with positional subpatterns.\n- Matching against a non-runtime-checkable protocol.\n- Matching against a `TypedDict`.\n\n## Examples\n\n```python\nclass Point:\n __match_args__ = (\"x\", \"y\")\n\n\ndef describe(p: Point) -> None:\n match p:\n # TypeError at runtime: Point() accepts 2 positional sub-patterns (3 given)\n case Point(x, y, z): # error: [invalid-match-pattern]\n ...\n```\n\n```python\nNotAClass = 42\n\nmatch object():\n # TypeError at runtime: called match pattern must be a class\n case NotAClass(): # error: [invalid-match-pattern]\n ...\n```",
"default": "error",
"oneOf": [
{