From 19f475ae1f36c387231ff3a00bff7236215bc913 Mon Sep 17 00:00:00 2001 From: Justin Prieto Date: Wed, 12 Jul 2023 22:50:00 -0400 Subject: [PATCH] [`flake8-pyi`] Implement PYI036 (#5668) ## Summary Implements PYI036 from `flake8-pyi`. See [original code](https://github.com/PyCQA/flake8-pyi/blob/main/pyi.py#L1585) ## Test Plan - Updated snapshots - Checked against manual runs of flake8 ref: #848 --- .../test/fixtures/flake8_pyi/PYI036.py | 75 ++++ .../test/fixtures/flake8_pyi/PYI036.pyi | 75 ++++ crates/ruff/src/checkers/ast/mod.rs | 8 + crates/ruff/src/codes.rs | 1 + crates/ruff/src/rules/flake8_pyi/mod.rs | 2 + .../flake8_pyi/rules/exit_annotations.rs | 351 ++++++++++++++++++ crates/ruff/src/rules/flake8_pyi/rules/mod.rs | 2 + ...__flake8_pyi__tests__PYI036_PYI036.py.snap | 4 + ..._flake8_pyi__tests__PYI036_PYI036.pyi.snap | 171 +++++++++ ruff.schema.json | 1 + 10 files changed, 690 insertions(+) create mode 100644 crates/ruff/resources/test/fixtures/flake8_pyi/PYI036.py create mode 100644 crates/ruff/resources/test/fixtures/flake8_pyi/PYI036.pyi create mode 100644 crates/ruff/src/rules/flake8_pyi/rules/exit_annotations.rs create mode 100644 crates/ruff/src/rules/flake8_pyi/snapshots/ruff__rules__flake8_pyi__tests__PYI036_PYI036.py.snap create mode 100644 crates/ruff/src/rules/flake8_pyi/snapshots/ruff__rules__flake8_pyi__tests__PYI036_PYI036.pyi.snap diff --git a/crates/ruff/resources/test/fixtures/flake8_pyi/PYI036.py b/crates/ruff/resources/test/fixtures/flake8_pyi/PYI036.py new file mode 100644 index 0000000000..57f71dc39e --- /dev/null +++ b/crates/ruff/resources/test/fixtures/flake8_pyi/PYI036.py @@ -0,0 +1,75 @@ +import builtins +import types +import typing +from collections.abc import Awaitable +from types import TracebackType +from typing import Any, Type + +import _typeshed +import typing_extensions +from _typeshed import Unused + +class GoodOne: + def __exit__(self, *args: object) -> None: ... + async def __aexit__(self, *args) -> str: ... + +class GoodTwo: + def __exit__(self, typ: type[builtins.BaseException] | None, *args: builtins.object) -> bool | None: ... + async def __aexit__(self, /, typ: Type[BaseException] | None, *args: object, **kwargs) -> bool: ... + +class GoodThree: + def __exit__(self, __typ: typing.Type[BaseException] | None, exc: BaseException | None, *args: object) -> None: ... + async def __aexit__(self, typ: typing_extensions.Type[BaseException] | None, __exc: BaseException | None, *args: object) -> None: ... + +class GoodFour: + def __exit__(self, typ: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None) -> None: ... + async def __aexit__(self, typ: type[BaseException] | None, exc: BaseException | None, tb: types.TracebackType | None, *args: list[None]) -> None: ... + +class GoodFive: + def __exit__(self, typ: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None, weird_extra_arg: int = ..., *args: int, **kwargs: str) -> None: ... + async def __aexit__(self, typ: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None) -> Awaitable[None]: ... + +class GoodSix: + def __exit__(self, typ: object, exc: builtins.object, tb: object) -> None: ... + async def __aexit__(self, typ: object, exc: object, tb: builtins.object) -> None: ... + +class GoodSeven: + def __exit__(self, *args: Unused) -> bool: ... + async def __aexit__(self, typ: Type[BaseException] | None, *args: _typeshed.Unused) -> Awaitable[None]: ... + +class GoodEight: + def __exit__(self, __typ: typing.Type[BaseException] | None, exc: BaseException | None, *args: _typeshed.Unused) -> bool: ... + async def __aexit__(self, typ: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None, weird_extra_arg: int = ..., *args: Unused, **kwargs: Unused) -> Awaitable[None]: ... + +class GoodNine: + def __exit__(self, __typ: typing.Union[typing.Type[BaseException] , None], exc: typing.Union[BaseException , None], *args: _typeshed.Unused) -> bool: ... + async def __aexit__(self, typ: typing.Union[typing.Type[BaseException], None], exc: typing.Union[BaseException , None], tb: typing.Union[TracebackType , None], weird_extra_arg: int = ..., *args: Unused, **kwargs: Unused) -> Awaitable[None]: ... + +class GoodTen: + def __exit__(self, __typ: typing.Optional[typing.Type[BaseException]], exc: typing.Optional[BaseException], *args: _typeshed.Unused) -> bool: ... + async def __aexit__(self, typ: typing.Optional[typing.Type[BaseException]], exc: typing.Optional[BaseException], tb: typing.Optional[TracebackType], weird_extra_arg: int = ..., *args: Unused, **kwargs: Unused) -> Awaitable[None]: ... + + +class BadOne: + def __exit__(self, *args: Any) -> None: ... # PYI036: Bad star-args annotation + async def __aexit__(self) -> None: ... # PYI036: Missing args + +class BadTwo: + def __exit__(self, typ, exc, tb, weird_extra_arg) -> None: ... # PYI036: Extra arg must have default + async def __aexit__(self, typ, exc, tb, *, weird_extra_arg) -> None: ...# PYI036: Extra arg must have default + +class BadThree: + def __exit__(self, typ: type[BaseException], exc: BaseException | None, tb: TracebackType | None) -> None: ... # PYI036: First arg has bad annotation + async def __aexit__(self, __typ: type[BaseException] | None, __exc: BaseException, __tb: TracebackType) -> bool | None: ... # PYI036: Second arg has bad annotation + +class BadFour: + def __exit__(self, typ: typing.Optional[type[BaseException]], exc: typing.Union[BaseException, None], tb: TracebackType) -> None: ... # PYI036: Third arg has bad annotation + async def __aexit__(self, __typ: type[BaseException] | None, __exc: BaseException | None, __tb: typing.Union[TracebackType, None, int]) -> bool | None: ... # PYI036: Third arg has bad annotation + +class BadFive: + def __exit__(self, typ: BaseException | None, *args: list[str]) -> bool: ... # PYI036: Bad star-args annotation + async def __aexit__(self, /, typ: type[BaseException] | None, *args: Any) -> Awaitable[None]: ... # PYI036: Bad star-args annotation + +class BadSix: + def __exit__(self, typ, exc, tb, weird_extra_arg, extra_arg2 = None) -> None: ... # PYI036: Extra arg must have default + async def __aexit__(self, typ, exc, tb, *, weird_extra_arg) -> None: ... # PYI036: kwargs must have default diff --git a/crates/ruff/resources/test/fixtures/flake8_pyi/PYI036.pyi b/crates/ruff/resources/test/fixtures/flake8_pyi/PYI036.pyi new file mode 100644 index 0000000000..a49791aa1b --- /dev/null +++ b/crates/ruff/resources/test/fixtures/flake8_pyi/PYI036.pyi @@ -0,0 +1,75 @@ +import builtins +import types +import typing +from collections.abc import Awaitable +from types import TracebackType +from typing import Any, Type + +import _typeshed +import typing_extensions +from _typeshed import Unused + +class GoodOne: + def __exit__(self, *args: object) -> None: ... + async def __aexit__(self, *args) -> str: ... + +class GoodTwo: + def __exit__(self, typ: type[builtins.BaseException] | None, *args: builtins.object) -> bool | None: ... + async def __aexit__(self, /, typ: Type[BaseException] | None, *args: object, **kwargs) -> bool: ... + +class GoodThree: + def __exit__(self, __typ: typing.Type[BaseException] | None, exc: BaseException | None, *args: object) -> None: ... + async def __aexit__(self, typ: typing_extensions.Type[BaseException] | None, __exc: BaseException | None, *args: object) -> None: ... + +class GoodFour: + def __exit__(self, typ: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None) -> None: ... + async def __aexit__(self, typ: type[BaseException] | None, exc: BaseException | None, tb: types.TracebackType | None, *args: list[None]) -> None: ... + +class GoodFive: + def __exit__(self, typ: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None, weird_extra_arg: int = ..., *args: int, **kwargs: str) -> None: ... + async def __aexit__(self, typ: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None) -> Awaitable[None]: ... + +class GoodSix: + def __exit__(self, typ: object, exc: builtins.object, tb: object) -> None: ... + async def __aexit__(self, typ: object, exc: object, tb: builtins.object) -> None: ... + +class GoodSeven: + def __exit__(self, *args: Unused) -> bool: ... + async def __aexit__(self, typ: Type[BaseException] | None, *args: _typeshed.Unused) -> Awaitable[None]: ... + +class GoodEight: + def __exit__(self, __typ: typing.Type[BaseException] | None, exc: BaseException | None, *args: _typeshed.Unused) -> bool: ... + async def __aexit__(self, typ: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None, weird_extra_arg: int = ..., *args: Unused, **kwargs: Unused) -> Awaitable[None]: ... + +class GoodNine: + def __exit__(self, __typ: typing.Union[typing.Type[BaseException] , None], exc: typing.Union[BaseException , None], *args: _typeshed.Unused) -> bool: ... + async def __aexit__(self, typ: typing.Union[typing.Type[BaseException], None], exc: typing.Union[BaseException , None], tb: typing.Union[TracebackType , None], weird_extra_arg: int = ..., *args: Unused, **kwargs: Unused) -> Awaitable[None]: ... + +class GoodTen: + def __exit__(self, __typ: typing.Optional[typing.Type[BaseException]], exc: typing.Optional[BaseException], *args: _typeshed.Unused) -> bool: ... + async def __aexit__(self, typ: typing.Optional[typing.Type[BaseException]], exc: typing.Optional[BaseException], tb: typing.Optional[TracebackType], weird_extra_arg: int = ..., *args: Unused, **kwargs: Unused) -> Awaitable[None]: ... + + +class BadOne: + def __exit__(self, *args: Any) -> None: ... # PYI036: Bad star-args annotation + async def __aexit__(self) -> None: ... # PYI036: Missing args + +class BadTwo: + def __exit__(self, typ, exc, tb, weird_extra_arg) -> None: ... # PYI036: Extra arg must have default + async def __aexit__(self, typ, exc, tb, *, weird_extra_arg1, weird_extra_arg2) -> None: ...# PYI036: kwargs must have default + +class BadThree: + def __exit__(self, typ: type[BaseException], exc: BaseException | None, tb: TracebackType | None) -> None: ... # PYI036: First arg has bad annotation + async def __aexit__(self, __typ: type[BaseException] | None, __exc: BaseException, __tb: TracebackType) -> bool | None: ... # PYI036: Second arg has bad annotation + +class BadFour: + def __exit__(self, typ: typing.Optional[type[BaseException]], exc: typing.Union[BaseException, None], tb: TracebackType) -> None: ... # PYI036: Third arg has bad annotation + async def __aexit__(self, __typ: type[BaseException] | None, __exc: BaseException | None, __tb: typing.Union[TracebackType, None, int]) -> bool | None: ... # PYI036: Third arg has bad annotation + +class BadFive: + def __exit__(self, typ: BaseException | None, *args: list[str]) -> bool: ... # PYI036: Bad star-args annotation + async def __aexit__(self, /, typ: type[BaseException] | None, *args: Any) -> Awaitable[None]: ... # PYI036: Bad star-args annotation + +class BadSix: + def __exit__(self, typ, exc, tb, weird_extra_arg, extra_arg2 = None) -> None: ... # PYI036: Extra arg must have default + async def __aexit__(self, typ, exc, tb, *, weird_extra_arg) -> None: ... # PYI036: kwargs must have default diff --git a/crates/ruff/src/checkers/ast/mod.rs b/crates/ruff/src/checkers/ast/mod.rs index 2a92755808..930bcc35c7 100644 --- a/crates/ruff/src/checkers/ast/mod.rs +++ b/crates/ruff/src/checkers/ast/mod.rs @@ -435,6 +435,14 @@ where if self.enabled(Rule::NoReturnArgumentAnnotationInStub) { flake8_pyi::rules::no_return_argument_annotation(self, args); } + if self.enabled(Rule::BadExitAnnotation) { + flake8_pyi::rules::bad_exit_annotation( + self, + stmt.is_async_function_def_stmt(), + name, + args, + ); + } } if self.enabled(Rule::DunderFunctionName) { if let Some(diagnostic) = pep8_naming::rules::dunder_function_name( diff --git a/crates/ruff/src/codes.rs b/crates/ruff/src/codes.rs index 27d20325d7..b8b95250ad 100644 --- a/crates/ruff/src/codes.rs +++ b/crates/ruff/src/codes.rs @@ -638,6 +638,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (Flake8Pyi, "033") => (RuleGroup::Unspecified, rules::flake8_pyi::rules::TypeCommentInStub), (Flake8Pyi, "034") => (RuleGroup::Unspecified, rules::flake8_pyi::rules::NonSelfReturnType), (Flake8Pyi, "035") => (RuleGroup::Unspecified, rules::flake8_pyi::rules::UnassignedSpecialVariableInStub), + (Flake8Pyi, "036") => (RuleGroup::Unspecified, rules::flake8_pyi::rules::BadExitAnnotation), (Flake8Pyi, "042") => (RuleGroup::Unspecified, rules::flake8_pyi::rules::SnakeCaseTypeAlias), (Flake8Pyi, "043") => (RuleGroup::Unspecified, rules::flake8_pyi::rules::TSuffixedTypeAlias), (Flake8Pyi, "044") => (RuleGroup::Unspecified, rules::flake8_pyi::rules::FutureAnnotationsInStub), diff --git a/crates/ruff/src/rules/flake8_pyi/mod.rs b/crates/ruff/src/rules/flake8_pyi/mod.rs index 8dd20e1f11..6b8e6e4264 100644 --- a/crates/ruff/src/rules/flake8_pyi/mod.rs +++ b/crates/ruff/src/rules/flake8_pyi/mod.rs @@ -19,6 +19,8 @@ mod tests { #[test_case(Rule::ArgumentDefaultInStub, Path::new("PYI014.pyi"))] #[test_case(Rule::AssignmentDefaultInStub, Path::new("PYI015.py"))] #[test_case(Rule::AssignmentDefaultInStub, Path::new("PYI015.pyi"))] + #[test_case(Rule::BadExitAnnotation, Path::new("PYI036.py"))] + #[test_case(Rule::BadExitAnnotation, Path::new("PYI036.pyi"))] #[test_case(Rule::BadVersionInfoComparison, Path::new("PYI006.py"))] #[test_case(Rule::BadVersionInfoComparison, Path::new("PYI006.pyi"))] #[test_case(Rule::CollectionsNamedTuple, Path::new("PYI024.py"))] diff --git a/crates/ruff/src/rules/flake8_pyi/rules/exit_annotations.rs b/crates/ruff/src/rules/flake8_pyi/rules/exit_annotations.rs new file mode 100644 index 0000000000..69887d75e3 --- /dev/null +++ b/crates/ruff/src/rules/flake8_pyi/rules/exit_annotations.rs @@ -0,0 +1,351 @@ +use std::fmt::{Display, Formatter}; + +use rustpython_parser::ast::{ + ArgWithDefault, Arguments, Expr, ExprBinOp, ExprSubscript, ExprTuple, Identifier, Operator, + Ranged, +}; +use smallvec::SmallVec; + +use ruff_diagnostics::{AutofixKind, Diagnostic, Edit, Fix, Violation}; +use ruff_macros::{derive_message_formats, violation}; +use ruff_python_ast::helpers::is_const_none; +use ruff_python_semantic::SemanticModel; + +use crate::checkers::ast::Checker; +use crate::registry::AsRule; + +/// ## What it does +/// Checks for incorrect function signatures on `__exit__` and `__aexit__` +/// methods. +/// +/// ## Why is this bad? +/// Improperly-annotated `__exit__` and `__aexit__` methods can cause +/// unexpected behavior when interacting with type checkers. +/// +/// ## Example +/// ```python +/// class Foo: +/// def __exit__(self, typ, exc, tb, extra_arg) -> None: +/// ... +/// ``` +/// +/// Use instead: +/// ```python +/// class Foo: +/// def __exit__( +/// self, +/// typ: type[BaseException] | None, +/// exc: BaseException | None, +/// tb: TracebackType | None, +/// extra_arg: int = 0, +/// ) -> None: +/// ... +/// ``` +#[violation] +pub struct BadExitAnnotation { + func_kind: FuncKind, + error_kind: ErrorKind, +} + +impl Violation for BadExitAnnotation { + const AUTOFIX: AutofixKind = AutofixKind::Sometimes; + + #[derive_message_formats] + fn message(&self) -> String { + let method_name = self.func_kind.to_string(); + match self.error_kind { + ErrorKind::StarArgsNotAnnotated => format!("Star-args in `{method_name}` should be annotated with `object`"), + ErrorKind::MissingArgs => format!("If there are no star-args, `{method_name}` should have at least 3 non-keyword-only args (excluding `self`)"), + ErrorKind::ArgsAfterFirstFourMustHaveDefault => format!("All arguments after the first four in `{method_name}` must have a default value"), + ErrorKind::AllKwargsMustHaveDefault => format!("All keyword-only arguments in `{method_name}` must have a default value"), + ErrorKind::FirstArgBadAnnotation => format!("The first argument in `{method_name}` should be annotated with `object` or `type[BaseException] | None`"), + ErrorKind::SecondArgBadAnnotation => format!("The second argument in `{method_name}` should be annotated with `object` or `BaseException | None`"), + ErrorKind::ThirdArgBadAnnotation => format!("The third argument in `{method_name}` should be annotated with `object` or `types.TracebackType | None`"), + } + } + + fn autofix_title(&self) -> Option { + if matches!(self.error_kind, ErrorKind::StarArgsNotAnnotated) { + Some("Annotate star-args with `object`".to_string()) + } else { + None + } + } +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +enum FuncKind { + Sync, + Async, +} + +impl Display for FuncKind { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + FuncKind::Sync => write!(f, "__exit__"), + FuncKind::Async => write!(f, "__aexit__"), + } + } +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +enum ErrorKind { + StarArgsNotAnnotated, + MissingArgs, + FirstArgBadAnnotation, + SecondArgBadAnnotation, + ThirdArgBadAnnotation, + ArgsAfterFirstFourMustHaveDefault, + AllKwargsMustHaveDefault, +} + +/// PYI036 +pub(crate) fn bad_exit_annotation( + checker: &mut Checker, + is_async: bool, + name: &Identifier, + args: &Arguments, +) { + let func_kind = match name.as_str() { + "__exit__" if !is_async => FuncKind::Sync, + "__aexit__" if is_async => FuncKind::Async, + _ => return, + }; + + let positional_args = args + .args + .iter() + .chain(args.posonlyargs.iter()) + .collect::>(); + + // If there are less than three positional arguments, at least one of them must be a star-arg, + // and it must be annotated with `object`. + if positional_args.len() < 4 { + check_short_args_list(checker, args, func_kind); + } + + // Every positional argument (beyond the first four) must have a default. + for arg_with_default in positional_args + .iter() + .skip(4) + .filter(|arg_with_default| arg_with_default.default.is_none()) + { + checker.diagnostics.push(Diagnostic::new( + BadExitAnnotation { + func_kind, + error_kind: ErrorKind::ArgsAfterFirstFourMustHaveDefault, + }, + arg_with_default.range(), + )); + } + + // ...as should all keyword-only arguments. + for arg_with_default in args.kwonlyargs.iter().filter(|arg| arg.default.is_none()) { + checker.diagnostics.push(Diagnostic::new( + BadExitAnnotation { + func_kind, + error_kind: ErrorKind::AllKwargsMustHaveDefault, + }, + arg_with_default.range(), + )); + } + + check_positional_args(checker, &positional_args, func_kind); +} + +/// Determine whether a "short" argument list (i.e., an argument list with less than four elements) +/// contains a star-args argument annotated with `object`. If not, report an error. +fn check_short_args_list(checker: &mut Checker, args: &Arguments, func_kind: FuncKind) { + if let Some(varargs) = &args.vararg { + if let Some(annotation) = varargs + .annotation + .as_ref() + .filter(|ann| !is_object_or_unused(ann, checker.semantic())) + { + let mut diagnostic = Diagnostic::new( + BadExitAnnotation { + func_kind, + error_kind: ErrorKind::StarArgsNotAnnotated, + }, + annotation.range(), + ); + + if checker.patch(diagnostic.kind.rule()) { + if checker.semantic().is_builtin("object") { + diagnostic.set_fix(Fix::automatic(Edit::range_replacement( + "object".to_string(), + annotation.range(), + ))); + } + } + + checker.diagnostics.push(diagnostic); + } + } else { + checker.diagnostics.push(Diagnostic::new( + BadExitAnnotation { + func_kind, + error_kind: ErrorKind::MissingArgs, + }, + args.range(), + )); + } +} + +/// Determines whether the positional arguments of an `__exit__` or `__aexit__` method are +/// annotated correctly. +fn check_positional_args( + checker: &mut Checker, + positional_args: &[&ArgWithDefault], + kind: FuncKind, +) { + // For each argument, define the predicate against which to check the annotation. + type AnnotationValidator = fn(&Expr, &SemanticModel) -> bool; + + let validations: [(ErrorKind, AnnotationValidator); 3] = [ + (ErrorKind::FirstArgBadAnnotation, is_base_exception_type), + (ErrorKind::SecondArgBadAnnotation, is_base_exception), + (ErrorKind::ThirdArgBadAnnotation, is_traceback_type), + ]; + + for (arg, (error_info, predicate)) in positional_args + .iter() + .skip(1) + .take(3) + .zip(validations.into_iter()) + { + let Some(annotation) = arg.def.annotation.as_ref() else { + continue; + }; + + if is_object_or_unused(annotation, checker.semantic()) { + continue; + } + + // If there's an annotation that's not `object` or `Unused`, check that the annotated type + // matches the predicate. + if non_none_annotation_element(annotation, checker.semantic()) + .map_or(false, |elem| predicate(elem, checker.semantic())) + { + continue; + } + + checker.diagnostics.push(Diagnostic::new( + BadExitAnnotation { + func_kind: kind, + error_kind: error_info, + }, + annotation.range(), + )); + } +} + +/// Return the non-`None` annotation element of a PEP 604-style union or `Optional` annotation. +fn non_none_annotation_element<'a>( + annotation: &'a Expr, + model: &SemanticModel, +) -> Option<&'a Expr> { + // E.g., `typing.Union` or `typing.Optional` + if let Expr::Subscript(ExprSubscript { value, slice, .. }) = annotation { + if model.match_typing_expr(value, "Optional") { + return if is_const_none(slice) { + None + } else { + Some(slice) + }; + } + + if !model.match_typing_expr(value, "Union") { + return None; + } + + let Expr::Tuple(ExprTuple { elts, .. }) = slice.as_ref() else { + return None; + }; + + let [left, right] = elts.as_slice() else { + return None; + }; + + return match (is_const_none(left), is_const_none(right)) { + (false, true) => Some(left), + (true, false) => Some(right), + (true, true) => None, + (false, false) => None, + }; + } + + // PEP 604-style union (e.g., `int | None`) + if let Expr::BinOp(ExprBinOp { + op: Operator::BitOr, + left, + right, + .. + }) = annotation + { + if !is_const_none(left) { + return Some(left); + } + + if !is_const_none(right) { + return Some(right); + } + + return None; + } + + None +} + +/// Return `true` if the [`Expr`] is the `object` builtin or the `_typeshed.Unused` type. +fn is_object_or_unused(expr: &Expr, model: &SemanticModel) -> bool { + model + .resolve_call_path(expr) + .as_ref() + .map_or(false, |call_path| { + matches!( + call_path.as_slice(), + ["" | "builtins", "object"] | ["_typeshed", "Unused"] + ) + }) +} + +/// Return `true` if the [`Expr`] is `BaseException`. +fn is_base_exception(expr: &Expr, model: &SemanticModel) -> bool { + model + .resolve_call_path(expr) + .as_ref() + .map_or(false, |call_path| { + matches!(call_path.as_slice(), ["" | "builtins", "BaseException"]) + }) +} + +/// Return `true` if the [`Expr`] is the `types.TracebackType` type. +fn is_traceback_type(expr: &Expr, model: &SemanticModel) -> bool { + model + .resolve_call_path(expr) + .as_ref() + .map_or(false, |call_path| { + matches!(call_path.as_slice(), ["types", "TracebackType"]) + }) +} + +/// Return `true` if the [`Expr`] is, e.g., `Type[BaseException]`. +fn is_base_exception_type(expr: &Expr, model: &SemanticModel) -> bool { + let Expr::Subscript(ExprSubscript { value, slice, .. }) = expr else { + return false; + }; + + if model.match_typing_expr(value, "Type") + || model + .resolve_call_path(value) + .as_ref() + .map_or(false, |call_path| { + matches!(call_path.as_slice(), ["" | "builtins", "type"]) + }) + { + is_base_exception(slice, model) + } else { + false + } +} diff --git a/crates/ruff/src/rules/flake8_pyi/rules/mod.rs b/crates/ruff/src/rules/flake8_pyi/rules/mod.rs index ce083d31c3..5fda64cb32 100644 --- a/crates/ruff/src/rules/flake8_pyi/rules/mod.rs +++ b/crates/ruff/src/rules/flake8_pyi/rules/mod.rs @@ -5,6 +5,7 @@ pub(crate) use complex_if_statement_in_stub::*; pub(crate) use docstring_in_stubs::*; pub(crate) use duplicate_union_member::*; pub(crate) use ellipsis_in_non_empty_class_body::*; +pub(crate) use exit_annotations::*; pub(crate) use future_annotations_in_stub::*; pub(crate) use iter_method_return_iterable::*; pub(crate) use no_return_argument_annotation::*; @@ -33,6 +34,7 @@ mod complex_if_statement_in_stub; mod docstring_in_stubs; mod duplicate_union_member; mod ellipsis_in_non_empty_class_body; +mod exit_annotations; mod future_annotations_in_stub; mod iter_method_return_iterable; mod no_return_argument_annotation; diff --git a/crates/ruff/src/rules/flake8_pyi/snapshots/ruff__rules__flake8_pyi__tests__PYI036_PYI036.py.snap b/crates/ruff/src/rules/flake8_pyi/snapshots/ruff__rules__flake8_pyi__tests__PYI036_PYI036.py.snap new file mode 100644 index 0000000000..d1aa2e9116 --- /dev/null +++ b/crates/ruff/src/rules/flake8_pyi/snapshots/ruff__rules__flake8_pyi__tests__PYI036_PYI036.py.snap @@ -0,0 +1,4 @@ +--- +source: crates/ruff/src/rules/flake8_pyi/mod.rs +--- + diff --git a/crates/ruff/src/rules/flake8_pyi/snapshots/ruff__rules__flake8_pyi__tests__PYI036_PYI036.pyi.snap b/crates/ruff/src/rules/flake8_pyi/snapshots/ruff__rules__flake8_pyi__tests__PYI036_PYI036.pyi.snap new file mode 100644 index 0000000000..2c3a0dc7b6 --- /dev/null +++ b/crates/ruff/src/rules/flake8_pyi/snapshots/ruff__rules__flake8_pyi__tests__PYI036_PYI036.pyi.snap @@ -0,0 +1,171 @@ +--- +source: crates/ruff/src/rules/flake8_pyi/mod.rs +--- +PYI036.pyi:54:31: PYI036 [*] Star-args in `__exit__` should be annotated with `object` + | +53 | class BadOne: +54 | def __exit__(self, *args: Any) -> None: ... # PYI036: Bad star-args annotation + | ^^^ PYI036 +55 | async def __aexit__(self) -> None: ... # PYI036: Missing args + | + = help: Annotate star-args with `object` + +ℹ Fix +51 51 | +52 52 | +53 53 | class BadOne: +54 |- def __exit__(self, *args: Any) -> None: ... # PYI036: Bad star-args annotation + 54 |+ def __exit__(self, *args: object) -> None: ... # PYI036: Bad star-args annotation +55 55 | async def __aexit__(self) -> None: ... # PYI036: Missing args +56 56 | +57 57 | class BadTwo: + +PYI036.pyi:55:24: PYI036 If there are no star-args, `__aexit__` should have at least 3 non-keyword-only args (excluding `self`) + | +53 | class BadOne: +54 | def __exit__(self, *args: Any) -> None: ... # PYI036: Bad star-args annotation +55 | async def __aexit__(self) -> None: ... # PYI036: Missing args + | ^^^^^^ PYI036 +56 | +57 | class BadTwo: + | + +PYI036.pyi:58:38: PYI036 All arguments after the first four in `__exit__` must have a default value + | +57 | class BadTwo: +58 | def __exit__(self, typ, exc, tb, weird_extra_arg) -> None: ... # PYI036: Extra arg must have default + | ^^^^^^^^^^^^^^^ PYI036 +59 | async def __aexit__(self, typ, exc, tb, *, weird_extra_arg1, weird_extra_arg2) -> None: ...# PYI036: kwargs must have default + | + +PYI036.pyi:59:48: PYI036 All keyword-only arguments in `__aexit__` must have a default value + | +57 | class BadTwo: +58 | def __exit__(self, typ, exc, tb, weird_extra_arg) -> None: ... # PYI036: Extra arg must have default +59 | async def __aexit__(self, typ, exc, tb, *, weird_extra_arg1, weird_extra_arg2) -> None: ...# PYI036: kwargs must have default + | ^^^^^^^^^^^^^^^^ PYI036 +60 | +61 | class BadThree: + | + +PYI036.pyi:59:66: PYI036 All keyword-only arguments in `__aexit__` must have a default value + | +57 | class BadTwo: +58 | def __exit__(self, typ, exc, tb, weird_extra_arg) -> None: ... # PYI036: Extra arg must have default +59 | async def __aexit__(self, typ, exc, tb, *, weird_extra_arg1, weird_extra_arg2) -> None: ...# PYI036: kwargs must have default + | ^^^^^^^^^^^^^^^^ PYI036 +60 | +61 | class BadThree: + | + +PYI036.pyi:62:29: PYI036 The first argument in `__exit__` should be annotated with `object` or `type[BaseException] | None` + | +61 | class BadThree: +62 | def __exit__(self, typ: type[BaseException], exc: BaseException | None, tb: TracebackType | None) -> None: ... # PYI036: First arg has bad annotation + | ^^^^^^^^^^^^^^^^^^^ PYI036 +63 | async def __aexit__(self, __typ: type[BaseException] | None, __exc: BaseException, __tb: TracebackType) -> bool | None: ... # PYI036: Second arg has bad annotation + | + +PYI036.pyi:63:73: PYI036 The second argument in `__aexit__` should be annotated with `object` or `BaseException | None` + | +61 | class BadThree: +62 | def __exit__(self, typ: type[BaseException], exc: BaseException | None, tb: TracebackType | None) -> None: ... # PYI036: First arg has bad annotation +63 | async def __aexit__(self, __typ: type[BaseException] | None, __exc: BaseException, __tb: TracebackType) -> bool | None: ... # PYI036: Second arg has bad annotation + | ^^^^^^^^^^^^^ PYI036 +64 | +65 | class BadFour: + | + +PYI036.pyi:63:94: PYI036 The third argument in `__aexit__` should be annotated with `object` or `types.TracebackType | None` + | +61 | class BadThree: +62 | def __exit__(self, typ: type[BaseException], exc: BaseException | None, tb: TracebackType | None) -> None: ... # PYI036: First arg has bad annotation +63 | async def __aexit__(self, __typ: type[BaseException] | None, __exc: BaseException, __tb: TracebackType) -> bool | None: ... # PYI036: Second arg has bad annotation + | ^^^^^^^^^^^^^ PYI036 +64 | +65 | class BadFour: + | + +PYI036.pyi:66:111: PYI036 The third argument in `__exit__` should be annotated with `object` or `types.TracebackType | None` + | +65 | class BadFour: +66 | def __exit__(self, typ: typing.Optional[type[BaseException]], exc: typing.Union[BaseException, None], tb: TracebackType) -> None: ... # PYI036: Third arg has bad annotation + | ^^^^^^^^^^^^^ PYI036 +67 | async def __aexit__(self, __typ: type[BaseException] | None, __exc: BaseException | None, __tb: typing.Union[TracebackType, None, int]) -> bool | None: ... # PYI036: Third arg has bad annotation + | + +PYI036.pyi:67:101: PYI036 The third argument in `__aexit__` should be annotated with `object` or `types.TracebackType | None` + | +65 | class BadFour: +66 | def __exit__(self, typ: typing.Optional[type[BaseException]], exc: typing.Union[BaseException, None], tb: TracebackType) -> None: ... # PYI036: Third arg has bad annotation +67 | async def __aexit__(self, __typ: type[BaseException] | None, __exc: BaseException | None, __tb: typing.Union[TracebackType, None, int]) -> bool | None: ... # PYI036: Third arg has bad annotation + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PYI036 +68 | +69 | class BadFive: + | + +PYI036.pyi:70:29: PYI036 The first argument in `__exit__` should be annotated with `object` or `type[BaseException] | None` + | +69 | class BadFive: +70 | def __exit__(self, typ: BaseException | None, *args: list[str]) -> bool: ... # PYI036: Bad star-args annotation + | ^^^^^^^^^^^^^^^^^^^^ PYI036 +71 | async def __aexit__(self, /, typ: type[BaseException] | None, *args: Any) -> Awaitable[None]: ... # PYI036: Bad star-args annotation + | + +PYI036.pyi:70:58: PYI036 [*] Star-args in `__exit__` should be annotated with `object` + | +69 | class BadFive: +70 | def __exit__(self, typ: BaseException | None, *args: list[str]) -> bool: ... # PYI036: Bad star-args annotation + | ^^^^^^^^^ PYI036 +71 | async def __aexit__(self, /, typ: type[BaseException] | None, *args: Any) -> Awaitable[None]: ... # PYI036: Bad star-args annotation + | + = help: Annotate star-args with `object` + +ℹ Fix +67 67 | async def __aexit__(self, __typ: type[BaseException] | None, __exc: BaseException | None, __tb: typing.Union[TracebackType, None, int]) -> bool | None: ... # PYI036: Third arg has bad annotation +68 68 | +69 69 | class BadFive: +70 |- def __exit__(self, typ: BaseException | None, *args: list[str]) -> bool: ... # PYI036: Bad star-args annotation + 70 |+ def __exit__(self, typ: BaseException | None, *args: object) -> bool: ... # PYI036: Bad star-args annotation +71 71 | async def __aexit__(self, /, typ: type[BaseException] | None, *args: Any) -> Awaitable[None]: ... # PYI036: Bad star-args annotation +72 72 | +73 73 | class BadSix: + +PYI036.pyi:71:74: PYI036 [*] Star-args in `__aexit__` should be annotated with `object` + | +69 | class BadFive: +70 | def __exit__(self, typ: BaseException | None, *args: list[str]) -> bool: ... # PYI036: Bad star-args annotation +71 | async def __aexit__(self, /, typ: type[BaseException] | None, *args: Any) -> Awaitable[None]: ... # PYI036: Bad star-args annotation + | ^^^ PYI036 +72 | +73 | class BadSix: + | + = help: Annotate star-args with `object` + +ℹ Fix +68 68 | +69 69 | class BadFive: +70 70 | def __exit__(self, typ: BaseException | None, *args: list[str]) -> bool: ... # PYI036: Bad star-args annotation +71 |- async def __aexit__(self, /, typ: type[BaseException] | None, *args: Any) -> Awaitable[None]: ... # PYI036: Bad star-args annotation + 71 |+ async def __aexit__(self, /, typ: type[BaseException] | None, *args: object) -> Awaitable[None]: ... # PYI036: Bad star-args annotation +72 72 | +73 73 | class BadSix: +74 74 | def __exit__(self, typ, exc, tb, weird_extra_arg, extra_arg2 = None) -> None: ... # PYI036: Extra arg must have default + +PYI036.pyi:74:38: PYI036 All arguments after the first four in `__exit__` must have a default value + | +73 | class BadSix: +74 | def __exit__(self, typ, exc, tb, weird_extra_arg, extra_arg2 = None) -> None: ... # PYI036: Extra arg must have default + | ^^^^^^^^^^^^^^^ PYI036 +75 | async def __aexit__(self, typ, exc, tb, *, weird_extra_arg) -> None: ... # PYI036: kwargs must have default + | + +PYI036.pyi:75:48: PYI036 All keyword-only arguments in `__aexit__` must have a default value + | +73 | class BadSix: +74 | def __exit__(self, typ, exc, tb, weird_extra_arg, extra_arg2 = None) -> None: ... # PYI036: Extra arg must have default +75 | async def __aexit__(self, typ, exc, tb, *, weird_extra_arg) -> None: ... # PYI036: kwargs must have default + | ^^^^^^^^^^^^^^^ PYI036 + | + + diff --git a/ruff.schema.json b/ruff.schema.json index 696a7d9cca..ac8dfa68b5 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -2355,6 +2355,7 @@ "PYI033", "PYI034", "PYI035", + "PYI036", "PYI04", "PYI042", "PYI043",