diff --git a/crates/ruff/resources/test/fixtures/flake8_annotations/annotation_presence.py b/crates/ruff/resources/test/fixtures/flake8_annotations/annotation_presence.py index d37f178bbb..7778c85072 100644 --- a/crates/ruff/resources/test/fixtures/flake8_annotations/annotation_presence.py +++ b/crates/ruff/resources/test/fixtures/flake8_annotations/annotation_presence.py @@ -1,4 +1,4 @@ -from typing import Any, Type +from typing import Annotated, Any, Optional, Type, Union from typing_extensions import override # Error @@ -95,27 +95,27 @@ class Foo: def foo(self: "Foo", a: int, *params: str, **options: Any) -> int: pass - # ANN401 + # OK @override def foo(self: "Foo", a: Any, *params: str, **options: str) -> int: pass - # ANN401 + # OK @override def foo(self: "Foo", a: int, *params: str, **options: str) -> Any: pass - # ANN401 + # OK @override def foo(self: "Foo", a: int, *params: Any, **options: Any) -> int: pass - # ANN401 + # OK @override def foo(self: "Foo", a: int, *params: Any, **options: str) -> int: pass - # ANN401 + # OK @override def foo(self: "Foo", a: int, *params: str, **options: Any) -> int: pass @@ -137,3 +137,17 @@ class Foo: # OK def f(*args: *tuple[int]) -> None: ... +def f(a: object) -> None: ... +def f(a: str | bytes) -> None: ... +def f(a: Union[str, bytes]) -> None: ... +def f(a: Optional[str]) -> None: ... +def f(a: Annotated[str, ...]) -> None: ... +def f(a: "Union[str, bytes]") -> None: ... + +# ANN401 +def f(a: Any | int) -> None: ... +def f(a: int | Any) -> None: ... +def f(a: Union[str, bytes, Any]) -> None: ... +def f(a: Optional[Any]) -> None: ... +def f(a: Annotated[Any, ...]) -> None: ... +def f(a: "Union[str, bytes, Any]") -> None: ... diff --git a/crates/ruff/src/rules/flake8_annotations/rules/definition.rs b/crates/ruff/src/rules/flake8_annotations/rules/definition.rs index 47232b9e04..f672afd9b7 100644 --- a/crates/ruff/src/rules/flake8_annotations/rules/definition.rs +++ b/crates/ruff/src/rules/flake8_annotations/rules/definition.rs @@ -1,4 +1,4 @@ -use rustpython_parser::ast::{ArgWithDefault, Expr, Ranged, Stmt}; +use rustpython_parser::ast::{self, ArgWithDefault, Constant, Expr, Ranged, Stmt}; use ruff_diagnostics::{AlwaysAutofixableViolation, Diagnostic, Fix, Violation}; use ruff_macros::{derive_message_formats, violation}; @@ -6,12 +6,14 @@ use ruff_python_ast::cast; use ruff_python_ast::helpers::ReturnStatementVisitor; use ruff_python_ast::identifier::Identifier; use ruff_python_ast::statement_visitor::StatementVisitor; +use ruff_python_ast::typing::parse_type_annotation; use ruff_python_semantic::analyze::visibility; -use ruff_python_semantic::{Definition, Member, MemberKind, SemanticModel}; +use ruff_python_semantic::{Definition, Member, MemberKind}; use ruff_python_stdlib::typing::simple_magic_return_type; use crate::checkers::ast::Checker; use crate::registry::{AsRule, Rule}; +use crate::rules::ruff::typing::type_hint_resolves_to_any; use super::super::fixes; use super::super::helpers::match_function_def; @@ -432,20 +434,46 @@ fn is_none_returning(body: &[Stmt]) -> bool { /// ANN401 fn check_dynamically_typed( + checker: &Checker, annotation: &Expr, func: F, diagnostics: &mut Vec, - is_overridden: bool, - semantic: &SemanticModel, ) where F: FnOnce() -> String, { - if !is_overridden && semantic.match_typing_expr(annotation, "Any") { - diagnostics.push(Diagnostic::new( - AnyType { name: func() }, - annotation.range(), - )); - }; + if let Expr::Constant(ast::ExprConstant { + range, + value: Constant::Str(string), + .. + }) = annotation + { + // Quoted annotations + if let Ok((parsed_annotation, _)) = parse_type_annotation(string, *range, checker.locator) { + if type_hint_resolves_to_any( + &parsed_annotation, + checker.semantic(), + checker.locator, + checker.settings.target_version.minor(), + ) { + diagnostics.push(Diagnostic::new( + AnyType { name: func() }, + annotation.range(), + )); + } + } + } else { + if type_hint_resolves_to_any( + annotation, + checker.semantic(), + checker.locator, + checker.settings.target_version.minor(), + ) { + diagnostics.push(Diagnostic::new( + AnyType { name: func() }, + annotation.range(), + )); + } + } } /// Generate flake8-annotation checks for a given `Definition`. @@ -500,13 +528,12 @@ pub(crate) fn definition( // ANN401 for dynamically typed arguments if let Some(annotation) = &def.annotation { has_any_typed_arg = true; - if checker.enabled(Rule::AnyType) { + if checker.enabled(Rule::AnyType) && !is_overridden { check_dynamically_typed( + checker, annotation, || def.arg.to_string(), &mut diagnostics, - is_overridden, - checker.semantic(), ); } } else { @@ -530,15 +557,9 @@ pub(crate) fn definition( if let Some(expr) = &arg.annotation { has_any_typed_arg = true; if !checker.settings.flake8_annotations.allow_star_arg_any { - if checker.enabled(Rule::AnyType) { + if checker.enabled(Rule::AnyType) && !is_overridden { let name = &arg.arg; - check_dynamically_typed( - expr, - || format!("*{name}"), - &mut diagnostics, - is_overridden, - checker.semantic(), - ); + check_dynamically_typed(checker, expr, || format!("*{name}"), &mut diagnostics); } } } else { @@ -562,14 +583,13 @@ pub(crate) fn definition( if let Some(expr) = &arg.annotation { has_any_typed_arg = true; if !checker.settings.flake8_annotations.allow_star_arg_any { - if checker.enabled(Rule::AnyType) { + if checker.enabled(Rule::AnyType) && !is_overridden { let name = &arg.arg; check_dynamically_typed( + checker, expr, || format!("**{name}"), &mut diagnostics, - is_overridden, - checker.semantic(), ); } } @@ -629,14 +649,8 @@ pub(crate) fn definition( // ANN201, ANN202, ANN401 if let Some(expr) = &returns { has_typed_return = true; - if checker.enabled(Rule::AnyType) { - check_dynamically_typed( - expr, - || name.to_string(), - &mut diagnostics, - is_overridden, - checker.semantic(), - ); + if checker.enabled(Rule::AnyType) && !is_overridden { + check_dynamically_typed(checker, expr, || name.to_string(), &mut diagnostics); } } else if !( // Allow omission of return annotation if the function only returns `None` diff --git a/crates/ruff/src/rules/flake8_annotations/snapshots/ruff__rules__flake8_annotations__tests__defaults.snap b/crates/ruff/src/rules/flake8_annotations/snapshots/ruff__rules__flake8_annotations__tests__defaults.snap index 0eba6a0469..7cd87414d4 100644 --- a/crates/ruff/src/rules/flake8_annotations/snapshots/ruff__rules__flake8_annotations__tests__defaults.snap +++ b/crates/ruff/src/rules/flake8_annotations/snapshots/ruff__rules__flake8_annotations__tests__defaults.snap @@ -186,4 +186,60 @@ annotation_presence.py:134:13: ANN101 Missing type annotation for `self` in meth 135 | pass | +annotation_presence.py:148:10: ANN401 Dynamically typed expressions (typing.Any) are disallowed in `a` + | +147 | # ANN401 +148 | def f(a: Any | int) -> None: ... + | ^^^^^^^^^ ANN401 +149 | def f(a: int | Any) -> None: ... +150 | def f(a: Union[str, bytes, Any]) -> None: ... + | + +annotation_presence.py:149:10: ANN401 Dynamically typed expressions (typing.Any) are disallowed in `a` + | +147 | # ANN401 +148 | def f(a: Any | int) -> None: ... +149 | def f(a: int | Any) -> None: ... + | ^^^^^^^^^ ANN401 +150 | def f(a: Union[str, bytes, Any]) -> None: ... +151 | def f(a: Optional[Any]) -> None: ... + | + +annotation_presence.py:150:10: ANN401 Dynamically typed expressions (typing.Any) are disallowed in `a` + | +148 | def f(a: Any | int) -> None: ... +149 | def f(a: int | Any) -> None: ... +150 | def f(a: Union[str, bytes, Any]) -> None: ... + | ^^^^^^^^^^^^^^^^^^^^^^ ANN401 +151 | def f(a: Optional[Any]) -> None: ... +152 | def f(a: Annotated[Any, ...]) -> None: ... + | + +annotation_presence.py:151:10: ANN401 Dynamically typed expressions (typing.Any) are disallowed in `a` + | +149 | def f(a: int | Any) -> None: ... +150 | def f(a: Union[str, bytes, Any]) -> None: ... +151 | def f(a: Optional[Any]) -> None: ... + | ^^^^^^^^^^^^^ ANN401 +152 | def f(a: Annotated[Any, ...]) -> None: ... +153 | def f(a: "Union[str, bytes, Any]") -> None: ... + | + +annotation_presence.py:152:10: ANN401 Dynamically typed expressions (typing.Any) are disallowed in `a` + | +150 | def f(a: Union[str, bytes, Any]) -> None: ... +151 | def f(a: Optional[Any]) -> None: ... +152 | def f(a: Annotated[Any, ...]) -> None: ... + | ^^^^^^^^^^^^^^^^^^^ ANN401 +153 | def f(a: "Union[str, bytes, Any]") -> None: ... + | + +annotation_presence.py:153:10: ANN401 Dynamically typed expressions (typing.Any) are disallowed in `a` + | +151 | def f(a: Optional[Any]) -> None: ... +152 | def f(a: Annotated[Any, ...]) -> None: ... +153 | def f(a: "Union[str, bytes, Any]") -> None: ... + | ^^^^^^^^^^^^^^^^^^^^^^^^ ANN401 + | + diff --git a/crates/ruff/src/rules/ruff/mod.rs b/crates/ruff/src/rules/ruff/mod.rs index 0c9aded910..79062bc839 100644 --- a/crates/ruff/src/rules/ruff/mod.rs +++ b/crates/ruff/src/rules/ruff/mod.rs @@ -1,6 +1,7 @@ //! Ruff-specific rules. pub(crate) mod rules; +pub(crate) mod typing; #[cfg(test)] mod tests { diff --git a/crates/ruff/src/rules/ruff/rules/implicit_optional.rs b/crates/ruff/src/rules/ruff/rules/implicit_optional.rs index 64fb85c073..d9f03e2699 100644 --- a/crates/ruff/src/rules/ruff/rules/implicit_optional.rs +++ b/crates/ruff/src/rules/ruff/rules/implicit_optional.rs @@ -6,18 +6,16 @@ use rustpython_parser::ast::{self, ArgWithDefault, Arguments, Constant, Expr, Op use ruff_diagnostics::{AutofixKind, Diagnostic, Edit, Fix, Violation}; use ruff_macros::{derive_message_formats, violation}; -use ruff_python_ast::call_path::CallPath; use ruff_python_ast::helpers::is_const_none; -use ruff_python_ast::source_code::Locator; use ruff_python_ast::typing::parse_type_annotation; -use ruff_python_semantic::SemanticModel; -use ruff_python_stdlib::sys::is_known_standard_library; use crate::checkers::ast::Checker; use crate::importer::ImportRequest; use crate::registry::AsRule; use crate::settings::types::PythonVersion; +use super::super::typing::type_hint_explicitly_allows_none; + /// ## What it does /// Checks for the use of implicit `Optional` in type annotations when the /// default parameter value is `None`. @@ -121,231 +119,6 @@ impl From for ConversionType { } } -/// Custom iterator to collect all the `|` separated expressions in a PEP 604 -/// union type. -struct PEP604UnionIterator<'a> { - stack: Vec<&'a Expr>, -} - -impl<'a> PEP604UnionIterator<'a> { - fn new(expr: &'a Expr) -> Self { - Self { stack: vec![expr] } - } -} - -impl<'a> Iterator for PEP604UnionIterator<'a> { - type Item = &'a Expr; - - fn next(&mut self) -> Option { - while let Some(expr) = self.stack.pop() { - match expr { - Expr::BinOp(ast::ExprBinOp { - left, - op: Operator::BitOr, - right, - .. - }) => { - self.stack.push(left); - self.stack.push(right); - } - _ => return Some(expr), - } - } - None - } -} - -/// Returns `true` if the given call path is a known type. -/// -/// A known type is either a builtin type, any object from the standard library, -/// or a type from the `typing_extensions` module. -fn is_known_type(call_path: &CallPath, target_version: PythonVersion) -> bool { - match call_path.as_slice() { - ["" | "typing_extensions", ..] => true, - [module, ..] => is_known_standard_library(target_version.minor(), module), - _ => false, - } -} - -#[derive(Debug)] -enum TypingTarget<'a> { - None, - Any, - Object, - Optional, - ForwardReference(Expr), - Union(Vec<&'a Expr>), - Literal(Vec<&'a Expr>), - Annotated(&'a Expr), -} - -impl<'a> TypingTarget<'a> { - fn try_from_expr( - expr: &'a Expr, - semantic: &SemanticModel, - locator: &Locator, - target_version: PythonVersion, - ) -> Option { - match expr { - Expr::Subscript(ast::ExprSubscript { value, slice, .. }) => { - if semantic.match_typing_expr(value, "Optional") { - return Some(TypingTarget::Optional); - } - let Expr::Tuple(ast::ExprTuple { elts: elements, .. }) = slice.as_ref() else { - return None; - }; - if semantic.match_typing_expr(value, "Literal") { - Some(TypingTarget::Literal(elements.iter().collect())) - } else if semantic.match_typing_expr(value, "Union") { - Some(TypingTarget::Union(elements.iter().collect())) - } else if semantic.match_typing_expr(value, "Annotated") { - elements.first().map(TypingTarget::Annotated) - } else { - semantic.resolve_call_path(value).map_or( - // If we can't resolve the call path, it must be defined - // in the same file, so we assume it's `Any` as it could - // be a type alias. - Some(TypingTarget::Any), - |call_path| { - if is_known_type(&call_path, target_version) { - None - } else { - // If it's not a known type, we assume it's `Any`. - Some(TypingTarget::Any) - } - }, - ) - } - } - Expr::BinOp(..) => Some(TypingTarget::Union( - PEP604UnionIterator::new(expr).collect(), - )), - Expr::Constant(ast::ExprConstant { - value: Constant::None, - .. - }) => Some(TypingTarget::None), - Expr::Constant(ast::ExprConstant { - value: Constant::Str(string), - range, - .. - }) => parse_type_annotation(string, *range, locator) - // In case of a parse error, we return `Any` to avoid false positives. - .map_or(Some(TypingTarget::Any), |(expr, _)| { - Some(TypingTarget::ForwardReference(expr)) - }), - _ => semantic.resolve_call_path(expr).map_or( - // If we can't resolve the call path, it must be defined in the - // same file, so we assume it's `Any` as it could be a type alias. - Some(TypingTarget::Any), - |call_path| { - if semantic.match_typing_call_path(&call_path, "Any") { - Some(TypingTarget::Any) - } else if matches!(call_path.as_slice(), ["" | "builtins", "object"]) { - Some(TypingTarget::Object) - } else if !is_known_type(&call_path, target_version) { - // If it's not a known type, we assume it's `Any`. - Some(TypingTarget::Any) - } else { - None - } - }, - ), - } - } - - /// Check if the [`TypingTarget`] explicitly allows `None`. - fn contains_none( - &self, - semantic: &SemanticModel, - locator: &Locator, - target_version: PythonVersion, - ) -> bool { - match self { - TypingTarget::None - | TypingTarget::Optional - | TypingTarget::Any - | TypingTarget::Object => true, - TypingTarget::Literal(elements) => elements.iter().any(|element| { - let Some(new_target) = - TypingTarget::try_from_expr(element, semantic, locator, target_version) - else { - return false; - }; - // Literal can only contain `None`, a literal value, other `Literal` - // or an enum value. - match new_target { - TypingTarget::None => true, - TypingTarget::Literal(_) => { - new_target.contains_none(semantic, locator, target_version) - } - _ => false, - } - }), - TypingTarget::Union(elements) => elements.iter().any(|element| { - let Some(new_target) = - TypingTarget::try_from_expr(element, semantic, locator, target_version) - else { - return false; - }; - new_target.contains_none(semantic, locator, target_version) - }), - TypingTarget::Annotated(element) => { - let Some(new_target) = - TypingTarget::try_from_expr(element, semantic, locator, target_version) - else { - return false; - }; - new_target.contains_none(semantic, locator, target_version) - } - TypingTarget::ForwardReference(expr) => { - let Some(new_target) = - TypingTarget::try_from_expr(expr, semantic, locator, target_version) - else { - return false; - }; - new_target.contains_none(semantic, locator, target_version) - } - } - } -} - -/// Check if the given annotation [`Expr`] explicitly allows `None`. -/// -/// This function will return `None` if the annotation explicitly allows `None` -/// otherwise it will return the annotation itself. If it's a `Annotated` type, -/// then the inner type will be checked. -/// -/// This function assumes that the annotation is a valid typing annotation expression. -fn type_hint_explicitly_allows_none<'a>( - annotation: &'a Expr, - semantic: &SemanticModel, - locator: &Locator, - target_version: PythonVersion, -) -> Option<&'a Expr> { - let Some(target) = TypingTarget::try_from_expr(annotation, semantic, locator, target_version) - else { - return Some(annotation); - }; - match target { - // Short circuit on top level `None`, `Any` or `Optional` - TypingTarget::None | TypingTarget::Optional | TypingTarget::Any => None, - // Top-level `Annotated` node should check for the inner type and - // return the inner type if it doesn't allow `None`. If `Annotated` - // is found nested inside another type, then the outer type should - // be returned. - TypingTarget::Annotated(expr) => { - type_hint_explicitly_allows_none(expr, semantic, locator, target_version) - } - _ => { - if target.contains_none(semantic, locator, target_version) { - None - } else { - Some(annotation) - } - } - } -} - /// Generate a [`Fix`] for the given [`Expr`] as per the [`ConversionType`]. fn generate_fix(checker: &Checker, conversion_type: ConversionType, expr: &Expr) -> Result { match conversion_type { @@ -423,7 +196,7 @@ pub(crate) fn implicit_optional(checker: &mut Checker, arguments: &Arguments) { &annotation, checker.semantic(), checker.locator, - checker.settings.target_version, + checker.settings.target_version.minor(), ) else { continue; }; @@ -444,7 +217,7 @@ pub(crate) fn implicit_optional(checker: &mut Checker, arguments: &Arguments) { annotation, checker.semantic(), checker.locator, - checker.settings.target_version, + checker.settings.target_version.minor(), ) else { continue; }; @@ -459,40 +232,3 @@ pub(crate) fn implicit_optional(checker: &mut Checker, arguments: &Arguments) { } } } - -#[cfg(test)] -mod tests { - use ruff_python_ast::call_path::CallPath; - - use crate::settings::types::PythonVersion; - - use super::is_known_type; - - #[test] - fn test_is_known_type() { - assert!(is_known_type( - &CallPath::from_slice(&["", "int"]), - PythonVersion::Py311 - )); - assert!(is_known_type( - &CallPath::from_slice(&["builtins", "int"]), - PythonVersion::Py311 - )); - assert!(is_known_type( - &CallPath::from_slice(&["typing", "Optional"]), - PythonVersion::Py311 - )); - assert!(is_known_type( - &CallPath::from_slice(&["typing_extensions", "Literal"]), - PythonVersion::Py311 - )); - assert!(is_known_type( - &CallPath::from_slice(&["zoneinfo", "ZoneInfo"]), - PythonVersion::Py311 - )); - assert!(!is_known_type( - &CallPath::from_slice(&["zoneinfo", "ZoneInfo"]), - PythonVersion::Py38 - )); - } -} diff --git a/crates/ruff/src/rules/ruff/snapshots/ruff__rules__ruff__tests__PY39_RUF013_RUF013_0.py.snap b/crates/ruff/src/rules/ruff/snapshots/ruff__rules__ruff__tests__PY39_RUF013_RUF013_0.py.snap index 9ee735a80b..7d4b9a9f00 100644 --- a/crates/ruff/src/rules/ruff/snapshots/ruff__rules__ruff__tests__PY39_RUF013_RUF013_0.py.snap +++ b/crates/ruff/src/rules/ruff/snapshots/ruff__rules__ruff__tests__PY39_RUF013_RUF013_0.py.snap @@ -37,42 +37,6 @@ RUF013_0.py:25:12: RUF013 [*] PEP 484 prohibits implicit `Optional` 27 27 | 28 28 | -RUF013_0.py:29:12: RUF013 [*] PEP 484 prohibits implicit `Optional` - | -29 | def f(arg: typing.List[str] = None): # RUF013 - | ^^^^^^^^^^^^^^^^ RUF013 -30 | pass - | - = help: Convert to `Optional[T]` - -ℹ Suggested fix -26 26 | pass -27 27 | -28 28 | -29 |-def f(arg: typing.List[str] = None): # RUF013 - 29 |+def f(arg: Optional[typing.List[str]] = None): # RUF013 -30 30 | pass -31 31 | -32 32 | - -RUF013_0.py:33:12: RUF013 [*] PEP 484 prohibits implicit `Optional` - | -33 | def f(arg: Tuple[str] = None): # RUF013 - | ^^^^^^^^^^ RUF013 -34 | pass - | - = help: Convert to `Optional[T]` - -ℹ Suggested fix -30 30 | pass -31 31 | -32 32 | -33 |-def f(arg: Tuple[str] = None): # RUF013 - 33 |+def f(arg: Optional[Tuple[str]] = None): # RUF013 -34 34 | pass -35 35 | -36 36 | - RUF013_0.py:67:12: RUF013 [*] PEP 484 prohibits implicit `Optional` | 67 | def f(arg: Union = None): # RUF013 diff --git a/crates/ruff/src/rules/ruff/snapshots/ruff__rules__ruff__tests__RUF013_RUF013_0.py.snap b/crates/ruff/src/rules/ruff/snapshots/ruff__rules__ruff__tests__RUF013_RUF013_0.py.snap index 2f21fcd56b..341aecce5e 100644 --- a/crates/ruff/src/rules/ruff/snapshots/ruff__rules__ruff__tests__RUF013_RUF013_0.py.snap +++ b/crates/ruff/src/rules/ruff/snapshots/ruff__rules__ruff__tests__RUF013_RUF013_0.py.snap @@ -37,42 +37,6 @@ RUF013_0.py:25:12: RUF013 [*] PEP 484 prohibits implicit `Optional` 27 27 | 28 28 | -RUF013_0.py:29:12: RUF013 [*] PEP 484 prohibits implicit `Optional` - | -29 | def f(arg: typing.List[str] = None): # RUF013 - | ^^^^^^^^^^^^^^^^ RUF013 -30 | pass - | - = help: Convert to `T | None` - -ℹ Suggested fix -26 26 | pass -27 27 | -28 28 | -29 |-def f(arg: typing.List[str] = None): # RUF013 - 29 |+def f(arg: typing.List[str] | None = None): # RUF013 -30 30 | pass -31 31 | -32 32 | - -RUF013_0.py:33:12: RUF013 [*] PEP 484 prohibits implicit `Optional` - | -33 | def f(arg: Tuple[str] = None): # RUF013 - | ^^^^^^^^^^ RUF013 -34 | pass - | - = help: Convert to `T | None` - -ℹ Suggested fix -30 30 | pass -31 31 | -32 32 | -33 |-def f(arg: Tuple[str] = None): # RUF013 - 33 |+def f(arg: Tuple[str] | None = None): # RUF013 -34 34 | pass -35 35 | -36 36 | - RUF013_0.py:67:12: RUF013 [*] PEP 484 prohibits implicit `Optional` | 67 | def f(arg: Union = None): # RUF013 diff --git a/crates/ruff/src/rules/ruff/typing.rs b/crates/ruff/src/rules/ruff/typing.rs new file mode 100644 index 0000000000..127c213fef --- /dev/null +++ b/crates/ruff/src/rules/ruff/typing.rs @@ -0,0 +1,330 @@ +use rustpython_parser::ast::{self, Constant, Expr, Operator}; + +use ruff_python_ast::call_path::CallPath; +use ruff_python_ast::source_code::Locator; +use ruff_python_ast::typing::parse_type_annotation; +use ruff_python_semantic::SemanticModel; +use ruff_python_stdlib::sys::is_known_standard_library; + +/// Custom iterator to collect all the `|` separated expressions in a PEP 604 +/// union type. +struct PEP604UnionIterator<'a> { + stack: Vec<&'a Expr>, +} + +impl<'a> PEP604UnionIterator<'a> { + fn new(expr: &'a Expr) -> Self { + Self { stack: vec![expr] } + } +} + +impl<'a> Iterator for PEP604UnionIterator<'a> { + type Item = &'a Expr; + + fn next(&mut self) -> Option { + while let Some(expr) = self.stack.pop() { + match expr { + Expr::BinOp(ast::ExprBinOp { + left, + op: Operator::BitOr, + right, + .. + }) => { + self.stack.push(left); + self.stack.push(right); + } + _ => return Some(expr), + } + } + None + } +} + +/// Returns `true` if the given call path is a known type. +/// +/// A known type is either a builtin type, any object from the standard library, +/// or a type from the `typing_extensions` module. +fn is_known_type(call_path: &CallPath, minor_version: u32) -> bool { + match call_path.as_slice() { + ["" | "typing_extensions", ..] => true, + [module, ..] => is_known_standard_library(minor_version, module), + _ => false, + } +} + +#[derive(Debug)] +enum TypingTarget<'a> { + /// Literal `None` type. + None, + + /// A `typing.Any` type. + Any, + + /// Literal `object` type. + Object, + + /// Forward reference to a type e.g., `"List[str]"`. + ForwardReference(Expr), + + /// A `typing.Union` type or `|` separated types e.g., `Union[int, str]` + /// or `int | str`. + Union(Vec<&'a Expr>), + + /// A `typing.Literal` type e.g., `Literal[1, 2, 3]`. + Literal(Vec<&'a Expr>), + + /// A `typing.Optional` type e.g., `Optional[int]`. + Optional(&'a Expr), + + /// A `typing.Annotated` type e.g., `Annotated[int, ...]`. + Annotated(&'a Expr), + + /// Special type used to represent an unknown type (and not a typing target) + /// which could be a type alias. + Unknown, + + /// Special type used to represent a known type (and not a typing target). + /// A known type is either a builtin type, any object from the standard + /// library, or a type from the `typing_extensions` module. + Known, +} + +impl<'a> TypingTarget<'a> { + fn try_from_expr( + expr: &'a Expr, + semantic: &SemanticModel, + locator: &Locator, + minor_version: u32, + ) -> Option { + match expr { + Expr::Subscript(ast::ExprSubscript { value, slice, .. }) => { + if semantic.match_typing_expr(value, "Optional") { + return Some(TypingTarget::Optional(slice.as_ref())); + } + let Expr::Tuple(ast::ExprTuple { elts: elements, .. }) = slice.as_ref() else { + return None; + }; + if semantic.match_typing_expr(value, "Literal") { + Some(TypingTarget::Literal(elements.iter().collect())) + } else if semantic.match_typing_expr(value, "Union") { + Some(TypingTarget::Union(elements.iter().collect())) + } else if semantic.match_typing_expr(value, "Annotated") { + elements.first().map(TypingTarget::Annotated) + } else { + semantic.resolve_call_path(value).map_or( + // If we can't resolve the call path, it must be defined + // in the same file and could be a type alias. + Some(TypingTarget::Unknown), + |call_path| { + if is_known_type(&call_path, minor_version) { + Some(TypingTarget::Known) + } else { + Some(TypingTarget::Unknown) + } + }, + ) + } + } + Expr::BinOp(..) => Some(TypingTarget::Union( + PEP604UnionIterator::new(expr).collect(), + )), + Expr::Constant(ast::ExprConstant { + value: Constant::None, + .. + }) => Some(TypingTarget::None), + Expr::Constant(ast::ExprConstant { + value: Constant::Str(string), + range, + .. + }) => parse_type_annotation(string, *range, locator) + .map_or(None, |(expr, _)| Some(TypingTarget::ForwardReference(expr))), + _ => semantic.resolve_call_path(expr).map_or( + // If we can't resolve the call path, it must be defined in the + // same file, so we assume it's `Any` as it could be a type alias. + Some(TypingTarget::Unknown), + |call_path| { + if semantic.match_typing_call_path(&call_path, "Any") { + Some(TypingTarget::Any) + } else if matches!(call_path.as_slice(), ["" | "builtins", "object"]) { + Some(TypingTarget::Object) + } else if !is_known_type(&call_path, minor_version) { + // If it's not a known type, we assume it's `Any`. + Some(TypingTarget::Unknown) + } else { + Some(TypingTarget::Known) + } + }, + ), + } + } + + /// Check if the [`TypingTarget`] explicitly allows `None`. + fn contains_none( + &self, + semantic: &SemanticModel, + locator: &Locator, + minor_version: u32, + ) -> bool { + match self { + TypingTarget::None + | TypingTarget::Optional(_) + | TypingTarget::Any + | TypingTarget::Object + | TypingTarget::Unknown => true, + TypingTarget::Known => false, + TypingTarget::Literal(elements) => elements.iter().any(|element| { + // Literal can only contain `None`, a literal value, other `Literal` + // or an enum value. + match TypingTarget::try_from_expr(element, semantic, locator, minor_version) { + None | Some(TypingTarget::None) => true, + Some(new_target @ TypingTarget::Literal(_)) => { + new_target.contains_none(semantic, locator, minor_version) + } + _ => false, + } + }), + TypingTarget::Union(elements) => elements.iter().any(|element| { + TypingTarget::try_from_expr(element, semantic, locator, minor_version) + .map_or(true, |new_target| { + new_target.contains_none(semantic, locator, minor_version) + }) + }), + TypingTarget::Annotated(element) => { + TypingTarget::try_from_expr(element, semantic, locator, minor_version) + .map_or(true, |new_target| { + new_target.contains_none(semantic, locator, minor_version) + }) + } + TypingTarget::ForwardReference(expr) => { + TypingTarget::try_from_expr(expr, semantic, locator, minor_version) + .map_or(true, |new_target| { + new_target.contains_none(semantic, locator, minor_version) + }) + } + } + } + + /// Check if the [`TypingTarget`] explicitly allows `Any`. + fn contains_any( + &self, + semantic: &SemanticModel, + locator: &Locator, + minor_version: u32, + ) -> bool { + match self { + TypingTarget::Any => true, + // `Literal` cannot contain `Any` as it's a dynamic value. + TypingTarget::Literal(_) + | TypingTarget::None + | TypingTarget::Object + | TypingTarget::Known + | TypingTarget::Unknown => false, + TypingTarget::Union(elements) => elements.iter().any(|element| { + TypingTarget::try_from_expr(element, semantic, locator, minor_version) + .map_or(true, |new_target| { + new_target.contains_any(semantic, locator, minor_version) + }) + }), + TypingTarget::Annotated(element) | TypingTarget::Optional(element) => { + TypingTarget::try_from_expr(element, semantic, locator, minor_version) + .map_or(true, |new_target| { + new_target.contains_any(semantic, locator, minor_version) + }) + } + TypingTarget::ForwardReference(expr) => { + TypingTarget::try_from_expr(expr, semantic, locator, minor_version) + .map_or(true, |new_target| { + new_target.contains_any(semantic, locator, minor_version) + }) + } + } + } +} + +/// Check if the given annotation [`Expr`] explicitly allows `None`. +/// +/// This function will return `None` if the annotation explicitly allows `None` +/// otherwise it will return the annotation itself. If it's a `Annotated` type, +/// then the inner type will be checked. +/// +/// This function assumes that the annotation is a valid typing annotation expression. +pub(crate) fn type_hint_explicitly_allows_none<'a>( + annotation: &'a Expr, + semantic: &SemanticModel, + locator: &Locator, + minor_version: u32, +) -> Option<&'a Expr> { + match TypingTarget::try_from_expr(annotation, semantic, locator, minor_version) { + None | + // Short circuit on top level `None`, `Any` or `Optional` + Some(TypingTarget::None | TypingTarget::Optional(_) | TypingTarget::Any) => None, + // Top-level `Annotated` node should check for the inner type and + // return the inner type if it doesn't allow `None`. If `Annotated` + // is found nested inside another type, then the outer type should + // be returned. + Some(TypingTarget::Annotated(expr)) => { + type_hint_explicitly_allows_none(expr, semantic, locator, minor_version) + } + Some(target) => { + if target.contains_none(semantic, locator, minor_version) { + None + } else { + Some(annotation) + } + } + } +} + +/// Check if the given annotation [`Expr`] resolves to `Any`. +/// +/// This function assumes that the annotation is a valid typing annotation expression. +pub(crate) fn type_hint_resolves_to_any( + annotation: &Expr, + semantic: &SemanticModel, + locator: &Locator, + minor_version: u32, +) -> bool { + match TypingTarget::try_from_expr(annotation, semantic, locator, minor_version) { + None | + // Short circuit on top level `Any` + Some(TypingTarget::Any) => true, + // Top-level `Annotated` node should check if the inner type resolves + // to `Any`. + Some(TypingTarget::Annotated(expr)) => { + type_hint_resolves_to_any(expr, semantic, locator, minor_version) + } + Some(target) => target.contains_any(semantic, locator, minor_version), + } +} + +#[cfg(test)] +mod tests { + use ruff_python_ast::call_path::CallPath; + + use super::is_known_type; + + #[test] + fn test_is_known_type() { + assert!(is_known_type(&CallPath::from_slice(&["", "int"]), 11)); + assert!(is_known_type( + &CallPath::from_slice(&["builtins", "int"]), + 11 + )); + assert!(is_known_type( + &CallPath::from_slice(&["typing", "Optional"]), + 11 + )); + assert!(is_known_type( + &CallPath::from_slice(&["typing_extensions", "Literal"]), + 11 + )); + assert!(is_known_type( + &CallPath::from_slice(&["zoneinfo", "ZoneInfo"]), + 11 + )); + assert!(!is_known_type( + &CallPath::from_slice(&["zoneinfo", "ZoneInfo"]), + 8 + )); + } +} diff --git a/scripts/pyproject.toml b/scripts/pyproject.toml index bdb5a08d5e..1a3a318a1a 100644 --- a/scripts/pyproject.toml +++ b/scripts/pyproject.toml @@ -19,6 +19,7 @@ ignore = [ "T", # flake8-print "FBT", # flake8-boolean-trap "PERF", # perflint + "ANN401", ] [tool.ruff.isort]