From ce80843ff9ef09eb04f4d0610c50836fde528583 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 00:25:20 +0000 Subject: [PATCH 1/5] [ty] Validate ParamSpec component usage (P.args/P.kwargs) Add several new `invalid-paramspec` diagnostics for invalid usage of ParamSpec components: - P.args/P.kwargs on regular (non-variadic) parameters - P.args/P.kwargs as variable or instance attribute annotations - *args: P.args without matching **kwargs: P.kwargs (and vice versa) - *args: P.args with **kwargs annotated with a non-matching type - Keyword-only parameters between *args: P.args and **kwargs: P.kwargs - Mismatched ParamSpecs between *args and **kwargs These checks address several `# E` assertions from the typing conformance test `generics_paramspec_components.py`. https://claude.ai/code/session_01BNCtgwYBcEEFDEfuzWPLpQ --- .../mdtest/generics/legacy/paramspec.md | 22 +- .../mdtest/generics/pep695/paramspec.md | 26 +- .../src/types/infer/builder.rs | 253 ++++++++++++++++++ 3 files changed, 285 insertions(+), 16 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/generics/legacy/paramspec.md b/crates/ty_python_semantic/resources/mdtest/generics/legacy/paramspec.md index 63465627c0bdf2..2b27846dce1f77 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/legacy/paramspec.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/legacy/paramspec.md @@ -201,15 +201,23 @@ def foo1(c: Callable[P, int]) -> None: **kwargs: P.args, ) -> None: ... - # TODO: error + # error: [invalid-paramspec] "`*args: P.args` must be accompanied by `**kwargs: P.kwargs`" def nested3(*args: P.args) -> None: ... - # TODO: error + # error: [invalid-paramspec] "`**kwargs: P.kwargs` must be accompanied by `*args: P.args`" def nested4(**kwargs: P.kwargs) -> None: ... - # TODO: error + # error: [invalid-paramspec] "When using `P.args` and `P.kwargs`, no other parameters can appear between `*args` and `**kwargs`" def nested5(*args: P.args, x: int, **kwargs: P.kwargs) -> None: ... + # error: [invalid-paramspec] "`P.args` is not valid in this position; it can only be used to annotate `*args` or `**kwargs`" + def nested6(x: P.args) -> None: ... + def nested7( + *args: P.args, + # error: [invalid-paramspec] "`**kwargs` must be annotated with `P.kwargs` (to match `*args: P.args`)" + **kwargs: int, + ) -> None: ... + # TODO: error def bar1(*args: P.args, **kwargs: P.kwargs) -> None: pass @@ -223,17 +231,17 @@ And, they need to be used together. ```py def foo2(c: Callable[P, int]) -> None: - # TODO: error + # error: [invalid-paramspec] "`*args: P.args` must be accompanied by `**kwargs: P.kwargs`" def nested1(*args: P.args) -> None: ... - # TODO: error + # error: [invalid-paramspec] "`**kwargs: P.kwargs` must be accompanied by `*args: P.args`" def nested2(**kwargs: P.kwargs) -> None: ... class Foo2: - # TODO: error + # error: [invalid-paramspec] "`P.args` is not valid in this position" args: P.args - # TODO: error + # error: [invalid-paramspec] "`P.kwargs` is not valid in this position" kwargs: P.kwargs ``` diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md index 2c75fa153f64d0..ae42fe7e39f25d 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md @@ -103,31 +103,39 @@ def foo[**P](c: Callable[P, int]) -> None: # error: [invalid-type-form] "`P.args` is valid only in `*args` annotation: Did you mean `P.kwargs`?" def nested2(*args: P.kwargs, **kwargs: P.args) -> None: ... - # TODO: error + # error: [invalid-paramspec] "`*args: P.args` must be accompanied by `**kwargs: P.kwargs`" def nested3(*args: P.args) -> None: ... - # TODO: error + # error: [invalid-paramspec] "`**kwargs: P.kwargs` must be accompanied by `*args: P.args`" def nested4(**kwargs: P.kwargs) -> None: ... - # TODO: error + # error: [invalid-paramspec] "When using `P.args` and `P.kwargs`, no other parameters can appear between `*args` and `**kwargs`" def nested5(*args: P.args, x: int, **kwargs: P.kwargs) -> None: ... + + # error: [invalid-paramspec] "`P.args` is not valid in this position; it can only be used to annotate `*args` or `**kwargs`" + def nested6(x: P.args) -> None: ... + def nested7( + *args: P.args, + # error: [invalid-paramspec] "`**kwargs` must be annotated with `P.kwargs` (to match `*args: P.args`)" + **kwargs: int, + ) -> None: ... ``` And, they need to be used together. ```py def foo[**P](c: Callable[P, int]) -> None: - # TODO: error + # error: [invalid-paramspec] "`*args: P.args` must be accompanied by `**kwargs: P.kwargs`" def nested1(*args: P.args) -> None: ... - # TODO: error + # error: [invalid-paramspec] "`**kwargs: P.kwargs` must be accompanied by `*args: P.args`" def nested2(**kwargs: P.kwargs) -> None: ... class Foo[**P]: - # TODO: error + # error: [invalid-paramspec] "`P.args` is not valid in this position" args: P.args - # TODO: error + # error: [invalid-paramspec] "`P.kwargs` is not valid in this position" kwargs: P.kwargs ``` @@ -152,9 +160,9 @@ It isn't allowed to annotate an instance attribute either: class Foo4[**P]: def __init__(self, fn: Callable[P, int], *args: P.args, **kwargs: P.kwargs) -> None: self.fn = fn - # TODO: error + # error: [invalid-paramspec] "`P.args` is not valid in this position" self.args: P.args = args - # TODO: error + # error: [invalid-paramspec] "`P.kwargs` is not valid in this position" self.kwargs: P.kwargs = kwargs ``` diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index f3e84099b5179e..1633c9ddc06e5b 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -3176,6 +3176,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { for parameter in &function.parameters { self.infer_definition(parameter); } + + self.validate_paramspec_components(&function.parameters); + self.infer_body(&function.body); if let Some(returns) = function.returns.as_deref() { @@ -3668,6 +3671,29 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let default_expr = default.as_ref(); if let Some(annotation) = parameter.annotation.as_ref() { let declared_ty = self.file_expression_type(annotation); + + // P.args and P.kwargs are only valid as annotations on *args and **kwargs, + // not on regular parameters. + if let Type::TypeVar(typevar) = declared_ty + && typevar.is_paramspec(self.db()) + && let Some(attr) = typevar.paramspec_attr(self.db()) + { + let name = typevar.name(self.db()); + let attr_name = match attr { + ParamSpecAttrKind::Args => "args", + ParamSpecAttrKind::Kwargs => "kwargs", + }; + if let Some(builder) = self + .context + .report_lint(&INVALID_PARAMSPEC, annotation.as_ref()) + { + builder.into_diagnostic(format_args!( + "`{name}.{attr_name}` is not valid in this position; \ + it can only be used to annotate `*args` or `**kwargs`", + )); + } + } + if let Some(default_expr) = default_expr { let default_expr = default_expr.as_ref(); let default_ty = self.file_expression_type(default_expr); @@ -3933,6 +3959,156 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } + /// Validate the usage of `ParamSpec` components (`P.args` and `P.kwargs`) across all + /// parameters of a function. + /// + /// This enforces several rules from the typing spec: + /// - `P.args` and `P.kwargs` must always be used together + /// - When `*args: P.args` is present, `**kwargs` must be annotated with `P.kwargs` (same P) + /// - No keyword-only parameters are allowed between `*args: P.args` and `**kwargs: P.kwargs` + fn validate_paramspec_components(&mut self, parameters: &ast::Parameters) { + let db = self.db(); + + // Extract ParamSpec info from *args annotation + let args_paramspec = parameters.vararg.as_deref().and_then(|vararg| { + let annotation = vararg.annotation()?; + let ty = self.file_expression_type(annotation); + if let Type::TypeVar(typevar) = ty + && typevar.is_paramspec(db) + && typevar.paramspec_attr(db) == Some(ParamSpecAttrKind::Args) + { + Some((typevar.without_paramspec_attr(db), annotation)) + } else { + None + } + }); + + // Extract ParamSpec info from **kwargs annotation + let kwargs_paramspec = parameters.kwarg.as_deref().and_then(|kwarg| { + let annotation = kwarg.annotation()?; + let ty = self.file_expression_type(annotation); + if let Type::TypeVar(typevar) = ty + && typevar.is_paramspec(db) + && typevar.paramspec_attr(db) == Some(ParamSpecAttrKind::Kwargs) + { + Some((typevar.without_paramspec_attr(db), annotation)) + } else { + None + } + }); + + match (args_paramspec, kwargs_paramspec) { + // Both *args: P.args and **kwargs: P.kwargs present + (Some((args_tv, _args_annotation)), Some((kwargs_tv, kwargs_annotation))) => { + // Check they refer to the same ParamSpec + if !args_tv.is_same_typevar_as(db, kwargs_tv) { + let args_name = args_tv.name(db); + let kwargs_name = kwargs_tv.name(db); + if let Some(builder) = self + .context + .report_lint(&INVALID_PARAMSPEC, kwargs_annotation) + { + builder.into_diagnostic(format_args!( + "`**kwargs` must be annotated with `{args_name}.kwargs` \ + (to match `*args: {args_name}.args`), \ + not `{kwargs_name}.kwargs`", + )); + } + } else { + // Same ParamSpec - check no keyword-only params between them + if !parameters.kwonlyargs.is_empty() { + let name = args_tv.name(db); + if let Some(builder) = self + .context + .report_lint(&INVALID_PARAMSPEC, ¶meters.kwonlyargs[0]) + { + builder.into_diagnostic(format_args!( + "When using `{name}.args` and `{name}.kwargs`, \ + no other parameters can appear between `*args` and `**kwargs`", + )); + } + } + } + } + + // *args: P.args without matching **kwargs: P.kwargs + (Some((args_tv, args_annotation)), None) => { + let name = args_tv.name(db); + // Check if **kwargs exists but is annotated with something else + if let Some(kwarg) = parameters.kwarg.as_deref() { + if let Some(kwarg_annotation) = kwarg.annotation() { + if let Some(builder) = self + .context + .report_lint(&INVALID_PARAMSPEC, kwarg_annotation) + { + builder.into_diagnostic(format_args!( + "`**kwargs` must be annotated with `{name}.kwargs` \ + (to match `*args: {name}.args`)", + )); + } + } else if let Some(builder) = + self.context.report_lint(&INVALID_PARAMSPEC, kwarg) + { + builder.into_diagnostic(format_args!( + "`**kwargs` must be annotated with `{name}.kwargs` \ + (to match `*args: {name}.args`)", + )); + } + } else { + // No **kwargs at all + if let Some(builder) = self + .context + .report_lint(&INVALID_PARAMSPEC, args_annotation) + { + builder.into_diagnostic(format_args!( + "`*args: {name}.args` must be accompanied by `**kwargs: {name}.kwargs`", + )); + } + } + } + + // **kwargs: P.kwargs without matching *args: P.args + (None, Some((kwargs_tv, kwargs_annotation))) => { + let name = kwargs_tv.name(db); + // Check if *args exists but is annotated with something else + if let Some(vararg) = parameters.vararg.as_deref() { + if let Some(vararg_annotation) = vararg.annotation() { + if let Some(builder) = self + .context + .report_lint(&INVALID_PARAMSPEC, vararg_annotation) + { + builder.into_diagnostic(format_args!( + "`*args` must be annotated with `{name}.args` \ + (to match `**kwargs: {name}.kwargs`)", + )); + } + } else if let Some(builder) = + self.context.report_lint(&INVALID_PARAMSPEC, vararg) + { + builder.into_diagnostic(format_args!( + "`*args` must be annotated with `{name}.args` \ + (to match `**kwargs: {name}.kwargs`)", + )); + } + } else { + // No *args at all + if let Some(builder) = self + .context + .report_lint(&INVALID_PARAMSPEC, kwargs_annotation) + { + builder.into_diagnostic(format_args!( + "`**kwargs: {name}.kwargs` must be accompanied by \ + `*args: {name}.args`", + )); + } + } + } + + // No ParamSpec components in either position + (None, None) => {} + } + } + fn infer_class_definition_statement(&mut self, class: &ast::StmtClassDef) { self.infer_definition(class); } @@ -9224,6 +9400,46 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } + // P.args and P.kwargs are only valid as annotations on *args and **kwargs. + if let Type::TypeVar(typevar) = annotated.inner_type() + && typevar.is_paramspec(self.db()) + && let Some(attr) = typevar.paramspec_attr(self.db()) + { + let name = typevar.name(self.db()); + let attr_name = match attr { + ParamSpecAttrKind::Args => "args", + ParamSpecAttrKind::Kwargs => "kwargs", + }; + if let Some(builder) = self + .context + .report_lint(&INVALID_PARAMSPEC, annotation.as_ref()) + { + builder.into_diagnostic(format_args!( + "`{name}.{attr_name}` is not valid in this position; \ + it can only be used to annotate `*args` or `**kwargs`", + )); + } + } else if let ast::Expr::Attribute(attr_expr) = annotation.as_ref() + && matches!(attr_expr.attr.as_str(), "args" | "kwargs") + { + let value_ty = self.expression_type(&attr_expr.value); + if let Type::KnownInstance(KnownInstanceType::TypeVar(typevar)) = value_ty + && typevar.is_paramspec(self.db()) + { + let name = typevar.name(self.db()); + let attr_name = &attr_expr.attr; + if let Some(builder) = self + .context + .report_lint(&INVALID_PARAMSPEC, annotation.as_ref()) + { + builder.into_diagnostic(format_args!( + "`{name}.{attr_name}` is not valid in this position; \ + it can only be used to annotate `*args` or `**kwargs`", + )); + } + } + } + let value_ty = value.as_ref().map(|value| { self.infer_maybe_standalone_expression( value, @@ -9368,6 +9584,43 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { DeferredExpressionState::from(self.defer_annotations()), ); + // P.args and P.kwargs are only valid as annotations on *args and **kwargs, + // not as variable annotations. Check both resolved type and AST form. + if let Type::TypeVar(typevar) = declared.inner_type() + && typevar.is_paramspec(self.db()) + && let Some(attr) = typevar.paramspec_attr(self.db()) + { + let name = typevar.name(self.db()); + let attr_name = match attr { + ParamSpecAttrKind::Args => "args", + ParamSpecAttrKind::Kwargs => "kwargs", + }; + if let Some(builder) = self.context.report_lint(&INVALID_PARAMSPEC, annotation) { + builder.into_diagnostic(format_args!( + "`{name}.{attr_name}` is not valid in this position; \ + it can only be used to annotate `*args` or `**kwargs`", + )); + } + } else if let ast::Expr::Attribute(attr_expr) = annotation + && matches!(attr_expr.attr.as_str(), "args" | "kwargs") + { + // Also check the AST form for cases where P isn't bound (e.g., class body + // annotations). In this case, the type might not resolve to a TypeVar. + let value_ty = self.expression_type(&attr_expr.value); + if let Type::KnownInstance(KnownInstanceType::TypeVar(typevar)) = value_ty + && typevar.is_paramspec(self.db()) + { + let name = typevar.name(self.db()); + let attr_name = &attr_expr.attr; + if let Some(builder) = self.context.report_lint(&INVALID_PARAMSPEC, annotation) { + builder.into_diagnostic(format_args!( + "`{name}.{attr_name}` is not valid in this position; \ + it can only be used to annotate `*args` or `**kwargs`", + )); + } + } + } + let is_pep_613_type_alias = declared.inner_type().is_typealias_special_form(); if is_pep_613_type_alias From 3521acea3ad3e22f131b0a844a5af1c53b561376 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 14:38:54 +0000 Subject: [PATCH 2/5] [ty] Shorten and unify ParamSpec diagnostic messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "When using ... no other parameters can appear between ..." → "No parameters may appear between `*args: P.args` and `**kwargs: P.kwargs`" - "`P.args` is not valid in this position; it can only be used to ..." → "`P.args` is only valid for annotating `*args`" - "`**kwargs` must be annotated with ... (to match ...)" → "`*args: P.args` must be accompanied by `**kwargs: P.kwargs`" https://claude.ai/code/session_01BNCtgwYBcEEFDEfuzWPLpQ --- .../mdtest/generics/legacy/paramspec.md | 10 +- .../mdtest/generics/pep695/paramspec.md | 14 +-- .../src/types/infer/builder.rs | 119 +++++++----------- 3 files changed, 56 insertions(+), 87 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/generics/legacy/paramspec.md b/crates/ty_python_semantic/resources/mdtest/generics/legacy/paramspec.md index 2b27846dce1f77..850e28f02f6995 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/legacy/paramspec.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/legacy/paramspec.md @@ -207,14 +207,14 @@ def foo1(c: Callable[P, int]) -> None: # error: [invalid-paramspec] "`**kwargs: P.kwargs` must be accompanied by `*args: P.args`" def nested4(**kwargs: P.kwargs) -> None: ... - # error: [invalid-paramspec] "When using `P.args` and `P.kwargs`, no other parameters can appear between `*args` and `**kwargs`" + # error: [invalid-paramspec] "No parameters may appear between `*args: P.args` and `**kwargs: P.kwargs`" def nested5(*args: P.args, x: int, **kwargs: P.kwargs) -> None: ... - # error: [invalid-paramspec] "`P.args` is not valid in this position; it can only be used to annotate `*args` or `**kwargs`" + # error: [invalid-paramspec] "`P.args` is only valid for annotating `*args`" def nested6(x: P.args) -> None: ... def nested7( *args: P.args, - # error: [invalid-paramspec] "`**kwargs` must be annotated with `P.kwargs` (to match `*args: P.args`)" + # error: [invalid-paramspec] "`*args: P.args` must be accompanied by `**kwargs: P.kwargs`" **kwargs: int, ) -> None: ... @@ -238,10 +238,10 @@ def foo2(c: Callable[P, int]) -> None: def nested2(**kwargs: P.kwargs) -> None: ... class Foo2: - # error: [invalid-paramspec] "`P.args` is not valid in this position" + # error: [invalid-paramspec] "`P.args` is only valid for annotating `*args`" args: P.args - # error: [invalid-paramspec] "`P.kwargs` is not valid in this position" + # error: [invalid-paramspec] "`P.kwargs` is only valid for annotating `**kwargs`" kwargs: P.kwargs ``` diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md index ae42fe7e39f25d..f472c8b92b9058 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md @@ -109,14 +109,14 @@ def foo[**P](c: Callable[P, int]) -> None: # error: [invalid-paramspec] "`**kwargs: P.kwargs` must be accompanied by `*args: P.args`" def nested4(**kwargs: P.kwargs) -> None: ... - # error: [invalid-paramspec] "When using `P.args` and `P.kwargs`, no other parameters can appear between `*args` and `**kwargs`" + # error: [invalid-paramspec] "No parameters may appear between `*args: P.args` and `**kwargs: P.kwargs`" def nested5(*args: P.args, x: int, **kwargs: P.kwargs) -> None: ... - # error: [invalid-paramspec] "`P.args` is not valid in this position; it can only be used to annotate `*args` or `**kwargs`" + # error: [invalid-paramspec] "`P.args` is only valid for annotating `*args`" def nested6(x: P.args) -> None: ... def nested7( *args: P.args, - # error: [invalid-paramspec] "`**kwargs` must be annotated with `P.kwargs` (to match `*args: P.args`)" + # error: [invalid-paramspec] "`*args: P.args` must be accompanied by `**kwargs: P.kwargs`" **kwargs: int, ) -> None: ... ``` @@ -132,10 +132,10 @@ def foo[**P](c: Callable[P, int]) -> None: def nested2(**kwargs: P.kwargs) -> None: ... class Foo[**P]: - # error: [invalid-paramspec] "`P.args` is not valid in this position" + # error: [invalid-paramspec] "`P.args` is only valid for annotating `*args`" args: P.args - # error: [invalid-paramspec] "`P.kwargs` is not valid in this position" + # error: [invalid-paramspec] "`P.kwargs` is only valid for annotating `**kwargs`" kwargs: P.kwargs ``` @@ -160,9 +160,9 @@ It isn't allowed to annotate an instance attribute either: class Foo4[**P]: def __init__(self, fn: Callable[P, int], *args: P.args, **kwargs: P.kwargs) -> None: self.fn = fn - # error: [invalid-paramspec] "`P.args` is not valid in this position" + # error: [invalid-paramspec] "`P.args` is only valid for annotating `*args`" self.args: P.args = args - # error: [invalid-paramspec] "`P.kwargs` is not valid in this position" + # error: [invalid-paramspec] "`P.kwargs` is only valid for annotating `**kwargs`" self.kwargs: P.kwargs = kwargs ``` diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 1633c9ddc06e5b..8fc0ecc62dca15 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -3679,17 +3679,16 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { && let Some(attr) = typevar.paramspec_attr(self.db()) { let name = typevar.name(self.db()); - let attr_name = match attr { - ParamSpecAttrKind::Args => "args", - ParamSpecAttrKind::Kwargs => "kwargs", + let (attr_name, variadic) = match attr { + ParamSpecAttrKind::Args => ("args", "*args"), + ParamSpecAttrKind::Kwargs => ("kwargs", "**kwargs"), }; if let Some(builder) = self .context .report_lint(&INVALID_PARAMSPEC, annotation.as_ref()) { builder.into_diagnostic(format_args!( - "`{name}.{attr_name}` is not valid in this position; \ - it can only be used to annotate `*args` or `**kwargs`", + "`{name}.{attr_name}` is only valid for annotating `{variadic}`", )); } } @@ -3964,7 +3963,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { /// /// This enforces several rules from the typing spec: /// - `P.args` and `P.kwargs` must always be used together - /// - When `*args: P.args` is present, `**kwargs` must be annotated with `P.kwargs` (same P) + /// - When `*args: P.args` is present, `**kwargs: P.kwargs` must also be present (same P) /// - No keyword-only parameters are allowed between `*args: P.args` and `**kwargs: P.kwargs` fn validate_paramspec_components(&mut self, parameters: &ast::Parameters) { let db = self.db(); @@ -4003,15 +4002,13 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // Check they refer to the same ParamSpec if !args_tv.is_same_typevar_as(db, kwargs_tv) { let args_name = args_tv.name(db); - let kwargs_name = kwargs_tv.name(db); if let Some(builder) = self .context .report_lint(&INVALID_PARAMSPEC, kwargs_annotation) { builder.into_diagnostic(format_args!( - "`**kwargs` must be annotated with `{args_name}.kwargs` \ - (to match `*args: {args_name}.args`), \ - not `{kwargs_name}.kwargs`", + "`*args: {args_name}.args` must be accompanied \ + by `**kwargs: {args_name}.kwargs`", )); } } else { @@ -4023,8 +4020,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .report_lint(&INVALID_PARAMSPEC, ¶meters.kwonlyargs[0]) { builder.into_diagnostic(format_args!( - "When using `{name}.args` and `{name}.kwargs`, \ - no other parameters can appear between `*args` and `**kwargs`", + "No parameters may appear between \ + `*args: {name}.args` and `**kwargs: {name}.kwargs`", )); } } @@ -4034,62 +4031,36 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // *args: P.args without matching **kwargs: P.kwargs (Some((args_tv, args_annotation)), None) => { let name = args_tv.name(db); - // Check if **kwargs exists but is annotated with something else - if let Some(kwarg) = parameters.kwarg.as_deref() { - if let Some(kwarg_annotation) = kwarg.annotation() { - if let Some(builder) = self - .context - .report_lint(&INVALID_PARAMSPEC, kwarg_annotation) - { - builder.into_diagnostic(format_args!( - "`**kwargs` must be annotated with `{name}.kwargs` \ - (to match `*args: {name}.args`)", - )); - } - } else if let Some(builder) = - self.context.report_lint(&INVALID_PARAMSPEC, kwarg) - { - builder.into_diagnostic(format_args!( - "`**kwargs` must be annotated with `{name}.kwargs` \ - (to match `*args: {name}.args`)", - )); - } + // Report on the kwarg annotation if it exists, otherwise on *args + let range = if let Some(kwarg) = parameters.kwarg.as_deref() { + kwarg + .annotation() + .map_or(kwarg.range(), |a| a.range()) } else { - // No **kwargs at all - if let Some(builder) = self - .context - .report_lint(&INVALID_PARAMSPEC, args_annotation) - { - builder.into_diagnostic(format_args!( - "`*args: {name}.args` must be accompanied by `**kwargs: {name}.kwargs`", - )); - } + args_annotation.range() + }; + if let Some(builder) = self.context.report_lint(&INVALID_PARAMSPEC, range) { + builder.into_diagnostic(format_args!( + "`*args: {name}.args` must be accompanied by `**kwargs: {name}.kwargs`", + )); } } // **kwargs: P.kwargs without matching *args: P.args (None, Some((kwargs_tv, kwargs_annotation))) => { let name = kwargs_tv.name(db); - // Check if *args exists but is annotated with something else - if let Some(vararg) = parameters.vararg.as_deref() { - if let Some(vararg_annotation) = vararg.annotation() { - if let Some(builder) = self - .context - .report_lint(&INVALID_PARAMSPEC, vararg_annotation) - { - builder.into_diagnostic(format_args!( - "`*args` must be annotated with `{name}.args` \ - (to match `**kwargs: {name}.kwargs`)", - )); - } - } else if let Some(builder) = - self.context.report_lint(&INVALID_PARAMSPEC, vararg) - { - builder.into_diagnostic(format_args!( - "`*args` must be annotated with `{name}.args` \ - (to match `**kwargs: {name}.kwargs`)", - )); - } + // Report on the vararg annotation if it exists, otherwise on **kwargs + let range = if let Some(vararg) = parameters.vararg.as_deref() { + vararg + .annotation() + .map_or(vararg.range(), |a| a.range()) + } else { + kwargs_annotation.range() + }; + if let Some(builder) = self.context.report_lint(&INVALID_PARAMSPEC, range) { + builder.into_diagnostic(format_args!( + "`**kwargs: {name}.kwargs` must be accompanied by `*args: {name}.args`", + )); } else { // No *args at all if let Some(builder) = self @@ -9406,17 +9377,16 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { && let Some(attr) = typevar.paramspec_attr(self.db()) { let name = typevar.name(self.db()); - let attr_name = match attr { - ParamSpecAttrKind::Args => "args", - ParamSpecAttrKind::Kwargs => "kwargs", + let (attr_name, variadic) = match attr { + ParamSpecAttrKind::Args => ("args", "*args"), + ParamSpecAttrKind::Kwargs => ("kwargs", "**kwargs"), }; if let Some(builder) = self .context .report_lint(&INVALID_PARAMSPEC, annotation.as_ref()) { builder.into_diagnostic(format_args!( - "`{name}.{attr_name}` is not valid in this position; \ - it can only be used to annotate `*args` or `**kwargs`", + "`{name}.{attr_name}` is only valid for annotating `{variadic}`", )); } } else if let ast::Expr::Attribute(attr_expr) = annotation.as_ref() @@ -9428,13 +9398,13 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { { let name = typevar.name(self.db()); let attr_name = &attr_expr.attr; + let variadic = if attr_name == "args" { "*args" } else { "**kwargs" }; if let Some(builder) = self .context .report_lint(&INVALID_PARAMSPEC, annotation.as_ref()) { builder.into_diagnostic(format_args!( - "`{name}.{attr_name}` is not valid in this position; \ - it can only be used to annotate `*args` or `**kwargs`", + "`{name}.{attr_name}` is only valid for annotating `{variadic}`", )); } } @@ -9591,14 +9561,13 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { && let Some(attr) = typevar.paramspec_attr(self.db()) { let name = typevar.name(self.db()); - let attr_name = match attr { - ParamSpecAttrKind::Args => "args", - ParamSpecAttrKind::Kwargs => "kwargs", + let (attr_name, variadic) = match attr { + ParamSpecAttrKind::Args => ("args", "*args"), + ParamSpecAttrKind::Kwargs => ("kwargs", "**kwargs"), }; if let Some(builder) = self.context.report_lint(&INVALID_PARAMSPEC, annotation) { builder.into_diagnostic(format_args!( - "`{name}.{attr_name}` is not valid in this position; \ - it can only be used to annotate `*args` or `**kwargs`", + "`{name}.{attr_name}` is only valid for annotating `{variadic}`", )); } } else if let ast::Expr::Attribute(attr_expr) = annotation @@ -9612,10 +9581,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { { let name = typevar.name(self.db()); let attr_name = &attr_expr.attr; + let variadic = if attr_name == "args" { "*args" } else { "**kwargs" }; if let Some(builder) = self.context.report_lint(&INVALID_PARAMSPEC, annotation) { builder.into_diagnostic(format_args!( - "`{name}.{attr_name}` is not valid in this position; \ - it can only be used to annotate `*args` or `**kwargs`", + "`{name}.{attr_name}` is only valid for annotating `{variadic}`", )); } } From be537e86f174d61d121d393c32f783339bfea747 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 14:45:13 +0000 Subject: [PATCH 3/5] [ty] Add 'function parameters' suffix to out-of-parameter-list ParamSpec errors Distinguishes "`P.args` is only valid for annotating `*args`" (inside a parameter list) from "`P.args` is only valid for annotating `*args` function parameters" (variable/attribute annotations). https://claude.ai/code/session_01BNCtgwYBcEEFDEfuzWPLpQ --- .../resources/mdtest/generics/legacy/paramspec.md | 4 ++-- .../resources/mdtest/generics/pep695/paramspec.md | 8 ++++---- crates/ty_python_semantic/src/types/infer/builder.rs | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/generics/legacy/paramspec.md b/crates/ty_python_semantic/resources/mdtest/generics/legacy/paramspec.md index 850e28f02f6995..8635b03df88679 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/legacy/paramspec.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/legacy/paramspec.md @@ -238,10 +238,10 @@ def foo2(c: Callable[P, int]) -> None: def nested2(**kwargs: P.kwargs) -> None: ... class Foo2: - # error: [invalid-paramspec] "`P.args` is only valid for annotating `*args`" + # error: [invalid-paramspec] "`P.args` is only valid for annotating `*args` function parameters" args: P.args - # error: [invalid-paramspec] "`P.kwargs` is only valid for annotating `**kwargs`" + # error: [invalid-paramspec] "`P.kwargs` is only valid for annotating `**kwargs` function parameters" kwargs: P.kwargs ``` diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md index f472c8b92b9058..f550e9eb34fc1d 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md @@ -132,10 +132,10 @@ def foo[**P](c: Callable[P, int]) -> None: def nested2(**kwargs: P.kwargs) -> None: ... class Foo[**P]: - # error: [invalid-paramspec] "`P.args` is only valid for annotating `*args`" + # error: [invalid-paramspec] "`P.args` is only valid for annotating `*args` function parameters" args: P.args - # error: [invalid-paramspec] "`P.kwargs` is only valid for annotating `**kwargs`" + # error: [invalid-paramspec] "`P.kwargs` is only valid for annotating `**kwargs` function parameters" kwargs: P.kwargs ``` @@ -160,9 +160,9 @@ It isn't allowed to annotate an instance attribute either: class Foo4[**P]: def __init__(self, fn: Callable[P, int], *args: P.args, **kwargs: P.kwargs) -> None: self.fn = fn - # error: [invalid-paramspec] "`P.args` is only valid for annotating `*args`" + # error: [invalid-paramspec] "`P.args` is only valid for annotating `*args` function parameters" self.args: P.args = args - # error: [invalid-paramspec] "`P.kwargs` is only valid for annotating `**kwargs`" + # error: [invalid-paramspec] "`P.kwargs` is only valid for annotating `**kwargs` function parameters" self.kwargs: P.kwargs = kwargs ``` diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 8fc0ecc62dca15..805c182df8613f 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -9386,7 +9386,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .report_lint(&INVALID_PARAMSPEC, annotation.as_ref()) { builder.into_diagnostic(format_args!( - "`{name}.{attr_name}` is only valid for annotating `{variadic}`", + "`{name}.{attr_name}` is only valid for annotating `{variadic}` function parameters", )); } } else if let ast::Expr::Attribute(attr_expr) = annotation.as_ref() @@ -9404,7 +9404,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .report_lint(&INVALID_PARAMSPEC, annotation.as_ref()) { builder.into_diagnostic(format_args!( - "`{name}.{attr_name}` is only valid for annotating `{variadic}`", + "`{name}.{attr_name}` is only valid for annotating `{variadic}` function parameters", )); } } @@ -9567,7 +9567,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { }; if let Some(builder) = self.context.report_lint(&INVALID_PARAMSPEC, annotation) { builder.into_diagnostic(format_args!( - "`{name}.{attr_name}` is only valid for annotating `{variadic}`", + "`{name}.{attr_name}` is only valid for annotating `{variadic}` function parameters", )); } } else if let ast::Expr::Attribute(attr_expr) = annotation @@ -9584,7 +9584,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let variadic = if attr_name == "args" { "*args" } else { "**kwargs" }; if let Some(builder) = self.context.report_lint(&INVALID_PARAMSPEC, annotation) { builder.into_diagnostic(format_args!( - "`{name}.{attr_name}` is only valid for annotating `{variadic}`", + "`{name}.{attr_name}` is only valid for annotating `{variadic}` function parameters", )); } } From 130a5e4b34f73c94c6d66449859f50dbd6326715 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 15:00:18 +0000 Subject: [PATCH 4/5] [ty] Use actual parameter names in ParamSpec error messages Instead of hardcoding `*args` and `**kwargs` in error messages like "`*args: P.args` must be accompanied by `**kwargs: P.kwargs`", use the actual variadic-positional and keyword-variadic parameter names from the function signature. For example, `def f(*my_args: P.args)` now produces "`*my_args: P.args` must be accompanied by `**kwargs: P.kwargs`". https://claude.ai/code/session_01BNCtgwYBcEEFDEfuzWPLpQ --- .../mdtest/generics/legacy/paramspec.md | 17 +++++++ .../mdtest/generics/pep695/paramspec.md | 17 +++++++ .../src/types/infer/builder.rs | 49 +++++++++++++------ 3 files changed, 68 insertions(+), 15 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/generics/legacy/paramspec.md b/crates/ty_python_semantic/resources/mdtest/generics/legacy/paramspec.md index 8635b03df88679..f29db13a19e8a8 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/legacy/paramspec.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/legacy/paramspec.md @@ -260,6 +260,23 @@ class Foo3(Generic[P]): ) -> None: ... ``` +Error messages for `invalid-paramspec` also use the actual parameter names: + +```py +def bar(c: Callable[P, int]) -> None: + # error: [invalid-paramspec] "`*my_args: P.args` must be accompanied by `**my_kwargs: P.kwargs`" + def f1(*my_args: P.args, **my_kwargs: int) -> None: ... + + # error: [invalid-paramspec] "`*positional: P.args` must be accompanied by `**kwargs: P.kwargs`" + def f2(*positional: P.args) -> None: ... + + # error: [invalid-paramspec] "`**keyword: P.kwargs` must be accompanied by `*args: P.args`" + def f3(**keyword: P.kwargs) -> None: ... + + # error: [invalid-paramspec] "No parameters may appear between `*a: P.args` and `**kw: P.kwargs`" + def f4(*a: P.args, x: int, **kw: P.kwargs) -> None: ... +``` + ## Specializing generic classes explicitly ```py diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md index f550e9eb34fc1d..7b0d89f82e0ad8 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/paramspec.md @@ -154,6 +154,23 @@ class Foo3[**P]: ) -> None: ... ``` +Error messages for `invalid-paramspec` also use the actual parameter names: + +```py +def bar[**P](c: Callable[P, int]) -> None: + # error: [invalid-paramspec] "`*my_args: P.args` must be accompanied by `**my_kwargs: P.kwargs`" + def f1(*my_args: P.args, **my_kwargs: int) -> None: ... + + # error: [invalid-paramspec] "`*positional: P.args` must be accompanied by `**kwargs: P.kwargs`" + def f2(*positional: P.args) -> None: ... + + # error: [invalid-paramspec] "`**keyword: P.kwargs` must be accompanied by `*args: P.args`" + def f3(**keyword: P.kwargs) -> None: ... + + # error: [invalid-paramspec] "No parameters may appear between `*a: P.args` and `**kw: P.kwargs`" + def f4(*a: P.args, x: int, **kw: P.kwargs) -> None: ... +``` + It isn't allowed to annotate an instance attribute either: ```py diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 805c182df8613f..bde83c8b5e3237 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -3996,32 +3996,39 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } }); + let vararg_name = parameters.vararg.as_deref().map(|v| v.name.as_str()); + let kwarg_name = parameters.kwarg.as_deref().map(|k| k.name.as_str()); + match (args_paramspec, kwargs_paramspec) { // Both *args: P.args and **kwargs: P.kwargs present (Some((args_tv, _args_annotation)), Some((kwargs_tv, kwargs_annotation))) => { // Check they refer to the same ParamSpec if !args_tv.is_same_typevar_as(db, kwargs_tv) { let args_name = args_tv.name(db); + let vararg = vararg_name.unwrap_or("args"); + let kwarg = kwarg_name.unwrap_or("kwargs"); if let Some(builder) = self .context .report_lint(&INVALID_PARAMSPEC, kwargs_annotation) { builder.into_diagnostic(format_args!( - "`*args: {args_name}.args` must be accompanied \ - by `**kwargs: {args_name}.kwargs`", + "`*{vararg}: {args_name}.args` must be accompanied \ + by `**{kwarg}: {args_name}.kwargs`", )); } } else { // Same ParamSpec - check no keyword-only params between them if !parameters.kwonlyargs.is_empty() { let name = args_tv.name(db); + let vararg = vararg_name.unwrap_or("args"); + let kwarg = kwarg_name.unwrap_or("kwargs"); if let Some(builder) = self .context .report_lint(&INVALID_PARAMSPEC, ¶meters.kwonlyargs[0]) { builder.into_diagnostic(format_args!( "No parameters may appear between \ - `*args: {name}.args` and `**kwargs: {name}.kwargs`", + `*{vararg}: {name}.args` and `**{kwarg}: {name}.kwargs`", )); } } @@ -4031,17 +4038,19 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // *args: P.args without matching **kwargs: P.kwargs (Some((args_tv, args_annotation)), None) => { let name = args_tv.name(db); + let vararg = vararg_name.unwrap_or("args"); + let kwarg = kwarg_name.unwrap_or("kwargs"); // Report on the kwarg annotation if it exists, otherwise on *args - let range = if let Some(kwarg) = parameters.kwarg.as_deref() { - kwarg + let range = if let Some(kwarg_param) = parameters.kwarg.as_deref() { + kwarg_param .annotation() - .map_or(kwarg.range(), |a| a.range()) + .map_or(kwarg_param.range(), Ranged::range) } else { args_annotation.range() }; if let Some(builder) = self.context.report_lint(&INVALID_PARAMSPEC, range) { builder.into_diagnostic(format_args!( - "`*args: {name}.args` must be accompanied by `**kwargs: {name}.kwargs`", + "`*{vararg}: {name}.args` must be accompanied by `**{kwarg}: {name}.kwargs`", )); } } @@ -4049,17 +4058,19 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // **kwargs: P.kwargs without matching *args: P.args (None, Some((kwargs_tv, kwargs_annotation))) => { let name = kwargs_tv.name(db); + let vararg = vararg_name.unwrap_or("args"); + let kwarg = kwarg_name.unwrap_or("kwargs"); // Report on the vararg annotation if it exists, otherwise on **kwargs - let range = if let Some(vararg) = parameters.vararg.as_deref() { - vararg + let range = if let Some(vararg_param) = parameters.vararg.as_deref() { + vararg_param .annotation() - .map_or(vararg.range(), |a| a.range()) + .map_or(vararg_param.range(), Ranged::range) } else { kwargs_annotation.range() }; if let Some(builder) = self.context.report_lint(&INVALID_PARAMSPEC, range) { builder.into_diagnostic(format_args!( - "`**kwargs: {name}.kwargs` must be accompanied by `*args: {name}.args`", + "`**{kwarg}: {name}.kwargs` must be accompanied by `*{vararg}: {name}.args`", )); } else { // No *args at all @@ -4068,8 +4079,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .report_lint(&INVALID_PARAMSPEC, kwargs_annotation) { builder.into_diagnostic(format_args!( - "`**kwargs: {name}.kwargs` must be accompanied by \ - `*args: {name}.args`", + "`**{kwarg}: {name}.kwargs` must be accompanied by \ + `*{kwarg}: {name}.args`", )); } } @@ -9398,7 +9409,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { { let name = typevar.name(self.db()); let attr_name = &attr_expr.attr; - let variadic = if attr_name == "args" { "*args" } else { "**kwargs" }; + let variadic = if attr_name == "args" { + "*args" + } else { + "**kwargs" + }; if let Some(builder) = self .context .report_lint(&INVALID_PARAMSPEC, annotation.as_ref()) @@ -9581,7 +9596,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { { let name = typevar.name(self.db()); let attr_name = &attr_expr.attr; - let variadic = if attr_name == "args" { "*args" } else { "**kwargs" }; + let variadic = if attr_name == "args" { + "*args" + } else { + "**kwargs" + }; if let Some(builder) = self.context.report_lint(&INVALID_PARAMSPEC, annotation) { builder.into_diagnostic(format_args!( "`{name}.{attr_name}` is only valid for annotating `{variadic}` function parameters", From 3d28da83ff3e49c77e719301003fde728b6d1279 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Sun, 1 Mar 2026 12:30:19 +0000 Subject: [PATCH 5/5] RESIST the ever-growing length of `infer/builder.rs` --- .../src/types/infer/builder.rs | 139 +----------------- .../infer/builder/paramspec_validation.rs | 135 +++++++++++++++++ 2 files changed, 140 insertions(+), 134 deletions(-) create mode 100644 crates/ty_python_semantic/src/types/infer/builder/paramspec_validation.rs diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index bde83c8b5e3237..20ef2cc86e8a3b 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -118,6 +118,7 @@ use crate::types::generics::{ GenericContext, InferableTypeVars, SpecializationBuilder, bind_typevar, enclosing_generic_contexts, typing_self, }; +use crate::types::infer::builder::paramspec_validation::validate_paramspec_components; use crate::types::infer::{nearest_enclosing_class, nearest_enclosing_function}; use crate::types::mro::{DynamicMroErrorKind, StaticMroErrorKind}; use crate::types::newtype::NewType; @@ -149,6 +150,7 @@ use crate::unpack::{EvaluationMode, UnpackPosition}; use crate::{AnalysisSettings, Db, FxIndexSet, FxOrderSet, Program}; mod annotation_expression; +mod paramspec_validation; mod type_expression; /// Whether the intersection type is on the left or right side of the comparison. @@ -3177,7 +3179,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { self.infer_definition(parameter); } - self.validate_paramspec_components(&function.parameters); + validate_paramspec_components(&self.context, &function.parameters, |expr| { + self.file_expression_type(expr) + }); self.infer_body(&function.body); @@ -3958,139 +3962,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } - /// Validate the usage of `ParamSpec` components (`P.args` and `P.kwargs`) across all - /// parameters of a function. - /// - /// This enforces several rules from the typing spec: - /// - `P.args` and `P.kwargs` must always be used together - /// - When `*args: P.args` is present, `**kwargs: P.kwargs` must also be present (same P) - /// - No keyword-only parameters are allowed between `*args: P.args` and `**kwargs: P.kwargs` - fn validate_paramspec_components(&mut self, parameters: &ast::Parameters) { - let db = self.db(); - - // Extract ParamSpec info from *args annotation - let args_paramspec = parameters.vararg.as_deref().and_then(|vararg| { - let annotation = vararg.annotation()?; - let ty = self.file_expression_type(annotation); - if let Type::TypeVar(typevar) = ty - && typevar.is_paramspec(db) - && typevar.paramspec_attr(db) == Some(ParamSpecAttrKind::Args) - { - Some((typevar.without_paramspec_attr(db), annotation)) - } else { - None - } - }); - - // Extract ParamSpec info from **kwargs annotation - let kwargs_paramspec = parameters.kwarg.as_deref().and_then(|kwarg| { - let annotation = kwarg.annotation()?; - let ty = self.file_expression_type(annotation); - if let Type::TypeVar(typevar) = ty - && typevar.is_paramspec(db) - && typevar.paramspec_attr(db) == Some(ParamSpecAttrKind::Kwargs) - { - Some((typevar.without_paramspec_attr(db), annotation)) - } else { - None - } - }); - - let vararg_name = parameters.vararg.as_deref().map(|v| v.name.as_str()); - let kwarg_name = parameters.kwarg.as_deref().map(|k| k.name.as_str()); - - match (args_paramspec, kwargs_paramspec) { - // Both *args: P.args and **kwargs: P.kwargs present - (Some((args_tv, _args_annotation)), Some((kwargs_tv, kwargs_annotation))) => { - // Check they refer to the same ParamSpec - if !args_tv.is_same_typevar_as(db, kwargs_tv) { - let args_name = args_tv.name(db); - let vararg = vararg_name.unwrap_or("args"); - let kwarg = kwarg_name.unwrap_or("kwargs"); - if let Some(builder) = self - .context - .report_lint(&INVALID_PARAMSPEC, kwargs_annotation) - { - builder.into_diagnostic(format_args!( - "`*{vararg}: {args_name}.args` must be accompanied \ - by `**{kwarg}: {args_name}.kwargs`", - )); - } - } else { - // Same ParamSpec - check no keyword-only params between them - if !parameters.kwonlyargs.is_empty() { - let name = args_tv.name(db); - let vararg = vararg_name.unwrap_or("args"); - let kwarg = kwarg_name.unwrap_or("kwargs"); - if let Some(builder) = self - .context - .report_lint(&INVALID_PARAMSPEC, ¶meters.kwonlyargs[0]) - { - builder.into_diagnostic(format_args!( - "No parameters may appear between \ - `*{vararg}: {name}.args` and `**{kwarg}: {name}.kwargs`", - )); - } - } - } - } - - // *args: P.args without matching **kwargs: P.kwargs - (Some((args_tv, args_annotation)), None) => { - let name = args_tv.name(db); - let vararg = vararg_name.unwrap_or("args"); - let kwarg = kwarg_name.unwrap_or("kwargs"); - // Report on the kwarg annotation if it exists, otherwise on *args - let range = if let Some(kwarg_param) = parameters.kwarg.as_deref() { - kwarg_param - .annotation() - .map_or(kwarg_param.range(), Ranged::range) - } else { - args_annotation.range() - }; - if let Some(builder) = self.context.report_lint(&INVALID_PARAMSPEC, range) { - builder.into_diagnostic(format_args!( - "`*{vararg}: {name}.args` must be accompanied by `**{kwarg}: {name}.kwargs`", - )); - } - } - - // **kwargs: P.kwargs without matching *args: P.args - (None, Some((kwargs_tv, kwargs_annotation))) => { - let name = kwargs_tv.name(db); - let vararg = vararg_name.unwrap_or("args"); - let kwarg = kwarg_name.unwrap_or("kwargs"); - // Report on the vararg annotation if it exists, otherwise on **kwargs - let range = if let Some(vararg_param) = parameters.vararg.as_deref() { - vararg_param - .annotation() - .map_or(vararg_param.range(), Ranged::range) - } else { - kwargs_annotation.range() - }; - if let Some(builder) = self.context.report_lint(&INVALID_PARAMSPEC, range) { - builder.into_diagnostic(format_args!( - "`**{kwarg}: {name}.kwargs` must be accompanied by `*{vararg}: {name}.args`", - )); - } else { - // No *args at all - if let Some(builder) = self - .context - .report_lint(&INVALID_PARAMSPEC, kwargs_annotation) - { - builder.into_diagnostic(format_args!( - "`**{kwarg}: {name}.kwargs` must be accompanied by \ - `*{kwarg}: {name}.args`", - )); - } - } - } - - // No ParamSpec components in either position - (None, None) => {} - } - } - fn infer_class_definition_statement(&mut self, class: &ast::StmtClassDef) { self.infer_definition(class); } diff --git a/crates/ty_python_semantic/src/types/infer/builder/paramspec_validation.rs b/crates/ty_python_semantic/src/types/infer/builder/paramspec_validation.rs new file mode 100644 index 00000000000000..dcd2ee3f54d086 --- /dev/null +++ b/crates/ty_python_semantic/src/types/infer/builder/paramspec_validation.rs @@ -0,0 +1,135 @@ +use crate::types::{ParamSpecAttrKind, Type, context::InferContext, diagnostic::INVALID_PARAMSPEC}; +use ruff_python_ast as ast; +use ruff_text_size::Ranged; + +/// Validate the usage of `ParamSpec` components (`P.args` and `P.kwargs`) across all +/// parameters of a function. +/// +/// This enforces several rules from the typing spec: +/// - `P.args` and `P.kwargs` must always be used together +/// - When `*args: P.args` is present, `**kwargs: P.kwargs` must also be present (same P) +/// - No keyword-only parameters are allowed between `*args: P.args` and `**kwargs: P.kwargs` +pub(super) fn validate_paramspec_components<'db>( + context: &'db InferContext<'db, '_>, + parameters: &ast::Parameters, + infer_type: impl Fn(&ast::Expr) -> Type<'db>, +) { + let db = context.db(); + + // Extract ParamSpec info from *args annotation + let args_paramspec = parameters.vararg.as_deref().and_then(|vararg| { + let annotation = vararg.annotation()?; + let ty = infer_type(annotation); + if let Type::TypeVar(typevar) = ty + && typevar.is_paramspec(db) + && typevar.paramspec_attr(db) == Some(ParamSpecAttrKind::Args) + { + Some((typevar.without_paramspec_attr(db), annotation)) + } else { + None + } + }); + + // Extract ParamSpec info from **kwargs annotation + let kwargs_paramspec = parameters.kwarg.as_deref().and_then(|kwarg| { + let annotation = kwarg.annotation()?; + let ty = infer_type(annotation); + if let Type::TypeVar(typevar) = ty + && typevar.is_paramspec(db) + && typevar.paramspec_attr(db) == Some(ParamSpecAttrKind::Kwargs) + { + Some((typevar.without_paramspec_attr(db), annotation)) + } else { + None + } + }); + + let vararg_name = parameters.vararg.as_deref().map(|v| v.name.as_str()); + let kwarg_name = parameters.kwarg.as_deref().map(|k| k.name.as_str()); + + match (args_paramspec, kwargs_paramspec) { + // Both *args: P.args and **kwargs: P.kwargs present + (Some((args_tv, _args_annotation)), Some((kwargs_tv, kwargs_annotation))) => { + // Check they refer to the same ParamSpec + if !args_tv.is_same_typevar_as(db, kwargs_tv) { + let args_name = args_tv.name(db); + let vararg = vararg_name.unwrap_or("args"); + let kwarg = kwarg_name.unwrap_or("kwargs"); + if let Some(builder) = context.report_lint(&INVALID_PARAMSPEC, kwargs_annotation) { + builder.into_diagnostic(format_args!( + "`*{vararg}: {args_name}.args` must be accompanied \ + by `**{kwarg}: {args_name}.kwargs`", + )); + } + } else { + // Same ParamSpec - check no keyword-only params between them + if !parameters.kwonlyargs.is_empty() { + let name = args_tv.name(db); + let vararg = vararg_name.unwrap_or("args"); + let kwarg = kwarg_name.unwrap_or("kwargs"); + if let Some(builder) = + context.report_lint(&INVALID_PARAMSPEC, ¶meters.kwonlyargs[0]) + { + builder.into_diagnostic(format_args!( + "No parameters may appear between \ + `*{vararg}: {name}.args` and `**{kwarg}: {name}.kwargs`", + )); + } + } + } + } + + // *args: P.args without matching **kwargs: P.kwargs + (Some((args_tv, args_annotation)), None) => { + let name = args_tv.name(db); + let vararg = vararg_name.unwrap_or("args"); + let kwarg = kwarg_name.unwrap_or("kwargs"); + // Report on the kwarg annotation if it exists, otherwise on *args + let range = if let Some(kwarg_param) = parameters.kwarg.as_deref() { + kwarg_param + .annotation() + .map(Ranged::range) + .unwrap_or_else(|| kwarg_param.range()) + } else { + args_annotation.range() + }; + if let Some(builder) = context.report_lint(&INVALID_PARAMSPEC, range) { + builder.into_diagnostic(format_args!( + "`*{vararg}: {name}.args` must be accompanied by `**{kwarg}: {name}.kwargs`", + )); + } + } + + // **kwargs: P.kwargs without matching *args: P.args + (None, Some((kwargs_tv, kwargs_annotation))) => { + let name = kwargs_tv.name(db); + let vararg = vararg_name.unwrap_or("args"); + let kwarg = kwarg_name.unwrap_or("kwargs"); + // Report on the vararg annotation if it exists, otherwise on **kwargs + let range = if let Some(vararg_param) = parameters.vararg.as_deref() { + vararg_param + .annotation() + .map(Ranged::range) + .unwrap_or_else(|| vararg_param.range()) + } else { + kwargs_annotation.range() + }; + if let Some(builder) = context.report_lint(&INVALID_PARAMSPEC, range) { + builder.into_diagnostic(format_args!( + "`**{kwarg}: {name}.kwargs` must be accompanied by `*{vararg}: {name}.args`", + )); + } else { + // No *args at all + if let Some(builder) = context.report_lint(&INVALID_PARAMSPEC, kwargs_annotation) { + builder.into_diagnostic(format_args!( + "`**{kwarg}: {name}.kwargs` must be accompanied by \ + `*{kwarg}: {name}.args`", + )); + } + } + } + + // No ParamSpec components in either position + (None, None) => {} + } +}