mirror of
https://github.com/astral-sh/ruff.git
synced 2026-10-28 19:00:26 +08:00
[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:
1 parent
a582555cd7
commit
1d2041ca19
9 files changed
+512
-33
No files matched your search
Generated
+21
-3
@@ -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,
|
||||
|
||||
Generated
+1
-1
@@ -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": [
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user