diff --git a/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP006_4.py b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP006_4.py new file mode 100644 index 0000000000000..782ef95f5ed92 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP006_4.py @@ -0,0 +1,10 @@ +import typing +from typing import List + + +def f(x: typing.List[str]) -> None: + ... + + +def g(x: List[str]) -> None: + ... diff --git a/crates/ruff_linter/src/checkers/ast/analyze/expression.rs b/crates/ruff_linter/src/checkers/ast/analyze/expression.rs index e856ce0c6f531..26472001fec9e 100644 --- a/crates/ruff_linter/src/checkers/ast/analyze/expression.rs +++ b/crates/ruff_linter/src/checkers/ast/analyze/expression.rs @@ -7,7 +7,9 @@ use ruff_python_semantic::analyze::typing; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; -use crate::preview::is_future_required_preview_generics_enabled; +use crate::preview::{ + is_future_required_preview_generics_enabled, is_up006_future_annotations_fix_enabled, +}; use crate::registry::Rule; use crate::rules::{ airflow, flake8_2020, flake8_async, flake8_bandit, flake8_boolean_trap, flake8_bugbear, @@ -307,6 +309,11 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) { && checker.target_version() >= PythonVersion::PY37 && checker.semantic.in_annotation() && !checker.settings().pyupgrade.keep_runtime_typing + && !(checker.is_rule_enabled(Rule::NonPEP585Annotation) + && is_up006_future_annotations_fix_enabled( + checker.settings(), + ) + && checker.settings().future_annotations) { flake8_future_annotations::rules::future_rewritable_type_annotation(checker, expr); } @@ -315,7 +322,10 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) { if checker.source_type.is_stub() || checker.target_version() >= PythonVersion::PY39 || (checker.target_version() >= PythonVersion::PY37 - && checker.semantic.future_annotations_or_stub() + && (checker.semantic.future_annotations_or_stub() + || (is_up006_future_annotations_fix_enabled( + checker.settings(), + ) && checker.settings().future_annotations)) && checker.semantic.in_annotation() && !checker.settings().pyupgrade.keep_runtime_typing) { @@ -414,6 +424,9 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) { && checker.target_version() >= PythonVersion::PY37 && checker.semantic.in_annotation() && !checker.settings().pyupgrade.keep_runtime_typing + && !(checker.is_rule_enabled(Rule::NonPEP585Annotation) + && is_up006_future_annotations_fix_enabled(checker.settings()) + && checker.settings().future_annotations) { flake8_future_annotations::rules::future_rewritable_type_annotation( checker, expr, @@ -424,7 +437,10 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) { if checker.source_type.is_stub() || checker.target_version() >= PythonVersion::PY39 || (checker.target_version() >= PythonVersion::PY37 - && checker.semantic.future_annotations_or_stub() + && (checker.semantic.future_annotations_or_stub() + || (is_up006_future_annotations_fix_enabled( + checker.settings(), + ) && checker.settings().future_annotations)) && checker.semantic.in_annotation() && !checker.settings().pyupgrade.keep_runtime_typing) { diff --git a/crates/ruff_linter/src/preview.rs b/crates/ruff_linter/src/preview.rs index 0a4afc6bae0e4..f0be5463ca1ff 100644 --- a/crates/ruff_linter/src/preview.rs +++ b/crates/ruff_linter/src/preview.rs @@ -314,3 +314,8 @@ pub(crate) const fn is_incorrect_dict_iterator_comprehension_enabled( ) -> bool { settings.preview.is_enabled() } + +// https://github.com/astral-sh/ruff/pull/23260 +pub(crate) const fn is_up006_future_annotations_fix_enabled(settings: &LinterSettings) -> bool { + settings.preview.is_enabled() +} diff --git a/crates/ruff_linter/src/rules/pyupgrade/mod.rs b/crates/ruff_linter/src/rules/pyupgrade/mod.rs index 947aef26c632a..a4340bd604e71 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/mod.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/mod.rs @@ -128,6 +128,76 @@ mod tests { Ok(()) } + /// Test that enabling preview switches from `FA100` to `UP006` when `future-annotations` is on. + #[test] + fn up006_preview_with_fa100() -> Result<()> { + assert_diagnostics_diff!( + Path::new("pyupgrade/UP006_4.py"), + &settings::LinterSettings { + future_annotations: true, + preview: PreviewMode::Disabled, + unresolved_target_version: PythonVersion::PY38.into(), + ..settings::LinterSettings::for_rules(vec![ + Rule::NonPEP585Annotation, + Rule::FutureRewritableTypeAnnotation + ]) + }, + &settings::LinterSettings { + future_annotations: true, + preview: PreviewMode::Enabled, + unresolved_target_version: PythonVersion::PY38.into(), + ..settings::LinterSettings::for_rules(vec![ + Rule::NonPEP585Annotation, + Rule::FutureRewritableTypeAnnotation + ]) + }, + ); + Ok(()) + } + + /// Test that `FA100` fires when added alongside `UP006` in preview on 3.8 with + /// `future-annotations` disabled. + #[test] + fn up006_preview_with_fa100_no_future_annotations_setting() -> Result<()> { + assert_diagnostics_diff!( + Path::new("pyupgrade/UP006_4.py"), + &settings::LinterSettings { + preview: PreviewMode::Enabled, + unresolved_target_version: PythonVersion::PY38.into(), + ..settings::LinterSettings::for_rule(Rule::NonPEP585Annotation) + }, + &settings::LinterSettings { + preview: PreviewMode::Enabled, + unresolved_target_version: PythonVersion::PY38.into(), + ..settings::LinterSettings::for_rules(vec![ + Rule::NonPEP585Annotation, + Rule::FutureRewritableTypeAnnotation, + ]) + }, + ); + Ok(()) + } + + /// On 3.10, the `__future__` import is unnecessary from either the `future-annotations` setting + /// or from FA100. + #[test] + fn up006_preview_with_fa100_and_future_annotations_py39() -> Result<()> { + let diagnostics = test_path( + Path::new("pyupgrade/UP006_4.py"), + &settings::LinterSettings { + preview: PreviewMode::Enabled, + future_annotations: true, + unresolved_target_version: PythonVersion::PY310.into(), + ..settings::LinterSettings::for_rules(vec![ + Rule::NonPEP585Annotation, + Rule::FutureRewritableTypeAnnotation, + ]) + }, + )?; + assert_diagnostics!(diagnostics); + Ok(()) + } + #[test_case(Rule::NonPEP695GenericClass, Path::new("UP046_2.py"))] #[test_case(Rule::NonPEP695GenericFunction, Path::new("UP047_1.py"))] fn rules_not_applied_default_typevar_backported(rule_code: Rule, path: &Path) -> Result<()> { diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/use_pep585_annotation.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/use_pep585_annotation.rs index 420768f1eda66..458be3099031f 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/use_pep585_annotation.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/use_pep585_annotation.rs @@ -1,14 +1,15 @@ use ruff_python_ast::Expr; use ruff_macros::{ViolationMetadata, derive_message_formats}; +use ruff_python_ast::PythonVersion; use ruff_python_ast::name::UnqualifiedName; use ruff_python_semantic::analyze::typing::ModuleMember; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; use crate::importer::ImportRequest; +use crate::preview::is_up006_future_annotations_fix_enabled; use crate::{Applicability, Edit, Fix, FixAvailability, Violation}; -use ruff_python_ast::PythonVersion; /// ## What it does /// Checks for the use of generics that can be replaced with standard library @@ -49,11 +50,17 @@ use ruff_python_ast::PythonVersion; /// alongside libraries that rely on runtime type annotations, like Pydantic, /// on Python versions prior to Python 3.9. /// +/// In [preview], this rule can also add its own `__future__` import on Python +/// 3.9 and earlier, if the [`lint.future-annotations`] setting is enabled. This +/// also makes the fix unsafe. +/// /// ## Options /// - `target-version` /// - `lint.pyupgrade.keep-runtime-typing` +/// - `lint.future-annotations` /// /// [PEP 585]: https://peps.python.org/pep-0585/ +/// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] #[violation_metadata(stable_since = "v0.0.155")] pub(crate) struct NonPEP585Annotation { @@ -89,6 +96,25 @@ pub(crate) fn use_pep585_annotation(checker: &Checker, expr: &Expr, replacement: expr.range(), ); if !checker.semantic().in_complex_string_type_definition() { + let future_import = if checker.target_version() < PythonVersion::PY310 + && is_up006_future_annotations_fix_enabled(checker.settings()) + && checker.settings().future_annotations + && !checker.semantic().future_annotations_or_stub() + { + Some(checker.importer().add_future_import()) + } else { + None + }; + + // Adding `from __future__ import annotations` changes runtime behavior for all + // annotations in the file, so those fixes are always unsafe. Without a future + // import, the original applicability applies: safe only on Python 3.10+. + let applicability = + if future_import.is_none() && checker.target_version() >= PythonVersion::PY310 { + Applicability::Safe + } else { + Applicability::Unsafe + }; match replacement { ModuleMember::BuiltIn(name) => { // Built-in type, like `list`. @@ -99,14 +125,9 @@ pub(crate) fn use_pep585_annotation(checker: &Checker, expr: &Expr, replacement: checker.semantic(), )?; let binding_edit = Edit::range_replacement(binding, expr.range()); - let applicability = if checker.target_version() >= PythonVersion::PY310 { - Applicability::Safe - } else { - Applicability::Unsafe - }; Ok(Fix::applicable_edits( binding_edit, - import_edit, + import_edit.into_iter().chain(future_import), applicability, )) }); @@ -121,13 +142,9 @@ pub(crate) fn use_pep585_annotation(checker: &Checker, expr: &Expr, replacement: )?; let reference_edit = Edit::range_replacement(binding, expr.range()); Ok(Fix::applicable_edits( - import_edit, - [reference_edit], - if checker.target_version() >= PythonVersion::PY310 { - Applicability::Safe - } else { - Applicability::Unsafe - }, + reference_edit, + std::iter::once(import_edit).chain(future_import), + applicability, )) }); } diff --git a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__up006_preview_with_fa100.snap b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__up006_preview_with_fa100.snap new file mode 100644 index 0000000000000..f5f0189802f65 --- /dev/null +++ b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__up006_preview_with_fa100.snap @@ -0,0 +1,85 @@ +--- +source: crates/ruff_linter/src/rules/pyupgrade/mod.rs +--- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 2 +Added: 2 + +--- Removed --- +FA100 [*] Add `from __future__ import annotations` to simplify `typing.List` + --> UP006_4.py:5:10 + | +5 | def f(x: typing.List[str]) -> None: + | ^^^^^^^^^^^ +6 | ... + | +help: Add `from __future__ import annotations` +1 + from __future__ import annotations +2 | import typing +3 | from typing import List +4 | +note: This is an unsafe fix and may change runtime behavior + + +FA100 [*] Add `from __future__ import annotations` to simplify `typing.List` + --> UP006_4.py:9:10 + | + 9 | def g(x: List[str]) -> None: + | ^^^^ +10 | ... + | +help: Add `from __future__ import annotations` +1 + from __future__ import annotations +2 | import typing +3 | from typing import List +4 | +note: This is an unsafe fix and may change runtime behavior + + + +--- Added --- +UP006 [*] Use `list` instead of `typing.List` for type annotation + --> UP006_4.py:5:10 + | +5 | def f(x: typing.List[str]) -> None: + | ^^^^^^^^^^^ +6 | ... + | +help: Replace with `list` +1 + from __future__ import annotations +2 | import typing +3 | from typing import List +4 | +5 | + - def f(x: typing.List[str]) -> None: +6 + def f(x: list[str]) -> None: +7 | ... +8 | +9 | +note: This is an unsafe fix and may change runtime behavior + + +UP006 [*] Use `list` instead of `List` for type annotation + --> UP006_4.py:9:10 + | + 9 | def g(x: List[str]) -> None: + | ^^^^ +10 | ... + | +help: Replace with `list` +1 + from __future__ import annotations +2 | import typing +3 | from typing import List +4 | +-------------------------------------------------------------------------------- +7 | ... +8 | +9 | + - def g(x: List[str]) -> None: +10 + def g(x: list[str]) -> None: +11 | ... +note: This is an unsafe fix and may change runtime behavior diff --git a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__up006_preview_with_fa100_and_future_annotations_py39.snap b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__up006_preview_with_fa100_and_future_annotations_py39.snap new file mode 100644 index 0000000000000..99f188efaaf3a --- /dev/null +++ b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__up006_preview_with_fa100_and_future_annotations_py39.snap @@ -0,0 +1,34 @@ +--- +source: crates/ruff_linter/src/rules/pyupgrade/mod.rs +--- +UP006 [*] Use `list` instead of `typing.List` for type annotation + --> UP006_4.py:5:10 + | +5 | def f(x: typing.List[str]) -> None: + | ^^^^^^^^^^^ +6 | ... + | +help: Replace with `list` +2 | from typing import List +3 | +4 | + - def f(x: typing.List[str]) -> None: +5 + def f(x: list[str]) -> None: +6 | ... +7 | +8 | + +UP006 [*] Use `list` instead of `List` for type annotation + --> UP006_4.py:9:10 + | + 9 | def g(x: List[str]) -> None: + | ^^^^ +10 | ... + | +help: Replace with `list` +6 | ... +7 | +8 | + - def g(x: List[str]) -> None: +9 + def g(x: list[str]) -> None: +10 | ... diff --git a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__up006_preview_with_fa100_no_future_annotations_setting.snap b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__up006_preview_with_fa100_no_future_annotations_setting.snap new file mode 100644 index 0000000000000..34241a23a3aee --- /dev/null +++ b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__up006_preview_with_fa100_no_future_annotations_setting.snap @@ -0,0 +1,40 @@ +--- +source: crates/ruff_linter/src/rules/pyupgrade/mod.rs +--- +--- Linter settings --- ++ future-rewritable-type-annotation (FA100), ++ future-rewritable-type-annotation (FA100), + +--- Summary --- +Removed: 0 +Added: 2 + +--- Added --- +FA100 [*] Add `from __future__ import annotations` to simplify `typing.List` + --> UP006_4.py:5:10 + | +5 | def f(x: typing.List[str]) -> None: + | ^^^^^^^^^^^ +6 | ... + | +help: Add `from __future__ import annotations` +1 + from __future__ import annotations +2 | import typing +3 | from typing import List +4 | +note: This is an unsafe fix and may change runtime behavior + + +FA100 [*] Add `from __future__ import annotations` to simplify `typing.List` + --> UP006_4.py:9:10 + | + 9 | def g(x: List[str]) -> None: + | ^^^^ +10 | ... + | +help: Add `from __future__ import annotations` +1 + from __future__ import annotations +2 | import typing +3 | from typing import List +4 | +note: This is an unsafe fix and may change runtime behavior