diff --git a/crates/ruff/resources/test/fixtures/pyflakes/F401_15.py b/crates/ruff/resources/test/fixtures/pyflakes/F401_15.py new file mode 100644 index 0000000000000..ac4b90b2c4cb4 --- /dev/null +++ b/crates/ruff/resources/test/fixtures/pyflakes/F401_15.py @@ -0,0 +1,9 @@ +from typing import TYPE_CHECKING +from django.db.models import ForeignKey + +if TYPE_CHECKING: + from pathlib import Path + + +class Foo: + var = ForeignKey["Path"]() diff --git a/crates/ruff/src/checkers/ast/mod.rs b/crates/ruff/src/checkers/ast/mod.rs index 0dbde7739c0e0..6a2da1c5f0f91 100644 --- a/crates/ruff/src/checkers/ast/mod.rs +++ b/crates/ruff/src/checkers/ast/mod.rs @@ -3866,6 +3866,7 @@ where value, &self.semantic_model, self.settings.typing_modules.iter().map(String::as_str), + &self.settings.pyflakes.extend_generics, ) { Some(subscript) => { match subscript { diff --git a/crates/ruff/src/rules/pyflakes/mod.rs b/crates/ruff/src/rules/pyflakes/mod.rs index b197dd6d628de..bcaea7222230f 100644 --- a/crates/ruff/src/rules/pyflakes/mod.rs +++ b/crates/ruff/src/rules/pyflakes/mod.rs @@ -3,6 +3,7 @@ pub(crate) mod cformat; pub(crate) mod fixes; pub(crate) mod format; pub(crate) mod rules; +pub mod settings; #[cfg(test)] mod tests { @@ -19,7 +20,7 @@ mod tests { use crate::linter::{check_path, LinterResult}; use crate::registry::{AsRule, Linter, Rule}; - use crate::settings::flags; + use crate::settings::{flags, Settings}; use crate::test::test_path; use crate::{assert_messages, directives, settings}; @@ -38,6 +39,7 @@ mod tests { #[test_case(Rule::UnusedImport, Path::new("F401_12.py"))] #[test_case(Rule::UnusedImport, Path::new("F401_13.py"))] #[test_case(Rule::UnusedImport, Path::new("F401_14.py"))] + #[test_case(Rule::UnusedImport, Path::new("F401_15.py"))] #[test_case(Rule::ImportShadowedByLoopVar, Path::new("F402.py"))] #[test_case(Rule::UndefinedLocalWithImportStar, Path::new("F403.py"))] #[test_case(Rule::LateFutureImport, Path::new("F404.py"))] @@ -252,6 +254,22 @@ mod tests { Ok(()) } + #[test] + fn extend_generics() -> Result<()> { + let snapshot = "extend_immutable_calls".to_string(); + let diagnostics = test_path( + Path::new("pyflakes/F401_15.py"), + &Settings { + pyflakes: super::settings::Settings { + extend_generics: vec!["django.db.models.ForeignKey".to_string()], + }, + ..Settings::for_rules(vec![Rule::UnusedImport]) + }, + )?; + assert_messages!(snapshot, diagnostics); + Ok(()) + } + /// A re-implementation of the Pyflakes test runner. /// Note that all tests marked with `#[ignore]` should be considered TODOs. fn flakes(contents: &str, expected: &[Rule]) { diff --git a/crates/ruff/src/rules/pyflakes/rules/imports.rs b/crates/ruff/src/rules/pyflakes/rules/imports.rs index 373beefc6bb9d..a61ae7d4daed0 100644 --- a/crates/ruff/src/rules/pyflakes/rules/imports.rs +++ b/crates/ruff/src/rules/pyflakes/rules/imports.rs @@ -25,6 +25,10 @@ pub(crate) enum UnusedImportContext { /// If an import statement is used to check for the availability or existence /// of a module, consider using `importlib.util.find_spec` instead. /// +/// ## Options +/// +/// - `pyflakes.extend-generics` +/// /// ## Example /// ```python /// import numpy as np # unused import diff --git a/crates/ruff/src/rules/pyflakes/settings.rs b/crates/ruff/src/rules/pyflakes/settings.rs new file mode 100644 index 0000000000000..edf6150b1c3cb --- /dev/null +++ b/crates/ruff/src/rules/pyflakes/settings.rs @@ -0,0 +1,47 @@ +//! Settings for the `Pyflakes` plugin. + +use serde::{Deserialize, Serialize}; + +use ruff_macros::{CacheKey, CombineOptions, ConfigurationOptions}; + +#[derive( + Debug, PartialEq, Eq, Default, Serialize, Deserialize, ConfigurationOptions, CombineOptions, +)] +#[serde( + deny_unknown_fields, + rename_all = "kebab-case", + rename = "PyflakesOptions" +)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct Options { + #[option( + default = r#"[]"#, + value_type = "list[str]", + example = "extend-generics = [\"django.db.models.ForeignKey\"]" + )] + /// Additional functions or classes to consider generic, such that any + /// subscripts should be treated as type annotation (e.g., `ForeignKey` in + /// `django.db.models.ForeignKey["User"]`. + pub extend_generics: Option>, +} + +#[derive(Debug, Default, CacheKey)] +pub struct Settings { + pub extend_generics: Vec, +} + +impl From for Settings { + fn from(options: Options) -> Self { + Self { + extend_generics: options.extend_generics.unwrap_or_default(), + } + } +} + +impl From for Options { + fn from(settings: Settings) -> Self { + Self { + extend_generics: Some(settings.extend_generics), + } + } +} diff --git a/crates/ruff/src/rules/pyflakes/snapshots/ruff__rules__pyflakes__tests__F401_F401_15.py.snap b/crates/ruff/src/rules/pyflakes/snapshots/ruff__rules__pyflakes__tests__F401_F401_15.py.snap new file mode 100644 index 0000000000000..0110ad1ae74cb --- /dev/null +++ b/crates/ruff/src/rules/pyflakes/snapshots/ruff__rules__pyflakes__tests__F401_F401_15.py.snap @@ -0,0 +1,22 @@ +--- +source: crates/ruff/src/rules/pyflakes/mod.rs +--- +F401_15.py:5:25: F401 [*] `pathlib.Path` imported but unused + | +5 | if TYPE_CHECKING: +6 | from pathlib import Path + | ^^^^ F401 + | + = help: Remove unused import: `pathlib.Path` + +ℹ Suggested fix +2 2 | from django.db.models import ForeignKey +3 3 | +4 4 | if TYPE_CHECKING: +5 |- from pathlib import Path + 5 |+ pass +6 6 | +7 7 | +8 8 | class Foo: + + diff --git a/crates/ruff/src/rules/pyflakes/snapshots/ruff__rules__pyflakes__tests__extend_immutable_calls.snap b/crates/ruff/src/rules/pyflakes/snapshots/ruff__rules__pyflakes__tests__extend_immutable_calls.snap new file mode 100644 index 0000000000000..1976c4331d419 --- /dev/null +++ b/crates/ruff/src/rules/pyflakes/snapshots/ruff__rules__pyflakes__tests__extend_immutable_calls.snap @@ -0,0 +1,4 @@ +--- +source: crates/ruff/src/rules/pyflakes/mod.rs +--- + diff --git a/crates/ruff/src/settings/configuration.rs b/crates/ruff/src/settings/configuration.rs index 86212fd45e17f..16acabe30a6c6 100644 --- a/crates/ruff/src/settings/configuration.rs +++ b/crates/ruff/src/settings/configuration.rs @@ -19,7 +19,7 @@ use crate::rules::{ flake8_annotations, flake8_bandit, flake8_bugbear, flake8_builtins, flake8_comprehensions, flake8_errmsg, flake8_gettext, flake8_implicit_str_concat, flake8_import_conventions, flake8_pytest_style, flake8_quotes, flake8_self, flake8_tidy_imports, flake8_type_checking, - flake8_unused_arguments, isort, mccabe, pep8_naming, pycodestyle, pydocstyle, pylint, + flake8_unused_arguments, isort, mccabe, pep8_naming, pycodestyle, pydocstyle, pyflakes, pylint, }; use crate::settings::options::Options; use crate::settings::types::{ @@ -89,6 +89,7 @@ pub struct Configuration { pub pep8_naming: Option, pub pycodestyle: Option, pub pydocstyle: Option, + pub pyflakes: Option, pub pylint: Option, } @@ -241,6 +242,7 @@ impl Configuration { pep8_naming: options.pep8_naming, pycodestyle: options.pycodestyle, pydocstyle: options.pydocstyle, + pyflakes: options.pyflakes, pylint: options.pylint, }) } @@ -326,6 +328,7 @@ impl Configuration { pep8_naming: self.pep8_naming.combine(config.pep8_naming), pycodestyle: self.pycodestyle.combine(config.pycodestyle), pydocstyle: self.pydocstyle.combine(config.pydocstyle), + pyflakes: self.pyflakes.combine(config.pyflakes), pylint: self.pylint.combine(config.pylint), } } diff --git a/crates/ruff/src/settings/defaults.rs b/crates/ruff/src/settings/defaults.rs index 80ae6a0956326..aa5007789d58d 100644 --- a/crates/ruff/src/settings/defaults.rs +++ b/crates/ruff/src/settings/defaults.rs @@ -13,7 +13,7 @@ use crate::rules::{ flake8_annotations, flake8_bandit, flake8_bugbear, flake8_builtins, flake8_comprehensions, flake8_errmsg, flake8_gettext, flake8_implicit_str_concat, flake8_import_conventions, flake8_pytest_style, flake8_quotes, flake8_self, flake8_tidy_imports, flake8_type_checking, - flake8_unused_arguments, isort, mccabe, pep8_naming, pycodestyle, pydocstyle, pylint, + flake8_unused_arguments, isort, mccabe, pep8_naming, pycodestyle, pydocstyle, pyflakes, pylint, }; use crate::settings::types::FilePatternSet; @@ -110,6 +110,7 @@ impl Default for Settings { pep8_naming: pep8_naming::settings::Settings::default(), pycodestyle: pycodestyle::settings::Settings::default(), pydocstyle: pydocstyle::settings::Settings::default(), + pyflakes: pyflakes::settings::Settings::default(), pylint: pylint::settings::Settings::default(), } } diff --git a/crates/ruff/src/settings/mod.rs b/crates/ruff/src/settings/mod.rs index 2f31b6d221521..453a93dac5371 100644 --- a/crates/ruff/src/settings/mod.rs +++ b/crates/ruff/src/settings/mod.rs @@ -19,7 +19,7 @@ use crate::rules::{ flake8_annotations, flake8_bandit, flake8_bugbear, flake8_builtins, flake8_comprehensions, flake8_errmsg, flake8_gettext, flake8_implicit_str_concat, flake8_import_conventions, flake8_pytest_style, flake8_quotes, flake8_self, flake8_tidy_imports, flake8_type_checking, - flake8_unused_arguments, isort, mccabe, pep8_naming, pycodestyle, pydocstyle, pylint, + flake8_unused_arguments, isort, mccabe, pep8_naming, pycodestyle, pydocstyle, pyflakes, pylint, }; use crate::settings::configuration::Configuration; use crate::settings::types::{FilePatternSet, PerFileIgnore, PythonVersion, SerializationFormat}; @@ -126,6 +126,7 @@ pub struct Settings { pub pep8_naming: pep8_naming::settings::Settings, pub pycodestyle: pycodestyle::settings::Settings, pub pydocstyle: pydocstyle::settings::Settings, + pub pyflakes: pyflakes::settings::Settings, pub pylint: pylint::settings::Settings, } @@ -231,6 +232,7 @@ impl Settings { pep8_naming: config.pep8_naming.map(Into::into).unwrap_or_default(), pycodestyle: config.pycodestyle.map(Into::into).unwrap_or_default(), pydocstyle: config.pydocstyle.map(Into::into).unwrap_or_default(), + pyflakes: config.pyflakes.map(Into::into).unwrap_or_default(), pylint: config.pylint.map(Into::into).unwrap_or_default(), }) } diff --git a/crates/ruff/src/settings/options.rs b/crates/ruff/src/settings/options.rs index 7a78d31f36aac..589f35ad323dc 100644 --- a/crates/ruff/src/settings/options.rs +++ b/crates/ruff/src/settings/options.rs @@ -11,7 +11,7 @@ use crate::rules::{ flake8_annotations, flake8_bandit, flake8_bugbear, flake8_builtins, flake8_comprehensions, flake8_errmsg, flake8_gettext, flake8_implicit_str_concat, flake8_import_conventions, flake8_pytest_style, flake8_quotes, flake8_self, flake8_tidy_imports, flake8_type_checking, - flake8_unused_arguments, isort, mccabe, pep8_naming, pycodestyle, pydocstyle, pylint, + flake8_unused_arguments, isort, mccabe, pep8_naming, pycodestyle, pydocstyle, pyflakes, pylint, }; use crate::settings::types::{PythonVersion, SerializationFormat, Version}; @@ -539,6 +539,9 @@ pub struct Options { /// Options for the `pydocstyle` plugin. pub pydocstyle: Option, #[option_group] + /// Options for the `pyflakes` plugin. + pub pyflakes: Option, + #[option_group] /// Options for the `pylint` plugin. pub pylint: Option, // Tables are required to go last. diff --git a/crates/ruff_python_semantic/src/analyze/typing.rs b/crates/ruff_python_semantic/src/analyze/typing.rs index b4d006876981b..6e3ce7ad191cf 100644 --- a/crates/ruff_python_semantic/src/analyze/typing.rs +++ b/crates/ruff_python_semantic/src/analyze/typing.rs @@ -1,7 +1,7 @@ use rustpython_parser::ast::{self, Constant, Expr, Operator}; use num_traits::identities::Zero; -use ruff_python_ast::call_path::{from_unqualified_name, CallPath}; +use ruff_python_ast::call_path::{from_qualified_name, from_unqualified_name, CallPath}; use ruff_python_stdlib::typing::{ IMMUTABLE_GENERIC_TYPES, IMMUTABLE_TYPES, PEP_585_GENERICS, PEP_593_SUBSCRIPTS, SUBSCRIPTS, }; @@ -29,6 +29,7 @@ pub fn match_annotated_subscript<'a>( expr: &Expr, semantic_model: &SemanticModel, typing_modules: impl Iterator, + extend_generics: &[String], ) -> Option { if !matches!(expr, Expr::Name(_) | Expr::Attribute(_)) { return None; @@ -37,7 +38,12 @@ pub fn match_annotated_subscript<'a>( semantic_model .resolve_call_path(expr) .and_then(|call_path| { - if SUBSCRIPTS.contains(&call_path.as_slice()) { + if SUBSCRIPTS.contains(&call_path.as_slice()) + || extend_generics + .iter() + .map(|target| from_qualified_name(target)) + .any(|target| call_path == target) + { return Some(SubscriptKind::AnnotatedSubscript); } if PEP_593_SUBSCRIPTS.contains(&call_path.as_slice()) { diff --git a/crates/ruff_wasm/src/lib.rs b/crates/ruff_wasm/src/lib.rs index 9aeb656786726..84566771dd9cb 100644 --- a/crates/ruff_wasm/src/lib.rs +++ b/crates/ruff_wasm/src/lib.rs @@ -12,7 +12,7 @@ use ruff::rules::{ flake8_annotations, flake8_bandit, flake8_bugbear, flake8_builtins, flake8_comprehensions, flake8_errmsg, flake8_gettext, flake8_implicit_str_concat, flake8_import_conventions, flake8_pytest_style, flake8_quotes, flake8_self, flake8_tidy_imports, flake8_type_checking, - flake8_unused_arguments, isort, mccabe, pep8_naming, pycodestyle, pydocstyle, pylint, + flake8_unused_arguments, isort, mccabe, pep8_naming, pycodestyle, pydocstyle, pyflakes, pylint, }; use ruff::settings::configuration::Configuration; use ruff::settings::options::Options; @@ -158,6 +158,7 @@ pub fn defaultSettings() -> Result { pep8_naming: Some(pep8_naming::settings::Settings::default().into()), pycodestyle: Some(pycodestyle::settings::Settings::default().into()), pydocstyle: Some(pydocstyle::settings::Settings::default().into()), + pyflakes: Some(pyflakes::settings::Settings::default().into()), pylint: Some(pylint::settings::Settings::default().into()), })?) } diff --git a/ruff.schema.json b/ruff.schema.json index bd19906af2b99..bd1540db46195 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -442,6 +442,17 @@ } ] }, + "pyflakes": { + "description": "Options for the `pyflakes` plugin.", + "anyOf": [ + { + "$ref": "#/definitions/PyflakesOptions" + }, + { + "type": "null" + } + ] + }, "pylint": { "description": "Options for the `pylint` plugin.", "anyOf": [ @@ -1429,6 +1440,22 @@ }, "additionalProperties": false }, + "PyflakesOptions": { + "type": "object", + "properties": { + "extend-generics": { + "description": "Additional functions or classes to consider generic, such that any subscripts should be treated as type annotation (e.g., `ForeignKey` in `django.db.models.ForeignKey[\"User\"]`.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, "PylintOptions": { "type": "object", "properties": {