diff --git a/crates/ruff/resources/test/fixtures/flake8_pyi/PYI024.py b/crates/ruff/resources/test/fixtures/flake8_pyi/PYI024.py new file mode 100644 index 0000000000000..3090ae76c3192 --- /dev/null +++ b/crates/ruff/resources/test/fixtures/flake8_pyi/PYI024.py @@ -0,0 +1,9 @@ +import collections + +person: collections.namedtuple # OK + +from collections import namedtuple + +person: namedtuple # OK + +person = namedtuple("Person", ["name", "age"]) # OK diff --git a/crates/ruff/resources/test/fixtures/flake8_pyi/PYI024.pyi b/crates/ruff/resources/test/fixtures/flake8_pyi/PYI024.pyi new file mode 100644 index 0000000000000..b3d3b67b9ed03 --- /dev/null +++ b/crates/ruff/resources/test/fixtures/flake8_pyi/PYI024.pyi @@ -0,0 +1,11 @@ +import collections + +person: collections.namedtuple # Y024 Use "typing.NamedTuple" instead of "collections.namedtuple" + +from collections import namedtuple + +person: namedtuple # Y024 Use "typing.NamedTuple" instead of "collections.namedtuple" + +person = namedtuple( + "Person", ["name", "age"] +) # Y024 Use "typing.NamedTuple" instead of "collections.namedtuple" diff --git a/crates/ruff/src/checkers/ast/mod.rs b/crates/ruff/src/checkers/ast/mod.rs index e4e54c47bc087..328fcc8b2f72d 100644 --- a/crates/ruff/src/checkers/ast/mod.rs +++ b/crates/ruff/src/checkers/ast/mod.rs @@ -2292,6 +2292,11 @@ where if self.enabled(Rule::NumpyDeprecatedTypeAlias) { numpy::rules::deprecated_type_alias(self, expr); } + if self.is_stub { + if self.enabled(Rule::CollectionsNamedTuple) { + flake8_pyi::rules::collections_named_tuple(self, expr); + } + } // Ex) List[...] if self.any_enabled(&[ @@ -2424,6 +2429,11 @@ where if self.enabled(Rule::PrivateMemberAccess) { flake8_self::rules::private_member_access(self, expr); } + if self.is_stub { + if self.enabled(Rule::CollectionsNamedTuple) { + flake8_pyi::rules::collections_named_tuple(self, expr); + } + } pandas_vet::rules::attr(self, attr, value, expr); } Expr::Call(ast::ExprCall { diff --git a/crates/ruff/src/codes.rs b/crates/ruff/src/codes.rs index 1e896a3653f3b..c5ee5769e6fc7 100644 --- a/crates/ruff/src/codes.rs +++ b/crates/ruff/src/codes.rs @@ -591,6 +591,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (Flake8Pyi, "016") => (RuleGroup::Unspecified, Rule::DuplicateUnionMember), (Flake8Pyi, "020") => (RuleGroup::Unspecified, Rule::QuotedAnnotationInStub), (Flake8Pyi, "021") => (RuleGroup::Unspecified, Rule::DocstringInStub), + (Flake8Pyi, "024") => (RuleGroup::Unspecified, Rule::CollectionsNamedTuple), (Flake8Pyi, "032") => (RuleGroup::Unspecified, Rule::AnyEqNeAnnotation), (Flake8Pyi, "033") => (RuleGroup::Unspecified, Rule::TypeCommentInStub), (Flake8Pyi, "042") => (RuleGroup::Unspecified, Rule::SnakeCaseTypeAlias), diff --git a/crates/ruff/src/registry.rs b/crates/ruff/src/registry.rs index 0a642d2fc49ed..197d0de0a12eb 100644 --- a/crates/ruff/src/registry.rs +++ b/crates/ruff/src/registry.rs @@ -518,6 +518,7 @@ ruff_macros::register_rules!( rules::flake8_pyi::rules::IterMethodReturnIterable, rules::flake8_pyi::rules::DuplicateUnionMember, rules::flake8_pyi::rules::EllipsisInNonEmptyClassBody, + rules::flake8_pyi::rules::CollectionsNamedTuple, rules::flake8_pyi::rules::NonEmptyStubBody, rules::flake8_pyi::rules::PassInClassBody, rules::flake8_pyi::rules::PassStatementStubBody, diff --git a/crates/ruff/src/rules/flake8_pyi/mod.rs b/crates/ruff/src/rules/flake8_pyi/mod.rs index 3ccf108df1023..5d242bb333411 100644 --- a/crates/ruff/src/rules/flake8_pyi/mod.rs +++ b/crates/ruff/src/rules/flake8_pyi/mod.rs @@ -26,6 +26,8 @@ mod tests { #[test_case(Rule::DuplicateUnionMember, Path::new("PYI016.pyi"))] #[test_case(Rule::EllipsisInNonEmptyClassBody, Path::new("PYI013.py"))] #[test_case(Rule::EllipsisInNonEmptyClassBody, Path::new("PYI013.pyi"))] + #[test_case(Rule::CollectionsNamedTuple, Path::new("PYI024.py"))] + #[test_case(Rule::CollectionsNamedTuple, Path::new("PYI024.pyi"))] #[test_case(Rule::IterMethodReturnIterable, Path::new("PYI045.py"))] #[test_case(Rule::IterMethodReturnIterable, Path::new("PYI045.pyi"))] #[test_case(Rule::NonEmptyStubBody, Path::new("PYI010.py"))] diff --git a/crates/ruff/src/rules/flake8_pyi/rules/collections_named_tuple.rs b/crates/ruff/src/rules/flake8_pyi/rules/collections_named_tuple.rs new file mode 100644 index 0000000000000..7f171b755873b --- /dev/null +++ b/crates/ruff/src/rules/flake8_pyi/rules/collections_named_tuple.rs @@ -0,0 +1,64 @@ +use rustpython_parser::ast::Expr; + +use ruff_diagnostics::{Diagnostic, Violation}; +use ruff_macros::{derive_message_formats, violation}; +use ruff_python_ast::prelude::Ranged; + +use crate::checkers::ast::Checker; + +/// ## What it does +/// Checks for uses of `collections.namedtuple` in stub files. +/// +/// ## Why is this bad? +/// `typing.NamedTuple` is the "typed version" of `collections.namedtuple`. +/// +/// The class generated by subclassing `typing.NamedTuple` is equivalent to +/// `collections.namedtuple`, with the exception that `typing.NamedTuple` +/// includes an `__annotations__` attribute, which allows type checkers to +/// infer the types of the fields. +/// +/// ## Example +/// ```python +/// from collections import namedtuple +/// +/// +/// person = namedtuple("Person", ["name", "age"]) +/// ``` +/// +/// Use instead: +/// ```python +/// from typing import NamedTuple +/// +/// +/// class Person(NamedTuple): +/// name: str +/// age: int +/// ``` +#[violation] +pub struct CollectionsNamedTuple; + +impl Violation for CollectionsNamedTuple { + #[derive_message_formats] + fn message(&self) -> String { + format!("Use `typing.NamedTuple` instead of `collections.namedtuple`") + } + + fn autofix_title(&self) -> Option { + Some(format!("Replace with `typing.NamedTuple`")) + } +} + +/// PYI024 +pub(crate) fn collections_named_tuple(checker: &mut Checker, expr: &Expr) { + if checker + .semantic_model() + .resolve_call_path(expr) + .map_or(false, |call_path| { + matches!(call_path.as_slice(), ["collections", "namedtuple"]) + }) + { + checker + .diagnostics + .push(Diagnostic::new(CollectionsNamedTuple, expr.range())); + } +} diff --git a/crates/ruff/src/rules/flake8_pyi/rules/mod.rs b/crates/ruff/src/rules/flake8_pyi/rules/mod.rs index 790f0b494bea4..2fe699df83808 100644 --- a/crates/ruff/src/rules/flake8_pyi/rules/mod.rs +++ b/crates/ruff/src/rules/flake8_pyi/rules/mod.rs @@ -2,6 +2,7 @@ pub(crate) use any_eq_ne_annotation::{any_eq_ne_annotation, AnyEqNeAnnotation}; pub(crate) use bad_version_info_comparison::{ bad_version_info_comparison, BadVersionInfoComparison, }; +pub(crate) use collections_named_tuple::{collections_named_tuple, CollectionsNamedTuple}; pub(crate) use docstring_in_stubs::{docstring_in_stubs, DocstringInStub}; pub(crate) use duplicate_union_member::{duplicate_union_member, DuplicateUnionMember}; pub(crate) use ellipsis_in_non_empty_class_body::{ @@ -33,6 +34,7 @@ pub(crate) use unrecognized_platform::{ mod any_eq_ne_annotation; mod bad_version_info_comparison; +mod collections_named_tuple; mod docstring_in_stubs; mod duplicate_union_member; mod ellipsis_in_non_empty_class_body; diff --git a/crates/ruff/src/rules/flake8_pyi/snapshots/ruff__rules__flake8_pyi__tests__PYI024_PYI024.py.snap b/crates/ruff/src/rules/flake8_pyi/snapshots/ruff__rules__flake8_pyi__tests__PYI024_PYI024.py.snap new file mode 100644 index 0000000000000..d1aa2e9116558 --- /dev/null +++ b/crates/ruff/src/rules/flake8_pyi/snapshots/ruff__rules__flake8_pyi__tests__PYI024_PYI024.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__PYI024_PYI024.pyi.snap b/crates/ruff/src/rules/flake8_pyi/snapshots/ruff__rules__flake8_pyi__tests__PYI024_PYI024.pyi.snap new file mode 100644 index 0000000000000..1b8153dda2511 --- /dev/null +++ b/crates/ruff/src/rules/flake8_pyi/snapshots/ruff__rules__flake8_pyi__tests__PYI024_PYI024.pyi.snap @@ -0,0 +1,37 @@ +--- +source: crates/ruff/src/rules/flake8_pyi/mod.rs +--- +PYI024.pyi:3:9: PYI024 Use `typing.NamedTuple` instead of `collections.namedtuple` + | +3 | import collections +4 | +5 | person: collections.namedtuple # Y024 Use "typing.NamedTuple" instead of "collections.namedtuple" + | ^^^^^^^^^^^^^^^^^^^^^^ PYI024 +6 | +7 | from collections import namedtuple + | + = help: Replace with `typing.NamedTuple` + +PYI024.pyi:7:9: PYI024 Use `typing.NamedTuple` instead of `collections.namedtuple` + | + 7 | from collections import namedtuple + 8 | + 9 | person: namedtuple # Y024 Use "typing.NamedTuple" instead of "collections.namedtuple" + | ^^^^^^^^^^ PYI024 +10 | +11 | person = namedtuple( + | + = help: Replace with `typing.NamedTuple` + +PYI024.pyi:9:10: PYI024 Use `typing.NamedTuple` instead of `collections.namedtuple` + | + 9 | person: namedtuple # Y024 Use "typing.NamedTuple" instead of "collections.namedtuple" +10 | +11 | person = namedtuple( + | ^^^^^^^^^^ PYI024 +12 | "Person", ["name", "age"] +13 | ) # Y024 Use "typing.NamedTuple" instead of "collections.namedtuple" + | + = help: Replace with `typing.NamedTuple` + + diff --git a/ruff.schema.json b/ruff.schema.json index 31550f9dcb680..bd19906af2b99 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -2209,6 +2209,7 @@ "PYI02", "PYI020", "PYI021", + "PYI024", "PYI03", "PYI032", "PYI033",