diff --git a/crates/ruff/resources/test/fixtures/flake8_pyi/PYI042.py b/crates/ruff/resources/test/fixtures/flake8_pyi/PYI042.py new file mode 100644 index 00000000000000..8b159057d71ebc --- /dev/null +++ b/crates/ruff/resources/test/fixtures/flake8_pyi/PYI042.py @@ -0,0 +1,24 @@ +import typing +from collections.abc import Mapping +from typing import ( + Annotated, + TypeAlias, + Union, + Literal, +) + +just_literals_pipe_union: TypeAlias = ( + Literal[True] | Literal["idk"] +) # not PYI042 (not a stubfile) +PublicAliasT: TypeAlias = str | int +PublicAliasT2: TypeAlias = Union[str, bytes] +_ABCDEFGHIJKLMNOPQRST: TypeAlias = typing.Any +_PrivateAliasS: TypeAlias = Literal["I", "guess", "this", "is", "okay"] +_PrivateAliasS2: TypeAlias = Annotated[str, "also okay"] + +snake_case_alias1: TypeAlias = str | int # not PYI042 (not a stubfile) +_snake_case_alias2: TypeAlias = Literal["whatever"] # not PYI042 (not a stubfile) +Snake_case_alias: TypeAlias = int | float # not PYI042 (not a stubfile) + +# check that this edge case doesn't crash +_: TypeAlias = str | int diff --git a/crates/ruff/resources/test/fixtures/flake8_pyi/PYI042.pyi b/crates/ruff/resources/test/fixtures/flake8_pyi/PYI042.pyi new file mode 100644 index 00000000000000..2936c39c6c836c --- /dev/null +++ b/crates/ruff/resources/test/fixtures/flake8_pyi/PYI042.pyi @@ -0,0 +1,24 @@ +import typing +from collections.abc import Mapping +from typing import ( + Annotated, + TypeAlias, + Union, + Literal, +) + +just_literals_pipe_union: TypeAlias = ( + Literal[True] | Literal["idk"] +) # PYI042, since not camel case +PublicAliasT: TypeAlias = str | int +PublicAliasT2: TypeAlias = Union[str, bytes] +_ABCDEFGHIJKLMNOPQRST: TypeAlias = typing.Any +_PrivateAliasS: TypeAlias = Literal["I", "guess", "this", "is", "okay"] +_PrivateAliasS2: TypeAlias = Annotated[str, "also okay"] + +snake_case_alias1: TypeAlias = str | int # PYI042, since not camel case +_snake_case_alias2: TypeAlias = Literal["whatever"] # PYI042, since not camel case +Snake_case_alias: TypeAlias = int | float # PYI042, since not camel case + +# check that this edge case doesn't crash +_: TypeAlias = str | int diff --git a/crates/ruff/resources/test/fixtures/flake8_pyi/PYI043.py b/crates/ruff/resources/test/fixtures/flake8_pyi/PYI043.py new file mode 100644 index 00000000000000..d129f5befa06b9 --- /dev/null +++ b/crates/ruff/resources/test/fixtures/flake8_pyi/PYI043.py @@ -0,0 +1,23 @@ +import typing +from collections.abc import Mapping +from typing import ( + Annotated, + TypeAlias, + Union, + Literal, +) + +_PrivateAliasT: TypeAlias = str | int # not PYI043 (not a stubfile) +_PrivateAliasT2: TypeAlias = typing.Any # not PYI043 (not a stubfile) +_PrivateAliasT3: TypeAlias = Literal[ + "not", "a", "chance" +] # not PYI043 (not a stubfile) +just_literals_pipe_union: TypeAlias = Literal[True] | Literal["idk"] +PublicAliasT: TypeAlias = str | int +PublicAliasT2: TypeAlias = Union[str, bytes] +_ABCDEFGHIJKLMNOPQRST: TypeAlias = typing.Any +_PrivateAliasS: TypeAlias = Literal["I", "guess", "this", "is", "okay"] +_PrivateAliasS2: TypeAlias = Annotated[str, "also okay"] + +# check that this edge case doesn't crash +_: TypeAlias = str | int diff --git a/crates/ruff/resources/test/fixtures/flake8_pyi/PYI043.pyi b/crates/ruff/resources/test/fixtures/flake8_pyi/PYI043.pyi new file mode 100644 index 00000000000000..b48f5e0fa8c47a --- /dev/null +++ b/crates/ruff/resources/test/fixtures/flake8_pyi/PYI043.pyi @@ -0,0 +1,23 @@ +import typing +from collections.abc import Mapping +from typing import ( + Annotated, + TypeAlias, + Union, + Literal, +) + +_PrivateAliasT: TypeAlias = str | int # PYI043, since this ends in a T +_PrivateAliasT2: TypeAlias = typing.Any # PYI043, since this ends in a T +_PrivateAliasT3: TypeAlias = Literal[ + "not", "a", "chance" +] # PYI043, since this ends in a T +just_literals_pipe_union: TypeAlias = Literal[True] | Literal["idk"] +PublicAliasT: TypeAlias = str | int +PublicAliasT2: TypeAlias = Union[str, bytes] +_ABCDEFGHIJKLMNOPQRST: TypeAlias = typing.Any +_PrivateAliasS: TypeAlias = Literal["I", "guess", "this", "is", "okay"] +_PrivateAliasS2: TypeAlias = Annotated[str, "also okay"] + +# check that this edge case doesn't crash +_: TypeAlias = str | int diff --git a/crates/ruff/src/checkers/ast/mod.rs b/crates/ruff/src/checkers/ast/mod.rs index 486784b17a3906..39f0c70fb246f9 100644 --- a/crates/ruff/src/checkers/ast/mod.rs +++ b/crates/ruff/src/checkers/ast/mod.rs @@ -1914,6 +1914,14 @@ where } } } + if self.ctx.match_typing_expr(annotation, "TypeAlias") { + if self.settings.rules.enabled(Rule::SnakeCaseTypeAlias) { + flake8_pyi::rules::snake_case_type_alias(self, target); + } + if self.settings.rules.enabled(Rule::TSuffixedTypeAlias) { + flake8_pyi::rules::t_suffixed_type_alias(self, target); + } + } } } StmtKind::Delete { targets } => { diff --git a/crates/ruff/src/codes.rs b/crates/ruff/src/codes.rs index 84d894ac178bcf..6b4802a9b76b3f 100644 --- a/crates/ruff/src/codes.rs +++ b/crates/ruff/src/codes.rs @@ -595,6 +595,8 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option { (Flake8Pyi, "020") => Rule::QuotedAnnotationInStub, (Flake8Pyi, "021") => Rule::DocstringInStub, (Flake8Pyi, "033") => Rule::TypeCommentInStub, + (Flake8Pyi, "042") => Rule::SnakeCaseTypeAlias, + (Flake8Pyi, "043") => Rule::TSuffixedTypeAlias, // flake8-pytest-style (Flake8PytestStyle, "001") => Rule::PytestFixtureIncorrectParenthesesStyle, diff --git a/crates/ruff/src/registry.rs b/crates/ruff/src/registry.rs index 98eb0f0d71d7da..e4a30ee1a13aef 100644 --- a/crates/ruff/src/registry.rs +++ b/crates/ruff/src/registry.rs @@ -540,6 +540,8 @@ ruff_macros::register_rules!( rules::flake8_pyi::rules::PassInClassBody, rules::flake8_pyi::rules::DuplicateUnionMember, rules::flake8_pyi::rules::QuotedAnnotationInStub, + rules::flake8_pyi::rules::SnakeCaseTypeAlias, + rules::flake8_pyi::rules::TSuffixedTypeAlias, // flake8-pytest-style rules::flake8_pytest_style::rules::PytestFixtureIncorrectParenthesesStyle, rules::flake8_pytest_style::rules::PytestFixturePositionalArgs, diff --git a/crates/ruff/src/registry/rule_set.rs b/crates/ruff/src/registry/rule_set.rs index 8c358fa469837e..4cb6df76b423a5 100644 --- a/crates/ruff/src/registry/rule_set.rs +++ b/crates/ruff/src/registry/rule_set.rs @@ -7,10 +7,10 @@ use std::iter::FusedIterator; /// /// Uses a bitset where a bit of one signals that the Rule with that [u16] is in this set. #[derive(Clone, Default, CacheKey, PartialEq, Eq)] -pub struct RuleSet([u64; 9]); +pub struct RuleSet([u64; 10]); impl RuleSet { - const EMPTY: [u64; 9] = [0; 9]; + const EMPTY: [u64; 10] = [0; 10]; // 64 fits into a u16 without truncation #[allow(clippy::cast_possible_truncation)] diff --git a/crates/ruff/src/rules/flake8_pyi/mod.rs b/crates/ruff/src/rules/flake8_pyi/mod.rs index ac0878f3fda6cc..fd800a2db9acb5 100644 --- a/crates/ruff/src/rules/flake8_pyi/mod.rs +++ b/crates/ruff/src/rules/flake8_pyi/mod.rs @@ -41,6 +41,10 @@ mod tests { #[test_case(Rule::DocstringInStub, Path::new("PYI021.pyi"))] #[test_case(Rule::TypeCommentInStub, Path::new("PYI033.py"))] #[test_case(Rule::TypeCommentInStub, Path::new("PYI033.pyi"))] + #[test_case(Rule::SnakeCaseTypeAlias, Path::new("PYI042.py"))] + #[test_case(Rule::SnakeCaseTypeAlias, Path::new("PYI042.pyi"))] + #[test_case(Rule::TSuffixedTypeAlias, Path::new("PYI043.py"))] + #[test_case(Rule::TSuffixedTypeAlias, Path::new("PYI043.pyi"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); let diagnostics = test_path( diff --git a/crates/ruff/src/rules/flake8_pyi/rules/mod.rs b/crates/ruff/src/rules/flake8_pyi/rules/mod.rs index 62c72b628b7318..56019ab999c394 100644 --- a/crates/ruff/src/rules/flake8_pyi/rules/mod.rs +++ b/crates/ruff/src/rules/flake8_pyi/rules/mod.rs @@ -11,6 +11,9 @@ pub use simple_defaults::{ typed_argument_simple_defaults, ArgumentDefaultInStub, AssignmentDefaultInStub, TypedArgumentDefaultInStub, }; +pub use type_alias_naming::{ + snake_case_type_alias, t_suffixed_type_alias, SnakeCaseTypeAlias, TSuffixedTypeAlias, +}; pub use type_comment_in_stub::{type_comment_in_stub, TypeCommentInStub}; pub use unrecognized_platform::{ unrecognized_platform, UnrecognizedPlatformCheck, UnrecognizedPlatformName, @@ -25,5 +28,6 @@ mod pass_statement_stub_body; mod prefix_type_params; mod quoted_annotation_in_stub; mod simple_defaults; +mod type_alias_naming; mod type_comment_in_stub; mod unrecognized_platform; diff --git a/crates/ruff/src/rules/flake8_pyi/rules/type_alias_naming.rs b/crates/ruff/src/rules/flake8_pyi/rules/type_alias_naming.rs new file mode 100644 index 00000000000000..5ce2e1c7313f7e --- /dev/null +++ b/crates/ruff/src/rules/flake8_pyi/rules/type_alias_naming.rs @@ -0,0 +1,91 @@ +use rustpython_parser::ast::{Expr, ExprKind}; + +use ruff_diagnostics::{Diagnostic, Violation}; +use ruff_macros::{derive_message_formats, violation}; + +use crate::checkers::ast::Checker; + +#[violation] +pub struct SnakeCaseTypeAlias { + pub name: String, +} + +impl Violation for SnakeCaseTypeAlias { + #[derive_message_formats] + fn message(&self) -> String { + let Self { name } = self; + format!("Type alias `{name}` should be CamelCase") + } +} + +#[violation] +pub struct TSuffixedTypeAlias { + pub name: String, +} + +impl Violation for TSuffixedTypeAlias { + #[derive_message_formats] + fn message(&self) -> String { + let Self { name } = self; + format!("Private type alias `{name}` should not be suffixed with `T` (the `T` suffix implies that an object is a `TypeVar`)") + } +} + +/// Return `true` if the given name is a `snake_case` type alias. In this context, we match against +/// any name that begins with an optional underscore, followed by at least one lowercase letter. +fn is_snake_case_type_alias(name: &str) -> bool { + let mut chars = name.chars(); + matches!( + (chars.next(), chars.next()), + (Some('_'), Some('0'..='9' | 'a'..='z')) | (Some('0'..='9' | 'a'..='z'), ..) + ) +} + +/// Return `true` if the given name is a T-suffixed type alias. In this context, we match against +/// any name that begins with an underscore, and ends in a lowercase letter, followed by `T`, +/// followed by an optional digit. +fn is_t_suffixed_type_alias(name: &str) -> bool { + // A T-suffixed, private type alias must begin with an underscore. + if !name.starts_with('_') { + return false; + } + + // It must end in a lowercase letter, followed by `T`, and (optionally) a digit. + let mut chars = name.chars().rev(); + matches!( + (chars.next(), chars.next(), chars.next()), + (Some('0'..='9'), Some('T'), Some('a'..='z')) | (Some('T'), Some('a'..='z'), _) + ) +} + +/// PYI042 +pub fn snake_case_type_alias(checker: &mut Checker, target: &Expr) { + if let ExprKind::Name { id, .. } = target.node() { + if !is_snake_case_type_alias(id) { + return; + } + + checker.diagnostics.push(Diagnostic::new( + SnakeCaseTypeAlias { + name: id.to_string(), + }, + target.range(), + )); + } +} + +/// PYI043 +pub fn t_suffixed_type_alias(checker: &mut Checker, target: &Expr) { + if let ExprKind::Name { id, .. } = target.node() { + if !is_t_suffixed_type_alias(id) { + return; + } + + checker.diagnostics.push(Diagnostic::new( + TSuffixedTypeAlias { + name: id.to_string(), + }, + target.range(), + )); + } +} diff --git a/crates/ruff/src/rules/flake8_pyi/snapshots/ruff__rules__flake8_pyi__tests__PYI042_PYI042.py.snap b/crates/ruff/src/rules/flake8_pyi/snapshots/ruff__rules__flake8_pyi__tests__PYI042_PYI042.py.snap new file mode 100644 index 00000000000000..d1aa2e91165585 --- /dev/null +++ b/crates/ruff/src/rules/flake8_pyi/snapshots/ruff__rules__flake8_pyi__tests__PYI042_PYI042.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__PYI042_PYI042.pyi.snap b/crates/ruff/src/rules/flake8_pyi/snapshots/ruff__rules__flake8_pyi__tests__PYI042_PYI042.pyi.snap new file mode 100644 index 00000000000000..93b8949eae238c --- /dev/null +++ b/crates/ruff/src/rules/flake8_pyi/snapshots/ruff__rules__flake8_pyi__tests__PYI042_PYI042.pyi.snap @@ -0,0 +1,32 @@ +--- +source: crates/ruff/src/rules/flake8_pyi/mod.rs +--- +PYI042.pyi:10:1: PYI042 Type alias `just_literals_pipe_union` should be CamelCase + | +10 | ) +11 | +12 | just_literals_pipe_union: TypeAlias = ( + | ^^^^^^^^^^^^^^^^^^^^^^^^ PYI042 +13 | Literal[True] | Literal["idk"] +14 | ) # PYI042, since not camel case + | + +PYI042.pyi:19:1: PYI042 Type alias `snake_case_alias1` should be CamelCase + | +19 | _PrivateAliasS2: TypeAlias = Annotated[str, "also okay"] +20 | +21 | snake_case_alias1: TypeAlias = str | int # PYI042, since not camel case + | ^^^^^^^^^^^^^^^^^ PYI042 +22 | _snake_case_alias2: TypeAlias = Literal["whatever"] # PYI042, since not camel case +23 | Snake_case_alias: TypeAlias = int | float # PYI042, since not camel case + | + +PYI042.pyi:20:1: PYI042 Type alias `_snake_case_alias2` should be CamelCase + | +20 | snake_case_alias1: TypeAlias = str | int # PYI042, since not camel case +21 | _snake_case_alias2: TypeAlias = Literal["whatever"] # PYI042, since not camel case + | ^^^^^^^^^^^^^^^^^^ PYI042 +22 | Snake_case_alias: TypeAlias = int | float # PYI042, since not camel case + | + + diff --git a/crates/ruff/src/rules/flake8_pyi/snapshots/ruff__rules__flake8_pyi__tests__PYI043_PYI043.py.snap b/crates/ruff/src/rules/flake8_pyi/snapshots/ruff__rules__flake8_pyi__tests__PYI043_PYI043.py.snap new file mode 100644 index 00000000000000..d1aa2e91165585 --- /dev/null +++ b/crates/ruff/src/rules/flake8_pyi/snapshots/ruff__rules__flake8_pyi__tests__PYI043_PYI043.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__PYI043_PYI043.pyi.snap b/crates/ruff/src/rules/flake8_pyi/snapshots/ruff__rules__flake8_pyi__tests__PYI043_PYI043.pyi.snap new file mode 100644 index 00000000000000..dd42944ea4a916 --- /dev/null +++ b/crates/ruff/src/rules/flake8_pyi/snapshots/ruff__rules__flake8_pyi__tests__PYI043_PYI043.pyi.snap @@ -0,0 +1,33 @@ +--- +source: crates/ruff/src/rules/flake8_pyi/mod.rs +--- +PYI043.pyi:10:1: PYI043 Private type alias `_PrivateAliasT` should not be suffixed with `T` (the `T` suffix implies that an object is a `TypeVar`) + | +10 | ) +11 | +12 | _PrivateAliasT: TypeAlias = str | int # PYI043, since this ends in a T + | ^^^^^^^^^^^^^^ PYI043 +13 | _PrivateAliasT2: TypeAlias = typing.Any # PYI043, since this ends in a T +14 | _PrivateAliasT3: TypeAlias = Literal[ + | + +PYI043.pyi:11:1: PYI043 Private type alias `_PrivateAliasT2` should not be suffixed with `T` (the `T` suffix implies that an object is a `TypeVar`) + | +11 | _PrivateAliasT: TypeAlias = str | int # PYI043, since this ends in a T +12 | _PrivateAliasT2: TypeAlias = typing.Any # PYI043, since this ends in a T + | ^^^^^^^^^^^^^^^ PYI043 +13 | _PrivateAliasT3: TypeAlias = Literal[ +14 | "not", "a", "chance" + | + +PYI043.pyi:12:1: PYI043 Private type alias `_PrivateAliasT3` should not be suffixed with `T` (the `T` suffix implies that an object is a `TypeVar`) + | +12 | _PrivateAliasT: TypeAlias = str | int # PYI043, since this ends in a T +13 | _PrivateAliasT2: TypeAlias = typing.Any # PYI043, since this ends in a T +14 | _PrivateAliasT3: TypeAlias = Literal[ + | ^^^^^^^^^^^^^^^ PYI043 +15 | "not", "a", "chance" +16 | ] # PYI043, since this ends in a T + | + + diff --git a/ruff.schema.json b/ruff.schema.json index 64d99d10f240fa..76979c344659a8 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -2128,6 +2128,9 @@ "PYI021", "PYI03", "PYI033", + "PYI04", + "PYI042", + "PYI043", "Q", "Q0", "Q00",