-
Notifications
You must be signed in to change notification settings - Fork 401
fix: skip unannotated-return check for @no_type_check functions #3572
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
3412db8
4468762
9544646
4a85ce4
833bf10
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -453,6 +453,7 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { | |
| let mut flags = FuncFlags { | ||
| is_staticmethod: is_dunder_new, | ||
| is_classmethod: is_dunder_init_subclass, | ||
| has_no_type_check: false, | ||
| is_async: def.is_async, | ||
| placeholder_body_kind, | ||
| is_return_inferred, | ||
|
|
@@ -568,113 +569,117 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { | |
| let ret = self | ||
| .get(&Key::ReturnType(ShortIdentifier::new(&stmt.name))) | ||
| .arc_clone_ty(); | ||
| // `stmt.returns` is always set to None because the binding step calls `mem::take` on it | ||
| let has_return_annotation = self.bindings().function_has_return_annotation(&stmt.name); | ||
| if !has_return_annotation { | ||
| self.error( | ||
| errors, | ||
| stmt.name.range(), | ||
| ErrorKind::UnannotatedReturn, | ||
| format!("`{}` is missing a return annotation", stmt.name), | ||
| ); | ||
| } | ||
| // The first parameter of a non-static method is the implicit self/cls | ||
| // parameter and does not require an annotation, regardless of its name. | ||
| // __new__ is an implicit staticmethod but still takes cls as its first parameter. | ||
| // If the first parameter is variadic (e.g. *args), self is passed inside it, | ||
| // so there is no separate implicit parameter to skip. | ||
| let is_dunder_new = def.defining_cls.is_some() && stmt.name.id == dunder::NEW; | ||
| let has_implicit_self_or_cls_param = | ||
| def.defining_cls.is_some() && (!def.metadata.flags.is_staticmethod || is_dunder_new); | ||
| for (i, p) in stmt.parameters.iter().enumerate() { | ||
| // Skip first param if it's implicit self/cls and not variadic | ||
| if i == 0 && has_implicit_self_or_cls_param && !p.is_variadic() { | ||
| continue; | ||
| } | ||
| if p.annotation().is_none() { | ||
| let name = p.name().as_str(); | ||
| let contains_self_type = |ty: &Type| ty.any(|t| matches!(t, Type::SelfType(_))); | ||
|
|
||
| if !def.metadata.flags.has_no_type_check { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Copilot is right, gating the entire block of code behind the flag means that a lot of other things are skipped, which is not the scope of the original task. I'd prefer for this to be saved in a variable, and checked before emitting each error, like in the code snippet I gave as an example. |
||
| // `stmt.returns` is always set to None because the binding step calls `mem::take` on it | ||
| let has_return_annotation = self.bindings().function_has_return_annotation(&stmt.name); | ||
| if !has_return_annotation { | ||
| self.error( | ||
| errors, | ||
| p.name().range(), | ||
| ErrorKind::ImplicitAnyParameter, | ||
| format!( | ||
| "`{}` is missing an annotation for parameter `{name}`", | ||
| stmt.name | ||
| ), | ||
| stmt.name.range(), | ||
| ErrorKind::UnannotatedReturn, | ||
| format!("`{}` is missing a return annotation", stmt.name), | ||
| ); | ||
| } | ||
| } | ||
| // Only validate TypeGuard/TypeIs functions when they have an explicit return annotation. | ||
| // Functions that return a TypeGuard value without an explicit annotation should not be | ||
| // treated as TypeGuard functions. | ||
| if has_return_annotation { | ||
| if matches!(&ret, Type::TypeGuard(_) | Type::TypeIs(_)) { | ||
| self.validate_type_guard_positional_argument_count( | ||
| &def.params, | ||
| def.id_range(), | ||
| &def.defining_cls, | ||
| def.metadata.flags.is_staticmethod, | ||
| errors, | ||
| ); | ||
|
|
||
| // The first parameter of a non-static method is the implicit self/cls | ||
| // parameter and does not require an annotation, regardless of its name. | ||
| // __new__ is an implicit staticmethod but still takes cls as its first parameter. | ||
| // If the first parameter is variadic (e.g. *args), self is passed inside it, | ||
| // so there is no separate implicit parameter to skip. | ||
| for (i, p) in stmt.parameters.iter().enumerate() { | ||
| // Skip first param if it's implicit self/cls and not variadic | ||
| if i == 0 && has_implicit_self_or_cls_param && !p.is_variadic() { | ||
| continue; | ||
| } | ||
| if p.annotation().is_none() { | ||
| let name = p.name().as_str(); | ||
| self.error( | ||
| errors, | ||
| p.name().range(), | ||
| ErrorKind::ImplicitAnyParameter, | ||
| format!( | ||
| "`{}` is missing an annotation for parameter `{name}`", | ||
| stmt.name | ||
| ), | ||
| ); | ||
| } | ||
| } | ||
|
Comment on lines
+575
to
611
|
||
|
|
||
| if let Type::TypeIs(ty_narrow) = &ret { | ||
| self.validate_type_is_type_narrowing( | ||
| &def.params, | ||
| stmt, | ||
| &def.defining_cls, | ||
| def.metadata.flags.is_staticmethod, | ||
| ty_narrow, | ||
| errors, | ||
| ); | ||
| // When self/cls has an explicit TypeVar annotation, using Self anywhere in the signature | ||
| // (return type or other parameters) is invalid because the TypeVar and Self create | ||
| // conflicting type parameterization. | ||
| // For classmethods, the annotation is `type[TFoo]`, so we also unwrap `Type::Type(...)`. | ||
| if has_implicit_self_or_cls_param | ||
| && !def.metadata.flags.is_staticmethod | ||
| && let Some(first_param) = def.params.first() | ||
| && { | ||
| let ty = first_param.as_type(); | ||
| ty.is_explicit_type_variable() | ||
| || matches!(ty, Type::Type(inner) if inner.is_explicit_type_variable()) | ||
| } | ||
| { | ||
| let signature_contains_self = contains_self_type(&ret) | ||
| || def | ||
| .params | ||
| .iter() | ||
| .skip(1) | ||
| .any(|p| contains_self_type(p.as_type())); | ||
| if signature_contains_self { | ||
| self.error( | ||
| errors, | ||
| stmt.name.range, | ||
| ErrorKind::InvalidAnnotation, | ||
| format!( | ||
| "`Self` cannot be used when `{}` has an explicit TypeVar annotation", | ||
| first_param.name().map_or("self", |n| n.as_str()) | ||
| ), | ||
| ); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| let contains_self_type = |ty: &Type| ty.any(|t| matches!(t, Type::SelfType(_))); | ||
| // Only validate TypeGuard/TypeIs functions when they have an explicit return annotation. | ||
| // Functions that return a TypeGuard value without an explicit annotation should not be | ||
| // treated as TypeGuard functions. | ||
| if has_return_annotation { | ||
| if matches!(&ret, Type::TypeGuard(_) | Type::TypeIs(_)) { | ||
| self.validate_type_guard_positional_argument_count( | ||
| &def.params, | ||
| def.id_range(), | ||
| &def.defining_cls, | ||
| def.metadata.flags.is_staticmethod, | ||
| errors, | ||
| ); | ||
| } | ||
|
|
||
| if def.metadata.flags.is_staticmethod && stmt.name.as_str() != dunder::NEW { | ||
| // For static methods, the use of `Self` is not allowed. | ||
| let signature_contains_self = contains_self_type(&ret) | ||
| || def.params.iter().any(|p| contains_self_type(p.as_type())); | ||
| if signature_contains_self { | ||
| self.error( | ||
| errors, | ||
| stmt.name.range, | ||
| ErrorKind::InvalidAnnotation, | ||
| "`Self` cannot be used in a static method".to_owned(), | ||
| ); | ||
| if let Type::TypeIs(ty_narrow) = &ret { | ||
| self.validate_type_is_type_narrowing( | ||
| &def.params, | ||
| stmt, | ||
| &def.defining_cls, | ||
| def.metadata.flags.is_staticmethod, | ||
| ty_narrow, | ||
| errors, | ||
| ); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // When self/cls has an explicit TypeVar annotation, using Self anywhere in the signature | ||
| // (return type or other parameters) is invalid because the TypeVar and Self create | ||
| // conflicting type parameterization. | ||
| // For classmethods, the annotation is `type[TFoo]`, so we also unwrap `Type::Type(...)`. | ||
| if has_implicit_self_or_cls_param | ||
| && !def.metadata.flags.is_staticmethod | ||
| && let Some(first_param) = def.params.first() | ||
| && { | ||
| let ty = first_param.as_type(); | ||
| ty.is_explicit_type_variable() | ||
| || matches!(ty, Type::Type(inner) if inner.is_explicit_type_variable()) | ||
| } | ||
| { | ||
| let signature_contains_self = contains_self_type(&ret) | ||
| || def | ||
| .params | ||
| .iter() | ||
| .skip(1) | ||
| .any(|p| contains_self_type(p.as_type())); | ||
| if signature_contains_self { | ||
| self.error( | ||
| errors, | ||
| stmt.name.range, | ||
| ErrorKind::InvalidAnnotation, | ||
| format!( | ||
| "`Self` cannot be used when `{}` has an explicit TypeVar annotation", | ||
| first_param.name().map_or("self", |n| n.as_str()) | ||
| ), | ||
| ); | ||
| if def.metadata.flags.is_staticmethod && stmt.name.as_str() != dunder::NEW { | ||
| // For static methods, the use of `Self` is not allowed. | ||
| let signature_contains_self = contains_self_type(&ret) | ||
| || def.params.iter().any(|p| contains_self_type(p.as_type())); | ||
| if signature_contains_self { | ||
| self.error( | ||
| errors, | ||
| stmt.name.range, | ||
| ErrorKind::InvalidAnnotation, | ||
| "`Self` cannot be used in a static method".to_owned(), | ||
| ); | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -736,7 +741,14 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { | |
| .forall(tparams); | ||
| ty = self.move_return_tparams_of_type(ty); | ||
| for (x, range) in def.decorators.iter().rev() { | ||
| ty = self.apply_function_decorator(x.clone(), ty, &def.metadata, *range, errors); | ||
| ty = self.apply_function_decorator( | ||
| x.clone(), | ||
| ty, | ||
| &def.metadata, | ||
| &stmt.name, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is an unrelated change |
||
| *range, | ||
| errors, | ||
| ); | ||
| } | ||
| Arc::new(ty) | ||
| } | ||
|
|
@@ -753,6 +765,15 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { | |
| return Some(SpecialDecorator::PropertyDeleter(&decorator.ty)); | ||
| } | ||
| match decorator.ty.callee_kind() { | ||
| Some(CalleeKind::Function(FunctionKind::Def(func_id))) | ||
| if func_id.name.as_str() == "no_type_check" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Probably should add a FunctionKind for this instead of hardcoded strings |
||
| && matches!( | ||
| func_id.module.name().as_str(), | ||
| "typing" | "typing_extensions" | ||
| ) => | ||
| { | ||
| Some(SpecialDecorator::NoTypeCheck) | ||
| } | ||
| Some(CalleeKind::Function(FunctionKind::Overload)) => Some(SpecialDecorator::Overload), | ||
| Some(CalleeKind::Class(ClassKind::StaticMethod(name))) => { | ||
| Some(SpecialDecorator::StaticMethod(name)) | ||
|
|
@@ -844,6 +865,10 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { | |
| flags.has_final_decoration = true; | ||
| true | ||
| } | ||
| SpecialDecorator::NoTypeCheck => { | ||
| flags.has_no_type_check = true; | ||
| true | ||
| } | ||
| SpecialDecorator::Deprecated(deprecation) => { | ||
| flags.deprecation = Some((**deprecation).clone()); | ||
| true | ||
|
|
@@ -1596,11 +1621,59 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { | |
| } | ||
| } | ||
|
|
||
| fn decorator_missing_injected_parameter_message( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is an unrelated change |
||
| &self, | ||
| decorator: &Type, | ||
| decoratee_name: &Identifier, | ||
| decoratee: &Type, | ||
| ) -> Option<String> { | ||
| let decorator_name = decorator | ||
| .visit_toplevel_func_metadata(&|meta| Some(meta.kind.function_name().to_string()))?; | ||
| let expected_decoratee = decorator.callable_first_param(self.heap)?; | ||
| let expected_signature = expected_decoratee | ||
| .callable_signatures() | ||
| .into_iter() | ||
| .find(|signature| { | ||
| matches!(&signature.params, Params::ParamSpec(prefix, _) if !prefix.is_empty()) | ||
| })?; | ||
|
|
||
| let actual_signature = match decoratee { | ||
| Type::Function(func) => &func.signature, | ||
| Type::Callable(callable) => callable.as_ref(), | ||
| _ => return None, | ||
| }; | ||
|
|
||
| let Params::ParamSpec(prefix, _) = &expected_signature.params else { | ||
| return None; | ||
| }; | ||
| let Params::List(actual_params) = &actual_signature.params else { | ||
| return None; | ||
| }; | ||
| if actual_params.len() + 1 != prefix.len() { | ||
| return None; | ||
| } | ||
|
|
||
| let missing = prefix.get(actual_params.len())?; | ||
| let missing_ty = match missing { | ||
| PrefixParam::PosOnly(_, ty, Required::Required) | ||
| | PrefixParam::Pos(_, ty, Required::Required) => ty, | ||
| PrefixParam::PosOnly(_, _, Required::Optional(_)) | ||
| | PrefixParam::Pos(_, _, Required::Optional(_)) => return None, | ||
| }; | ||
| Some(format!( | ||
| "Function `{}` is missing parameter of type `{}` injected by decorator `{}`", | ||
| decoratee_name.as_str(), | ||
| self.for_display(missing_ty.clone()), | ||
| decorator_name, | ||
| )) | ||
| } | ||
|
|
||
|
Comment on lines
+1624
to
+1670
|
||
| fn apply_function_decorator( | ||
| &self, | ||
| decorator: Type, | ||
| decoratee: Type, | ||
| metadata: &FuncMetadata, | ||
| decoratee_name: &Identifier, | ||
| range: TextRange, | ||
| errors: &ErrorCollector, | ||
| ) -> Type { | ||
|
|
@@ -1610,6 +1683,7 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { | |
| { | ||
| return decoratee; | ||
| } | ||
| let decorator_for_message = decorator.clone(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This and below are all unrelated changes |
||
| let application = self.prepare_decorator_application(decorator, decoratee, range, errors); | ||
| // Run a decorator call, buffering errors so we can decide between the primary | ||
| // and Self-rewritten fallback without double-reporting. | ||
|
|
@@ -1638,6 +1712,17 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { | |
| && fallback_errors.is_empty() | ||
| { | ||
| fallback_return | ||
| } else if let Some(message) = self.decorator_missing_injected_parameter_message( | ||
| &decorator_for_message, | ||
| decoratee_name, | ||
| &application.decoratee_arg, | ||
| ) { | ||
| self.error( | ||
| errors, | ||
| decoratee_name.range(), | ||
| ErrorKind::InvalidDecorator, | ||
| message, | ||
| ) | ||
| } else { | ||
| errors.extend(primary_errors); | ||
| primary_return | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -419,6 +419,23 @@ g(f) | |
| "#, | ||
| ); | ||
|
|
||
| testcase!( | ||
| test_decorator_missing_injected_parameter, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is completely unrelated |
||
| r#" | ||
| from typing import Callable, Concatenate | ||
|
|
||
| def with_current_tenant_id[T, **P, R]( | ||
| view: Callable[Concatenate[T, str, P], R], | ||
| ) -> Callable[Concatenate[T, P], R]: | ||
| ... | ||
|
|
||
| class Foo: | ||
| @with_current_tenant_id | ||
| def get(self) -> int: # E: Function `get` is missing parameter of type `str` injected by decorator `with_current_tenant_id` | ||
| return 0 | ||
| "#, | ||
| ); | ||
|
|
||
| testcase!( | ||
| bug = "This error message is confusing, I think we need to be clearer when we are printing the *type* of an argument", | ||
| test_decorator_error_message, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
maybe easier to just call it
no_type_check