From 893f5727e55e9d6317adb8db4e84fe5d837b55bc Mon Sep 17 00:00:00 2001 From: Brent Westbrook <36778786+ntBre@users.noreply.github.com> Date: Wed, 16 Jul 2025 08:50:52 -0400 Subject: [PATCH] [`flake8-type-checking`, `pyupgrade`, `ruff`] Add `from __future__ import annotations` when it would allow new fixes (`TC001`, `TC002`, `TC003`, `UP037`, `RUF013`) (#19100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This is a second attempt at addressing https://github.com/astral-sh/ruff/issues/18502 instead of reusing `FA100` (#18919). This PR: - adds a new `lint.allow-importing-future-annotations` option - uses the option to add a `__future__` import when it would trigger `TC001`, `TC002`, or `TC003` - uses the option to add an import when it would allow unquoting more annotations in [quoted-annotation (UP037)](https://docs.astral.sh/ruff/rules/quoted-annotation/#quoted-annotation-up037) - uses the option to allow the `|` union syntax before 3.10 in [implicit-optional (RUF013)](https://docs.astral.sh/ruff/rules/implicit-optional/#implicit-optional-ruf013) I started adding a fix for [runtime-string-union (TC010)](https://docs.astral.sh/ruff/rules/runtime-string-union/#runtime-string-union-tc010) too, as mentioned in my previous [comment](https://github.com/astral-sh/ruff/issues/18502#issuecomment-3005238092), but some of the existing tests already imported `from __future__ import annotations`, so I think we intentionally flag these cases for the user to inspect. Adding the import is _a_ fix but probably not the best one. ## Test Plan Existing `TC` tests, new copies of them with the option enabled, and new tests based on ideas in https://github.com/astral-sh/ruff/pull/18919#discussion_r2166292705 and the following thread. For UP037 and RUF013, the new tests are also copies of the existing tests, with the new option enabled. The easiest way to review them is probably by their diffs from the existing snapshots: ### UP037 `UP037_0.py` and `UP037_2.pyi` have no diffs. The diff for `UP037_1.py` is below. It correctly unquotes an annotation in module scope that would otherwise be invalid.
UP037_1.py ```diff 3d2 < snapshot_kind: text 23c22,42 < 12 12 | --- > 12 12 | > > UP037_1.py:14:4: UP037 [*] Remove quotes from type annotation > | > 13 | # OK > 14 | X: "Tuple[int, int]" = (0, 0) > | ^^^^^^^^^^^^^^^^^ UP037 > | > = help: Remove quotes > > ℹ Unsafe fix > 1 |+from __future__ import annotations > 1 2 | from typing import TYPE_CHECKING > 2 3 | > 3 4 | if TYPE_CHECKING: > -------------------------------------------------------------------------------- > 11 12 | > 12 13 | > 13 14 | # OK > 14 |-X: "Tuple[int, int]" = (0, 0) > 15 |+X: Tuple[int, int] = (0, 0) ```
### RUF013 The diffs here are mostly just the imports because the original snaps were on 3.13. So we're getting the same fixes now on 3.9.
RUF013_0.py ```diff 3d2 < snapshot_kind: text 14,16c13,20 < 17 17 | pass < 18 18 | < 19 19 | --- > 1 |+from __future__ import annotations > 1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable > 2 3 | > 3 4 | > -------------------------------------------------------------------------------- > 17 18 | pass > 18 19 | > 19 20 | 18,21c22,25 < 20 |+def f(arg: int | None = None): # RUF013 < 21 21 | pass < 22 22 | < 23 23 | --- > 21 |+def f(arg: int | None = None): # RUF013 > 21 22 | pass > 22 23 | > 23 24 | 32,34c36,43 < 21 21 | pass < 22 22 | < 23 23 | --- > 1 |+from __future__ import annotations > 1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable > 2 3 | > 3 4 | > -------------------------------------------------------------------------------- > 21 22 | pass > 22 23 | > 23 24 | 36,39c45,48 < 24 |+def f(arg: str | None = None): # RUF013 < 25 25 | pass < 26 26 | < 27 27 | --- > 25 |+def f(arg: str | None = None): # RUF013 > 25 26 | pass > 26 27 | > 27 28 | 50,52c59,66 < 25 25 | pass < 26 26 | < 27 27 | --- > 1 |+from __future__ import annotations > 1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable > 2 3 | > 3 4 | > -------------------------------------------------------------------------------- > 25 26 | pass > 26 27 | > 27 28 | 54,57c68,71 < 28 |+def f(arg: Tuple[str] | None = None): # RUF013 < 29 29 | pass < 30 30 | < 31 31 | --- > 29 |+def f(arg: Tuple[str] | None = None): # RUF013 > 29 30 | pass > 30 31 | > 31 32 | 68,70c82,89 < 55 55 | pass < 56 56 | < 57 57 | --- > 1 |+from __future__ import annotations > 1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable > 2 3 | > 3 4 | > -------------------------------------------------------------------------------- > 55 56 | pass > 56 57 | > 57 58 | 72,75c91,94 < 58 |+def f(arg: Union | None = None): # RUF013 < 59 59 | pass < 60 60 | < 61 61 | --- > 59 |+def f(arg: Union | None = None): # RUF013 > 59 60 | pass > 60 61 | > 61 62 | 86,88c105,112 < 59 59 | pass < 60 60 | < 61 61 | --- > 1 |+from __future__ import annotations > 1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable > 2 3 | > 3 4 | > -------------------------------------------------------------------------------- > 59 60 | pass > 60 61 | > 61 62 | 90,93c114,117 < 62 |+def f(arg: Union[int] | None = None): # RUF013 < 63 63 | pass < 64 64 | < 65 65 | --- > 63 |+def f(arg: Union[int] | None = None): # RUF013 > 63 64 | pass > 64 65 | > 65 66 | 104,106c128,135 < 63 63 | pass < 64 64 | < 65 65 | --- > 1 |+from __future__ import annotations > 1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable > 2 3 | > 3 4 | > -------------------------------------------------------------------------------- > 63 64 | pass > 64 65 | > 65 66 | 108,111c137,140 < 66 |+def f(arg: Union[int, str] | None = None): # RUF013 < 67 67 | pass < 68 68 | < 69 69 | --- > 67 |+def f(arg: Union[int, str] | None = None): # RUF013 > 67 68 | pass > 68 69 | > 69 70 | 122,124c151,158 < 82 82 | pass < 83 83 | < 84 84 | --- > 1 |+from __future__ import annotations > 1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable > 2 3 | > 3 4 | > -------------------------------------------------------------------------------- > 82 83 | pass > 83 84 | > 84 85 | 126,129c160,163 < 85 |+def f(arg: int | float | None = None): # RUF013 < 86 86 | pass < 87 87 | < 88 88 | --- > 86 |+def f(arg: int | float | None = None): # RUF013 > 86 87 | pass > 87 88 | > 88 89 | 140,142c174,181 < 86 86 | pass < 87 87 | < 88 88 | --- > 1 |+from __future__ import annotations > 1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable > 2 3 | > 3 4 | > -------------------------------------------------------------------------------- > 86 87 | pass > 87 88 | > 88 89 | 144,147c183,186 < 89 |+def f(arg: int | float | str | bytes | None = None): # RUF013 < 90 90 | pass < 91 91 | < 92 92 | --- > 90 |+def f(arg: int | float | str | bytes | None = None): # RUF013 > 90 91 | pass > 91 92 | > 92 93 | 158,160c197,204 < 105 105 | pass < 106 106 | < 107 107 | --- > 1 |+from __future__ import annotations > 1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable > 2 3 | > 3 4 | > -------------------------------------------------------------------------------- > 105 106 | pass > 106 107 | > 107 108 | 162,165c206,209 < 108 |+def f(arg: Literal[1] | None = None): # RUF013 < 109 109 | pass < 110 110 | < 111 111 | --- > 109 |+def f(arg: Literal[1] | None = None): # RUF013 > 109 110 | pass > 110 111 | > 111 112 | 176,178c220,227 < 109 109 | pass < 110 110 | < 111 111 | --- > 1 |+from __future__ import annotations > 1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable > 2 3 | > 3 4 | > -------------------------------------------------------------------------------- > 109 110 | pass > 110 111 | > 111 112 | 180,183c229,232 < 112 |+def f(arg: Literal[1, "foo"] | None = None): # RUF013 < 113 113 | pass < 114 114 | < 115 115 | --- > 113 |+def f(arg: Literal[1, "foo"] | None = None): # RUF013 > 113 114 | pass > 114 115 | > 115 116 | 194,196c243,250 < 128 128 | pass < 129 129 | < 130 130 | --- > 1 |+from __future__ import annotations > 1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable > 2 3 | > 3 4 | > -------------------------------------------------------------------------------- > 128 129 | pass > 129 130 | > 130 131 | 198,201c252,255 < 131 |+def f(arg: Annotated[int | None, ...] = None): # RUF013 < 132 132 | pass < 133 133 | < 134 134 | --- > 132 |+def f(arg: Annotated[int | None, ...] = None): # RUF013 > 132 133 | pass > 133 134 | > 134 135 | 212,214c266,273 < 132 132 | pass < 133 133 | < 134 134 | --- > 1 |+from __future__ import annotations > 1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable > 2 3 | > 3 4 | > -------------------------------------------------------------------------------- > 132 133 | pass > 133 134 | > 134 135 | 216,219c275,278 < 135 |+def f(arg: Annotated[Annotated[int | str | None, ...], ...] = None): # RUF013 < 136 136 | pass < 137 137 | < 138 138 | --- > 136 |+def f(arg: Annotated[Annotated[int | str | None, ...], ...] = None): # RUF013 > 136 137 | pass > 137 138 | > 138 139 | 232,234c291,298 < 148 148 | < 149 149 | < 150 150 | def f( --- > 1 |+from __future__ import annotations > 1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable > 2 3 | > 3 4 | > -------------------------------------------------------------------------------- > 148 149 | > 149 150 | > 150 151 | def f( 236,239c300,303 < 151 |+ arg1: int | None = None, # RUF013 < 152 152 | arg2: Union[int, float] = None, # RUF013 < 153 153 | arg3: Literal[1, 2, 3] = None, # RUF013 < 154 154 | ): --- > 152 |+ arg1: int | None = None, # RUF013 > 152 153 | arg2: Union[int, float] = None, # RUF013 > 153 154 | arg3: Literal[1, 2, 3] = None, # RUF013 > 154 155 | ): 253,255c317,324 < 149 149 | < 150 150 | def f( < 151 151 | arg1: int = None, # RUF013 --- > 1 |+from __future__ import annotations > 1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable > 2 3 | > 3 4 | > -------------------------------------------------------------------------------- > 149 150 | > 150 151 | def f( > 151 152 | arg1: int = None, # RUF013 257,260c326,329 < 152 |+ arg2: Union[int, float] | None = None, # RUF013 < 153 153 | arg3: Literal[1, 2, 3] = None, # RUF013 < 154 154 | ): < 155 155 | pass --- > 153 |+ arg2: Union[int, float] | None = None, # RUF013 > 153 154 | arg3: Literal[1, 2, 3] = None, # RUF013 > 154 155 | ): > 155 156 | pass 274,276c343,350 < 150 150 | def f( < 151 151 | arg1: int = None, # RUF013 < 152 152 | arg2: Union[int, float] = None, # RUF013 --- > 1 |+from __future__ import annotations > 1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable > 2 3 | > 3 4 | > -------------------------------------------------------------------------------- > 150 151 | def f( > 151 152 | arg1: int = None, # RUF013 > 152 153 | arg2: Union[int, float] = None, # RUF013 278,281c352,355 < 153 |+ arg3: Literal[1, 2, 3] | None = None, # RUF013 < 154 154 | ): < 155 155 | pass < 156 156 | --- > 154 |+ arg3: Literal[1, 2, 3] | None = None, # RUF013 > 154 155 | ): > 155 156 | pass > 156 157 | 292,294c366,373 < 178 178 | pass < 179 179 | < 180 180 | --- > 1 |+from __future__ import annotations > 1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable > 2 3 | > 3 4 | > -------------------------------------------------------------------------------- > 178 179 | pass > 179 180 | > 180 181 | 296,299c375,378 < 181 |+def f(arg: Union[Annotated[int, ...], Union[str, bytes]] | None = None): # RUF013 < 182 182 | pass < 183 183 | < 184 184 | --- > 182 |+def f(arg: Union[Annotated[int, ...], Union[str, bytes]] | None = None): # RUF013 > 182 183 | pass > 183 184 | > 184 185 | 307c386 < = help: Convert to `T | None` --- > = help: Convert to `Optional[T]` 314c393 < 188 |+def f(arg: "int | None" = None): # RUF013 --- > 188 |+def f(arg: "Optional[int]" = None): # RUF013 325c404 < = help: Convert to `T | None` --- > = help: Convert to `Optional[T]` 332c411 < 192 |+def f(arg: "str | None" = None): # RUF013 --- > 192 |+def f(arg: "Optional[str]" = None): # RUF013 343c422 < = help: Convert to `T | None` --- > = help: Convert to `Optional[T]` 354,356c433,440 < 201 201 | pass < 202 202 | < 203 203 | --- > 1 |+from __future__ import annotations > 1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable > 2 3 | > 3 4 | > -------------------------------------------------------------------------------- > 201 202 | pass > 202 203 | > 203 204 | 358,361c442,445 < 204 |+def f(arg: Union["int", "str"] | None = None): # RUF013 < 205 205 | pass < 206 206 | < 207 207 | --- > 205 |+def f(arg: Union["int", "str"] | None = None): # RUF013 > 205 206 | pass > 206 207 | > 207 208 | ```
RUF013_1.py ```diff 3d2 < snapshot_kind: text 15,16c14,16 < 2 2 | < 3 3 | --- > 2 |+from __future__ import annotations > 2 3 | > 3 4 | 18,19c18,19 < 4 |+def f(arg: int | None = None): # RUF013 < 5 5 | pass --- > 5 |+def f(arg: int | None = None): # RUF013 > 5 6 | pass ```
RUF013_3.py ```diff 3d2 < snapshot_kind: text 14,16c13,16 < 1 1 | import typing < 2 2 | < 3 3 | --- > 1 |+from __future__ import annotations > 1 2 | import typing > 2 3 | > 3 4 | 18,21c18,21 < 4 |+def f(arg: typing.List[str] | None = None): # RUF013 < 5 5 | pass < 6 6 | < 7 7 | --- > 5 |+def f(arg: typing.List[str] | None = None): # RUF013 > 5 6 | pass > 6 7 | > 7 8 | 32,34c32,39 < 19 19 | pass < 20 20 | < 21 21 | --- > 1 |+from __future__ import annotations > 1 2 | import typing > 2 3 | > 3 4 | > -------------------------------------------------------------------------------- > 19 20 | pass > 20 21 | > 21 22 | 36,39c41,44 < 22 |+def f(arg: typing.Union[int, str] | None = None): # RUF013 < 23 23 | pass < 24 24 | < 25 25 | --- > 23 |+def f(arg: typing.Union[int, str] | None = None): # RUF013 > 23 24 | pass > 24 25 | > 25 26 | 50,52c55,62 < 26 26 | # Literal < 27 27 | < 28 28 | --- > 1 |+from __future__ import annotations > 1 2 | import typing > 2 3 | > 3 4 | > -------------------------------------------------------------------------------- > 26 27 | # Literal > 27 28 | > 28 29 | 54,55c64,65 < 29 |+def f(arg: typing.Literal[1, "foo", True] | None = None): # RUF013 < 30 30 | pass --- > 30 |+def f(arg: typing.Literal[1, "foo", True] | None = None): # RUF013 > 30 31 | pass ```
RUF013_4.py ```diff 3d2 < snapshot_kind: text 13,15c12,20 < 12 12 | def multiple_1(arg1: Optional, arg2: Optional = None): ... < 13 13 | < 14 14 | --- > 1 1 | # https://github.com/astral-sh/ruff/issues/13833 > 2 |+from __future__ import annotations > 2 3 | > 3 4 | from typing import Optional > 4 5 | > -------------------------------------------------------------------------------- > 12 13 | def multiple_1(arg1: Optional, arg2: Optional = None): ... > 13 14 | > 14 15 | 17,20c22,25 < 15 |+def multiple_2(arg1: Optional, arg2: Optional = None, arg3: int | None = None): ... < 16 16 | < 17 17 | < 18 18 | def return_type(arg: Optional = None) -> Optional: ... --- > 16 |+def multiple_2(arg1: Optional, arg2: Optional = None, arg3: int | None = None): ... > 16 17 | > 17 18 | > 18 19 | def return_type(arg: Optional = None) -> Optional: ... ```
## Future work This PR does not touch UP006, UP007, or UP045, which are currently coupled to FA100. If this new approach turns out well, we may eventually want to deprecate FA100 and add a `__future__` import in those rules' fixes too. --------- Co-authored-by: Alex Waygood --- crates/ruff/tests/lint.rs | 23 + .../flake8_type_checking/TC001-3_future.py | 10 + .../flake8_type_checking/TC001_future.py | 68 ++ .../TC001_future_present.py | 6 + .../checkers/ast/analyze/deferred_scopes.rs | 2 +- crates/ruff_linter/src/checkers/ast/mod.rs | 7 +- crates/ruff_linter/src/importer/mod.rs | 11 + crates/ruff_linter/src/preview.rs | 5 + .../rules/future_required_type_annotation.rs | 17 +- .../future_rewritable_type_annotation.rs | 14 +- .../src/rules/flake8_type_checking/helpers.rs | 103 ++- .../src/rules/flake8_type_checking/imports.rs | 2 + .../src/rules/flake8_type_checking/mod.rs | 36 + .../runtime_import_in_type_checking_block.rs | 1 + .../rules/typing_only_runtime_import.rs | 279 ++++---- ...__TC001-TC002-TC003_TC001-3_future.py.snap | 76 +++ ...ts__add_future_import__TC001_TC001.py.snap | 32 + ..._future_import__TC001_TC001_future.py.snap | 56 ++ ...import__TC001_TC001_future_present.py.snap | 23 + ...ts__add_future_import__TC002_TC002.py.snap | 251 +++++++ ...ts__add_future_import__TC003_TC003.py.snap | 28 + crates/ruff_linter/src/rules/pyupgrade/mod.rs | 17 + .../pyupgrade/rules/quoted_annotation.rs | 34 +- ...sts__add_future_annotation_UP037_0.py.snap | 625 ++++++++++++++++++ ...sts__add_future_annotation_UP037_1.py.snap | 42 ++ ...ts__add_future_annotation_UP037_2.pyi.snap | 232 +++++++ crates/ruff_linter/src/rules/ruff/mod.rs | 20 + .../src/rules/ruff/rules/implicit_optional.rs | 30 +- ..._tests__add_future_import_RUF013_0.py.snap | 445 +++++++++++++ ..._tests__add_future_import_RUF013_1.py.snap | 19 + ..._tests__add_future_import_RUF013_2.py.snap | 4 + ..._tests__add_future_import_RUF013_3.py.snap | 65 ++ ..._tests__add_future_import_RUF013_4.py.snap | 25 + crates/ruff_linter/src/settings/mod.rs | 8 + crates/ruff_workspace/src/configuration.rs | 12 + crates/ruff_workspace/src/options.rs | 21 + ruff.schema.json | 7 + 37 files changed, 2487 insertions(+), 169 deletions(-) create mode 100644 crates/ruff_linter/resources/test/fixtures/flake8_type_checking/TC001-3_future.py create mode 100644 crates/ruff_linter/resources/test/fixtures/flake8_type_checking/TC001_future.py create mode 100644 crates/ruff_linter/resources/test/fixtures/flake8_type_checking/TC001_future_present.py create mode 100644 crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC001-TC002-TC003_TC001-3_future.py.snap create mode 100644 crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC001_TC001.py.snap create mode 100644 crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC001_TC001_future.py.snap create mode 100644 crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC001_TC001_future_present.py.snap create mode 100644 crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC002_TC002.py.snap create mode 100644 crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC003_TC003.py.snap create mode 100644 crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__add_future_annotation_UP037_0.py.snap create mode 100644 crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__add_future_annotation_UP037_1.py.snap create mode 100644 crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__add_future_annotation_UP037_2.pyi.snap create mode 100644 crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__add_future_import_RUF013_0.py.snap create mode 100644 crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__add_future_import_RUF013_1.py.snap create mode 100644 crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__add_future_import_RUF013_2.py.snap create mode 100644 crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__add_future_import_RUF013_3.py.snap create mode 100644 crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__add_future_import_RUF013_4.py.snap diff --git a/crates/ruff/tests/lint.rs b/crates/ruff/tests/lint.rs index 69a392456f..cd125ece11 100644 --- a/crates/ruff/tests/lint.rs +++ b/crates/ruff/tests/lint.rs @@ -993,6 +993,7 @@ fn value_given_to_table_key_is_not_inline_table_2() { - `lint.exclude` - `lint.preview` - `lint.typing-extensions` + - `lint.future-annotations` For more information, try '--help'. "); @@ -5744,3 +5745,25 @@ match 42: # invalid-syntax Ok(()) } + +#[test] +fn future_annotations_preview_warning() { + assert_cmd_snapshot!( + Command::new(get_cargo_bin(BIN_NAME)) + .args(STDIN_BASE_OPTIONS) + .args(["--config", "lint.future-annotations = true"]) + .args(["--select", "F"]) + .arg("--no-preview") + .arg("-") + .pass_stdin("1"), + @r" + success: true + exit_code: 0 + ----- stdout ----- + All checks passed! + + ----- stderr ----- + warning: The `lint.future-annotations` setting will have no effect because `preview` is disabled + ", + ); +} diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/TC001-3_future.py b/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/TC001-3_future.py new file mode 100644 index 0000000000..f76d6d039a --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/TC001-3_future.py @@ -0,0 +1,10 @@ +from collections import Counter + +from elsewhere import third_party + +from . import first_party + + +def f(x: first_party.foo): ... +def g(x: third_party.bar): ... +def h(x: Counter): ... diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/TC001_future.py b/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/TC001_future.py new file mode 100644 index 0000000000..a548df3274 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/TC001_future.py @@ -0,0 +1,68 @@ +def f(): + from . import first_party + + def f(x: first_party.foo): ... + + +# Type parameter bounds +def g(): + from . import foo + + class C[T: foo.Ty]: ... + + +def h(): + from . import foo + + def f[T: foo.Ty](x: T): ... + + +def i(): + from . import foo + + type Alias[T: foo.Ty] = list[T] + + +# Type parameter defaults +def j(): + from . import foo + + class C[T = foo.Ty]: ... + + +def k(): + from . import foo + + def f[T = foo.Ty](x: T): ... + + +def l(): + from . import foo + + type Alias[T = foo.Ty] = list[T] + + +# non-generic type alias +def m(): + from . import foo + + type Alias = foo.Ty + + +# unions +from typing import Union + + +def n(): + from . import foo + + def f(x: Union[foo.Ty, int]): ... + def g(x: foo.Ty | int): ... + + +# runtime and typing usage +def o(): + from . import foo + + def f(x: foo.Ty): + return foo.Ty() diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/TC001_future_present.py b/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/TC001_future_present.py new file mode 100644 index 0000000000..12b9bbdced --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/TC001_future_present.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from . import first_party + + +def f(x: first_party.foo): ... diff --git a/crates/ruff_linter/src/checkers/ast/analyze/deferred_scopes.rs b/crates/ruff_linter/src/checkers/ast/analyze/deferred_scopes.rs index 44b39d351f..4971254acf 100644 --- a/crates/ruff_linter/src/checkers/ast/analyze/deferred_scopes.rs +++ b/crates/ruff_linter/src/checkers/ast/analyze/deferred_scopes.rs @@ -71,7 +71,7 @@ pub(crate) fn deferred_scopes(checker: &Checker) { flake8_type_checking::helpers::is_valid_runtime_import( binding, &checker.semantic, - &checker.settings().flake8_type_checking, + checker.settings(), ) }) .collect() diff --git a/crates/ruff_linter/src/checkers/ast/mod.rs b/crates/ruff_linter/src/checkers/ast/mod.rs index e05c807c2d..d5e3860398 100644 --- a/crates/ruff_linter/src/checkers/ast/mod.rs +++ b/crates/ruff_linter/src/checkers/ast/mod.rs @@ -2770,11 +2770,10 @@ impl<'a> Checker<'a> { self.semantic.restore(snapshot); - if self.semantic.in_typing_only_annotation() { - if self.is_rule_enabled(Rule::QuotedAnnotation) { - pyupgrade::rules::quoted_annotation(self, annotation, range); - } + if self.is_rule_enabled(Rule::QuotedAnnotation) { + pyupgrade::rules::quoted_annotation(self, annotation, range); } + if self.source_type.is_stub() { if self.is_rule_enabled(Rule::QuotedAnnotationInStub) { flake8_pyi::rules::quoted_annotation_in_stub( diff --git a/crates/ruff_linter/src/importer/mod.rs b/crates/ruff_linter/src/importer/mod.rs index 31bf7262bc..f4939a63d3 100644 --- a/crates/ruff_linter/src/importer/mod.rs +++ b/crates/ruff_linter/src/importer/mod.rs @@ -527,6 +527,17 @@ impl<'a> Importer<'a> { None } } + + /// Add a `from __future__ import annotations` import. + pub(crate) fn add_future_import(&self) -> Edit { + let import = &NameImport::ImportFrom(MemberNameImport::member( + "__future__".to_string(), + "annotations".to_string(), + )); + // Note that `TextSize::default` should ensure that the import is added at the very + // beginning of the file via `Insertion::start_of_file`. + self.add_import(import, TextSize::default()) + } } /// An edit to the top-level of a module, making it available at runtime. diff --git a/crates/ruff_linter/src/preview.rs b/crates/ruff_linter/src/preview.rs index a582812734..fe7ba677a9 100644 --- a/crates/ruff_linter/src/preview.rs +++ b/crates/ruff_linter/src/preview.rs @@ -195,3 +195,8 @@ pub(crate) const fn is_safe_super_call_with_parameters_fix_enabled( pub(crate) const fn is_assert_raises_exception_call_enabled(settings: &LinterSettings) -> bool { settings.preview.is_enabled() } + +// https://github.com/astral-sh/ruff/pull/19100 +pub(crate) const fn is_add_future_annotations_imports_enabled(settings: &LinterSettings) -> bool { + settings.preview.is_enabled() +} diff --git a/crates/ruff_linter/src/rules/flake8_future_annotations/rules/future_required_type_annotation.rs b/crates/ruff_linter/src/rules/flake8_future_annotations/rules/future_required_type_annotation.rs index 2305cd5052..99571bb802 100644 --- a/crates/ruff_linter/src/rules/flake8_future_annotations/rules/future_required_type_annotation.rs +++ b/crates/ruff_linter/src/rules/flake8_future_annotations/rules/future_required_type_annotation.rs @@ -2,8 +2,7 @@ use std::fmt; use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_python_ast::Expr; -use ruff_python_semantic::{MemberNameImport, NameImport}; -use ruff_text_size::{Ranged, TextSize}; +use ruff_text_size::Ranged; use crate::checkers::ast::Checker; use crate::{AlwaysFixableViolation, Fix}; @@ -85,15 +84,7 @@ impl AlwaysFixableViolation for FutureRequiredTypeAnnotation { /// FA102 pub(crate) fn future_required_type_annotation(checker: &Checker, expr: &Expr, reason: Reason) { - let mut diagnostic = - checker.report_diagnostic(FutureRequiredTypeAnnotation { reason }, expr.range()); - let required_import = NameImport::ImportFrom(MemberNameImport::member( - "__future__".to_string(), - "annotations".to_string(), - )); - diagnostic.set_fix(Fix::unsafe_edit( - checker - .importer() - .add_import(&required_import, TextSize::default()), - )); + checker + .report_diagnostic(FutureRequiredTypeAnnotation { reason }, expr.range()) + .set_fix(Fix::unsafe_edit(checker.importer().add_future_import())); } diff --git a/crates/ruff_linter/src/rules/flake8_future_annotations/rules/future_rewritable_type_annotation.rs b/crates/ruff_linter/src/rules/flake8_future_annotations/rules/future_rewritable_type_annotation.rs index 1447b05322..66ca69cafa 100644 --- a/crates/ruff_linter/src/rules/flake8_future_annotations/rules/future_rewritable_type_annotation.rs +++ b/crates/ruff_linter/src/rules/flake8_future_annotations/rules/future_rewritable_type_annotation.rs @@ -1,12 +1,10 @@ -use ruff_diagnostics::Fix; use ruff_python_ast::Expr; use ruff_macros::{ViolationMetadata, derive_message_formats}; -use ruff_python_semantic::{MemberNameImport, NameImport}; use ruff_text_size::Ranged; -use crate::AlwaysFixableViolation; use crate::checkers::ast::Checker; +use crate::{AlwaysFixableViolation, Fix}; /// ## What it does /// Checks for missing `from __future__ import annotations` imports upon @@ -95,15 +93,7 @@ pub(crate) fn future_rewritable_type_annotation(checker: &Checker, expr: &Expr) let Some(name) = name else { return }; - let import = &NameImport::ImportFrom(MemberNameImport::member( - "__future__".to_string(), - "annotations".to_string(), - )); checker .report_diagnostic(FutureRewritableTypeAnnotation { name }, expr.range()) - .set_fix(Fix::unsafe_edit( - checker - .importer() - .add_import(import, ruff_text_size::TextSize::default()), - )); + .set_fix(Fix::unsafe_edit(checker.importer().add_future_import())); } diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/helpers.rs b/crates/ruff_linter/src/rules/flake8_type_checking/helpers.rs index 21da4fb514..6725e2f4b0 100644 --- a/crates/ruff_linter/src/rules/flake8_type_checking/helpers.rs +++ b/crates/ruff_linter/src/rules/flake8_type_checking/helpers.rs @@ -8,41 +8,110 @@ use ruff_python_ast::{self as ast, Decorator, Expr, StringLiteralFlags}; use ruff_python_codegen::{Generator, Stylist}; use ruff_python_parser::typing::parse_type_annotation; use ruff_python_semantic::{ - Binding, BindingKind, Modules, NodeId, ResolvedReference, ScopeKind, SemanticModel, analyze, + Binding, BindingKind, Modules, NodeId, ScopeKind, SemanticModel, analyze, }; use ruff_text_size::{Ranged, TextRange}; use crate::Edit; use crate::Locator; -use crate::rules::flake8_type_checking::settings::Settings; +use crate::settings::LinterSettings; -/// Returns `true` if the [`ResolvedReference`] is in a typing-only context _or_ a runtime-evaluated -/// context (with quoting enabled). -pub(crate) fn is_typing_reference(reference: &ResolvedReference, settings: &Settings) -> bool { - reference.in_type_checking_block() - // if we're not in a type checking block, we necessarily need to be within a - // type definition to be considered a typing reference - || (reference.in_type_definition() - && (reference.in_typing_only_annotation() - || reference.in_string_type_definition() - || (settings.quote_annotations && reference.in_runtime_evaluated_annotation()))) +/// Represents the kind of an existing or potential typing-only annotation. +/// +/// Note that the order of variants is important here. `Runtime` has the highest precedence when +/// calling [`TypingReference::combine`] on two references, followed by `Future`, `Quote`, and +/// `TypingOnly` with the lowest precedence. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum TypingReference { + /// The reference is in a runtime-evaluated context. + Runtime, + /// The reference is in a runtime-evaluated context, but the + /// `lint.future-annotations` setting is enabled. + /// + /// This takes precedence if both quoting and future imports are enabled. + Future, + /// The reference is in a runtime-evaluated context, but the + /// `lint.flake8-type-checking.quote-annotations` setting is enabled. + Quote, + /// The reference is in a typing-only context. + TypingOnly, +} + +impl TypingReference { + /// Determine the kind of [`TypingReference`] for all references to a binding. + pub(crate) fn from_references( + binding: &Binding, + semantic: &SemanticModel, + settings: &LinterSettings, + ) -> Self { + let references = binding + .references() + .map(|reference_id| semantic.reference(reference_id)); + let mut kind = Self::TypingOnly; + for reference in references { + if reference.in_type_checking_block() { + kind = kind.combine(Self::TypingOnly); + continue; + } + + // if we're not in a type checking block, we necessarily need to be within a + // type definition to be considered a typing reference + if !reference.in_type_definition() { + return Self::Runtime; + } + + if reference.in_typing_only_annotation() || reference.in_string_type_definition() { + kind = kind.combine(Self::TypingOnly); + continue; + } + + // prefer `from __future__ import annotations` to quoting + if settings.future_annotations() + && !reference.in_typing_only_annotation() + && reference.in_runtime_evaluated_annotation() + { + kind = kind.combine(Self::Future); + continue; + } + + if settings.flake8_type_checking.quote_annotations + && reference.in_runtime_evaluated_annotation() + { + kind = kind.combine(Self::Quote); + continue; + } + + return Self::Runtime; + } + + kind + } + + /// Logically combine two `TypingReference`s into one. + /// + /// `TypingReference::Runtime` has the highest precedence, followed by + /// `TypingReference::Future`, `TypingReference::Quote`, and then `TypingReference::TypingOnly`. + fn combine(self, other: TypingReference) -> TypingReference { + self.min(other) + } + + fn is_runtime(self) -> bool { + matches!(self, Self::Runtime) + } } /// Returns `true` if the [`Binding`] represents a runtime-required import. pub(crate) fn is_valid_runtime_import( binding: &Binding, semantic: &SemanticModel, - settings: &Settings, + settings: &LinterSettings, ) -> bool { if matches!( binding.kind, BindingKind::Import(..) | BindingKind::FromImport(..) | BindingKind::SubmoduleImport(..) ) { binding.context.is_runtime() - && binding - .references() - .map(|reference_id| semantic.reference(reference_id)) - .any(|reference| !is_typing_reference(reference, settings)) + && TypingReference::from_references(binding, semantic, settings).is_runtime() } else { false } diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/imports.rs b/crates/ruff_linter/src/rules/flake8_type_checking/imports.rs index 3cd8f8f227..2f4abad7d2 100644 --- a/crates/ruff_linter/src/rules/flake8_type_checking/imports.rs +++ b/crates/ruff_linter/src/rules/flake8_type_checking/imports.rs @@ -13,6 +13,8 @@ pub(crate) struct ImportBinding<'a> { pub(crate) range: TextRange, /// The range of the import's parent statement. pub(crate) parent_range: Option, + /// Whether the binding needs `from __future__ import annotations` to be imported. + pub(crate) needs_future_import: bool, } impl Ranged for ImportBinding<'_> { diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/mod.rs b/crates/ruff_linter/src/rules/flake8_type_checking/mod.rs index 45511cdd8c..2b66b3a567 100644 --- a/crates/ruff_linter/src/rules/flake8_type_checking/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_type_checking/mod.rs @@ -9,10 +9,12 @@ mod tests { use std::path::Path; use anyhow::Result; + use itertools::Itertools; use ruff_python_ast::PythonVersion; use test_case::test_case; use crate::registry::{Linter, Rule}; + use crate::settings::types::PreviewMode; use crate::test::{test_path, test_snippet}; use crate::{assert_diagnostics, settings}; @@ -64,6 +66,40 @@ mod tests { Ok(()) } + #[test_case(&[Rule::TypingOnlyFirstPartyImport], Path::new("TC001.py"))] + #[test_case(&[Rule::TypingOnlyThirdPartyImport], Path::new("TC002.py"))] + #[test_case(&[Rule::TypingOnlyStandardLibraryImport], Path::new("TC003.py"))] + #[test_case( + &[ + Rule::TypingOnlyFirstPartyImport, + Rule::TypingOnlyThirdPartyImport, + Rule::TypingOnlyStandardLibraryImport, + ], + Path::new("TC001-3_future.py") + )] + #[test_case(&[Rule::TypingOnlyFirstPartyImport], Path::new("TC001_future.py"))] + #[test_case(&[Rule::TypingOnlyFirstPartyImport], Path::new("TC001_future_present.py"))] + fn add_future_import(rules: &[Rule], path: &Path) -> Result<()> { + let name = rules.iter().map(Rule::noqa_code).join("-"); + let snapshot = format!("add_future_import__{}_{}", name, path.to_string_lossy()); + let diagnostics = test_path( + Path::new("flake8_type_checking").join(path).as_path(), + &settings::LinterSettings { + future_annotations: true, + preview: PreviewMode::Enabled, + // also enable quoting annotations to check the interaction. the future import + // should take precedence. + flake8_type_checking: super::settings::Settings { + quote_annotations: true, + ..Default::default() + }, + ..settings::LinterSettings::for_rules(rules.iter().copied()) + }, + )?; + assert_diagnostics!(snapshot, diagnostics); + Ok(()) + } + // we test these rules as a pair, since they're opposites of one another // so we want to make sure their fixes are not going around in circles. #[test_case(Rule::UnquotedTypeAlias, Path::new("TC007.py"))] diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/rules/runtime_import_in_type_checking_block.rs b/crates/ruff_linter/src/rules/flake8_type_checking/rules/runtime_import_in_type_checking_block.rs index 10a1580388..e3cbf17a02 100644 --- a/crates/ruff_linter/src/rules/flake8_type_checking/rules/runtime_import_in_type_checking_block.rs +++ b/crates/ruff_linter/src/rules/flake8_type_checking/rules/runtime_import_in_type_checking_block.rs @@ -139,6 +139,7 @@ pub(crate) fn runtime_import_in_type_checking_block(checker: &Checker, scope: &S binding, range: binding.range(), parent_range: binding.parent_range(checker.semantic()), + needs_future_import: false, // TODO(brent) See #19359. }; if checker.rule_is_ignored(Rule::RuntimeImportInTypeCheckingBlock, import.start()) diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/rules/typing_only_runtime_import.rs b/crates/ruff_linter/src/rules/flake8_type_checking/rules/typing_only_runtime_import.rs index dd0f1abe80..9789ce417d 100644 --- a/crates/ruff_linter/src/rules/flake8_type_checking/rules/typing_only_runtime_import.rs +++ b/crates/ruff_linter/src/rules/flake8_type_checking/rules/typing_only_runtime_import.rs @@ -13,7 +13,7 @@ use crate::fix; use crate::importer::ImportedMembers; use crate::preview::is_full_path_match_source_strategy_enabled; use crate::rules::flake8_type_checking::helpers::{ - filter_contained, is_typing_reference, quote_annotation, + TypingReference, filter_contained, quote_annotation, }; use crate::rules::flake8_type_checking::imports::ImportBinding; use crate::rules::isort::categorize::MatchSourceStrategy; @@ -71,12 +71,19 @@ use crate::{Fix, FixAvailability, Violation}; /// the criterion for determining whether an import is first-party /// is stricter, which could affect whether this lint is triggered vs [`TC001`](https://docs.astral.sh/ruff/rules/typing-only-third-party-import/). See [this FAQ section](https://docs.astral.sh/ruff/faq/#how-does-ruff-determine-which-of-my-imports-are-first-party-third-party-etc) for more details. /// +/// If [`lint.future-annotations`] is set to `true`, `from __future__ import +/// annotations` will be added if doing so would enable an import to be moved into an `if +/// TYPE_CHECKING:` block. This takes precedence over the +/// [`lint.flake8-type-checking.quote-annotations`] setting described above if both settings are +/// enabled. +/// /// ## Options /// - `lint.flake8-type-checking.quote-annotations` /// - `lint.flake8-type-checking.runtime-evaluated-base-classes` /// - `lint.flake8-type-checking.runtime-evaluated-decorators` /// - `lint.flake8-type-checking.strict` /// - `lint.typing-modules` +/// - `lint.future-annotations` /// /// ## References /// - [PEP 563: Runtime annotation resolution and `TYPE_CHECKING`](https://peps.python.org/pep-0563/#runtime-annotation-resolution-and-type-checking) @@ -151,12 +158,19 @@ impl Violation for TypingOnlyFirstPartyImport { /// the criterion for determining whether an import is first-party /// is stricter, which could affect whether this lint is triggered vs [`TC001`](https://docs.astral.sh/ruff/rules/typing-only-first-party-import/). See [this FAQ section](https://docs.astral.sh/ruff/faq/#how-does-ruff-determine-which-of-my-imports-are-first-party-third-party-etc) for more details. /// +/// If [`lint.future-annotations`] is set to `true`, `from __future__ import +/// annotations` will be added if doing so would enable an import to be moved into an `if +/// TYPE_CHECKING:` block. This takes precedence over the +/// [`lint.flake8-type-checking.quote-annotations`] setting described above if both settings are +/// enabled. +/// /// ## Options /// - `lint.flake8-type-checking.quote-annotations` /// - `lint.flake8-type-checking.runtime-evaluated-base-classes` /// - `lint.flake8-type-checking.runtime-evaluated-decorators` /// - `lint.flake8-type-checking.strict` /// - `lint.typing-modules` +/// - `lint.future-annotations` /// /// ## References /// - [PEP 563: Runtime annotation resolution and `TYPE_CHECKING`](https://peps.python.org/pep-0563/#runtime-annotation-resolution-and-type-checking) @@ -226,12 +240,22 @@ impl Violation for TypingOnlyThirdPartyImport { /// return str(path) /// ``` /// +/// ## Preview +/// +/// When [preview](https://docs.astral.sh/ruff/preview/) is enabled, if +/// [`lint.future-annotations`] is set to `true`, `from __future__ import +/// annotations` will be added if doing so would enable an import to be moved into an `if +/// TYPE_CHECKING:` block. This takes precedence over the +/// [`lint.flake8-type-checking.quote-annotations`] setting described above if both settings are +/// enabled. +/// /// ## Options /// - `lint.flake8-type-checking.quote-annotations` /// - `lint.flake8-type-checking.runtime-evaluated-base-classes` /// - `lint.flake8-type-checking.runtime-evaluated-decorators` /// - `lint.flake8-type-checking.strict` /// - `lint.typing-modules` +/// - `lint.future-annotations` /// /// ## References /// - [PEP 563: Runtime annotation resolution and `TYPE_CHECKING`](https://peps.python.org/pep-0563/#runtime-annotation-resolution-and-type-checking) @@ -271,9 +295,10 @@ pub(crate) fn typing_only_runtime_import( for binding_id in scope.binding_ids() { let binding = checker.semantic().binding(binding_id); - // If we're in un-strict mode, don't flag typing-only imports that are - // implicitly loaded by way of a valid runtime import. - if !checker.settings().flake8_type_checking.strict + // If we can't add a `__future__` import and in un-strict mode, don't flag typing-only + // imports that are implicitly loaded by way of a valid runtime import. + if !checker.settings().future_annotations() + && !checker.settings().flake8_type_checking.strict && runtime_imports .iter() .any(|import| is_implicit_import(binding, import)) @@ -289,95 +314,102 @@ pub(crate) fn typing_only_runtime_import( continue; }; - if binding.context.is_runtime() - && binding - .references() - .map(|reference_id| checker.semantic().reference(reference_id)) - .all(|reference| { - is_typing_reference(reference, &checker.settings().flake8_type_checking) - }) - { - let qualified_name = import.qualified_name(); + if !binding.context.is_runtime() { + continue; + } - if is_exempt( - &qualified_name.to_string(), - &checker - .settings() - .flake8_type_checking - .exempt_modules - .iter() - .map(String::as_str) - .collect::>(), - ) { - continue; - } + let typing_reference = + TypingReference::from_references(binding, checker.semantic(), checker.settings()); - let source_name = import.source_name().join("."); + let needs_future_import = match typing_reference { + TypingReference::Runtime => continue, + // We can only get the `Future` variant if `future_annotations` is + // enabled, so we can unconditionally set this here. + TypingReference::Future => true, + TypingReference::TypingOnly | TypingReference::Quote => false, + }; - // Categorize the import, using coarse-grained categorization. - let match_source_strategy = - if is_full_path_match_source_strategy_enabled(checker.settings()) { - MatchSourceStrategy::FullPath - } else { - MatchSourceStrategy::Root - }; + let qualified_name = import.qualified_name(); - let import_type = match categorize( - &source_name, - qualified_name.is_unresolved_import(), - &checker.settings().src, - checker.package(), - checker.settings().isort.detect_same_package, - &checker.settings().isort.known_modules, - checker.target_version(), - checker.settings().isort.no_sections, - &checker.settings().isort.section_order, - &checker.settings().isort.default_section, - match_source_strategy, - ) { - ImportSection::Known(ImportType::LocalFolder | ImportType::FirstParty) => { - ImportType::FirstParty - } - ImportSection::Known(ImportType::ThirdParty) | ImportSection::UserDefined(_) => { - ImportType::ThirdParty - } - ImportSection::Known(ImportType::StandardLibrary) => ImportType::StandardLibrary, - ImportSection::Known(ImportType::Future) => { - continue; - } - }; + if is_exempt( + &qualified_name.to_string(), + &checker + .settings() + .flake8_type_checking + .exempt_modules + .iter() + .map(String::as_str) + .collect::>(), + ) { + continue; + } - if !checker.is_rule_enabled(rule_for(import_type)) { - continue; - } + let source_name = import.source_name().join("."); - let Some(node_id) = binding.source else { - continue; - }; - - let import = ImportBinding { - import, - reference_id, - binding, - range: binding.range(), - parent_range: binding.parent_range(checker.semantic()), - }; - - if checker.rule_is_ignored(rule_for(import_type), import.start()) - || import.parent_range.is_some_and(|parent_range| { - checker.rule_is_ignored(rule_for(import_type), parent_range.start()) - }) - { - ignores_by_statement - .entry((node_id, import_type)) - .or_default() - .push(import); + // Categorize the import, using coarse-grained categorization. + let match_source_strategy = + if is_full_path_match_source_strategy_enabled(checker.settings()) { + MatchSourceStrategy::FullPath } else { - errors_by_statement - .entry((node_id, import_type)) - .or_default() - .push(import); + MatchSourceStrategy::Root + }; + + let import_type = match categorize( + &source_name, + qualified_name.is_unresolved_import(), + &checker.settings().src, + checker.package(), + checker.settings().isort.detect_same_package, + &checker.settings().isort.known_modules, + checker.target_version(), + checker.settings().isort.no_sections, + &checker.settings().isort.section_order, + &checker.settings().isort.default_section, + match_source_strategy, + ) { + ImportSection::Known(ImportType::LocalFolder | ImportType::FirstParty) => { + ImportType::FirstParty } + ImportSection::Known(ImportType::ThirdParty) | ImportSection::UserDefined(_) => { + ImportType::ThirdParty + } + ImportSection::Known(ImportType::StandardLibrary) => ImportType::StandardLibrary, + ImportSection::Known(ImportType::Future) => { + continue; + } + }; + + if !checker.is_rule_enabled(rule_for(import_type)) { + continue; + } + + let Some(node_id) = binding.source else { + continue; + }; + + let import = ImportBinding { + import, + reference_id, + binding, + range: binding.range(), + parent_range: binding.parent_range(checker.semantic()), + needs_future_import, + }; + + if checker.rule_is_ignored(rule_for(import_type), import.start()) + || import.parent_range.is_some_and(|parent_range| { + checker.rule_is_ignored(rule_for(import_type), parent_range.start()) + }) + { + ignores_by_statement + .entry((node_id, import_type)) + .or_default() + .push(import); + } else { + errors_by_statement + .entry((node_id, import_type)) + .or_default() + .push(import); } } @@ -509,6 +541,8 @@ fn fix_imports(checker: &Checker, node_id: NodeId, imports: &[ImportBinding]) -> .min() .expect("Expected at least one import"); + let add_future_import = imports.iter().any(|binding| binding.needs_future_import); + // Step 1) Remove the import. let remove_import_edit = fix::edits::remove_unused_imports( member_names.iter().map(AsRef::as_ref), @@ -532,37 +566,52 @@ fn fix_imports(checker: &Checker, node_id: NodeId, imports: &[ImportBinding]) -> )? .into_edits(); - // Step 3) Quote any runtime usages of the referenced symbol. - let quote_reference_edits = filter_contained( - imports - .iter() - .flat_map(|ImportBinding { binding, .. }| { - binding.references.iter().filter_map(|reference_id| { - let reference = checker.semantic().reference(*reference_id); - if reference.in_runtime_context() { - Some(quote_annotation( - reference.expression_id()?, - checker.semantic(), - checker.stylist(), - checker.locator(), - checker.default_string_flags(), - )) - } else { - None - } - }) - }) - .collect::>(), - ); + // Step 3) Either add a `__future__` import or quote any runtime usages of the referenced + // symbol. + let fix = if add_future_import { + let future_import = checker.importer().add_future_import(); - Ok(Fix::unsafe_edits( - type_checking_edit, - add_import_edit - .into_iter() - .chain(std::iter::once(remove_import_edit)) - .chain(quote_reference_edits), - ) - .isolate(Checker::isolation( + // The order here is very important. We first need to add the `__future__` import, if + // needed, since it's a syntax error to come later. Then `type_checking_edit` imports + // `TYPE_CHECKING`, if available. Then we can add and/or remove existing imports. + Fix::unsafe_edits( + future_import, + std::iter::once(type_checking_edit) + .chain(add_import_edit) + .chain(std::iter::once(remove_import_edit)), + ) + } else { + let quote_reference_edits = filter_contained( + imports + .iter() + .flat_map(|ImportBinding { binding, .. }| { + binding.references.iter().filter_map(|reference_id| { + let reference = checker.semantic().reference(*reference_id); + if reference.in_runtime_context() { + Some(quote_annotation( + reference.expression_id()?, + checker.semantic(), + checker.stylist(), + checker.locator(), + checker.default_string_flags(), + )) + } else { + None + } + }) + }) + .collect::>(), + ); + Fix::unsafe_edits( + type_checking_edit, + add_import_edit + .into_iter() + .chain(std::iter::once(remove_import_edit)) + .chain(quote_reference_edits), + ) + }; + + Ok(fix.isolate(Checker::isolation( checker.semantic().parent_statement_id(node_id), ))) } diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC001-TC002-TC003_TC001-3_future.py.snap b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC001-TC002-TC003_TC001-3_future.py.snap new file mode 100644 index 0000000000..9db86ba0f6 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC001-TC002-TC003_TC001-3_future.py.snap @@ -0,0 +1,76 @@ +--- +source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs +--- +TC001-3_future.py:1:25: TC003 [*] Move standard library import `collections.Counter` into a type-checking block + | +1 | from collections import Counter + | ^^^^^^^ TC003 +2 | +3 | from elsewhere import third_party + | + = help: Move into type-checking block + +ℹ Unsafe fix +1 |-from collections import Counter + 1 |+from __future__ import annotations +2 2 | +3 3 | from elsewhere import third_party +4 4 | +5 5 | from . import first_party + 6 |+from typing import TYPE_CHECKING + 7 |+ + 8 |+if TYPE_CHECKING: + 9 |+ from collections import Counter +6 10 | +7 11 | +8 12 | def f(x: first_party.foo): ... + +TC001-3_future.py:3:23: TC002 [*] Move third-party import `elsewhere.third_party` into a type-checking block + | +1 | from collections import Counter +2 | +3 | from elsewhere import third_party + | ^^^^^^^^^^^ TC002 +4 | +5 | from . import first_party + | + = help: Move into type-checking block + +ℹ Unsafe fix + 1 |+from __future__ import annotations +1 2 | from collections import Counter +2 3 | +3 |-from elsewhere import third_party +4 4 | +5 5 | from . import first_party + 6 |+from typing import TYPE_CHECKING + 7 |+ + 8 |+if TYPE_CHECKING: + 9 |+ from elsewhere import third_party +6 10 | +7 11 | +8 12 | def f(x: first_party.foo): ... + +TC001-3_future.py:5:15: TC001 [*] Move application import `.first_party` into a type-checking block + | +3 | from elsewhere import third_party +4 | +5 | from . import first_party + | ^^^^^^^^^^^ TC001 + | + = help: Move into type-checking block + +ℹ Unsafe fix + 1 |+from __future__ import annotations +1 2 | from collections import Counter +2 3 | +3 4 | from elsewhere import third_party +4 5 | +5 |-from . import first_party + 6 |+from typing import TYPE_CHECKING + 7 |+ + 8 |+if TYPE_CHECKING: + 9 |+ from . import first_party +6 10 | +7 11 | +8 12 | def f(x: first_party.foo): ... diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC001_TC001.py.snap b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC001_TC001.py.snap new file mode 100644 index 0000000000..cc9d12d169 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC001_TC001.py.snap @@ -0,0 +1,32 @@ +--- +source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs +--- +TC001.py:20:19: TC001 [*] Move application import `.TYP001` into a type-checking block + | +19 | def f(): +20 | from . import TYP001 + | ^^^^^^ TC001 +21 | +22 | x: TYP001 + | + = help: Move into type-checking block + +ℹ Unsafe fix +2 2 | +3 3 | For typing-only import detection tests, see `TC002.py`. +4 4 | """ + 5 |+from typing import TYPE_CHECKING + 6 |+ + 7 |+if TYPE_CHECKING: + 8 |+ from . import TYP001 +5 9 | +6 10 | +7 11 | def f(): +-------------------------------------------------------------------------------- +17 21 | +18 22 | +19 23 | def f(): +20 |- from . import TYP001 +21 24 | +22 25 | x: TYP001 +23 26 | diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC001_TC001_future.py.snap b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC001_TC001_future.py.snap new file mode 100644 index 0000000000..0ecb18cd62 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC001_TC001_future.py.snap @@ -0,0 +1,56 @@ +--- +source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs +--- +TC001_future.py:2:19: TC001 [*] Move application import `.first_party` into a type-checking block + | +1 | def f(): +2 | from . import first_party + | ^^^^^^^^^^^ TC001 +3 | +4 | def f(x: first_party.foo): ... + | + = help: Move into type-checking block + +ℹ Unsafe fix +1 |-def f(): + 1 |+from __future__ import annotations + 2 |+from typing import TYPE_CHECKING + 3 |+ + 4 |+if TYPE_CHECKING: +2 5 | from . import first_party + 6 |+def f(): +3 7 | +4 8 | def f(x: first_party.foo): ... +5 9 | + +TC001_future.py:57:19: TC001 [*] Move application import `.foo` into a type-checking block + | +56 | def n(): +57 | from . import foo + | ^^^ TC001 +58 | +59 | def f(x: Union[foo.Ty, int]): ... + | + = help: Move into type-checking block + +ℹ Unsafe fix + 1 |+from __future__ import annotations +1 2 | def f(): +2 3 | from . import first_party +3 4 | +-------------------------------------------------------------------------------- +50 51 | +51 52 | +52 53 | # unions +53 |-from typing import Union + 54 |+from typing import Union, TYPE_CHECKING +54 55 | + 56 |+if TYPE_CHECKING: + 57 |+ from . import foo + 58 |+ +55 59 | +56 60 | def n(): +57 |- from . import foo +58 61 | +59 62 | def f(x: Union[foo.Ty, int]): ... +60 63 | def g(x: foo.Ty | int): ... diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC001_TC001_future_present.py.snap b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC001_TC001_future_present.py.snap new file mode 100644 index 0000000000..0f6d0b7388 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC001_TC001_future_present.py.snap @@ -0,0 +1,23 @@ +--- +source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs +--- +TC001_future_present.py:3:15: TC001 [*] Move application import `.first_party` into a type-checking block + | +1 | from __future__ import annotations +2 | +3 | from . import first_party + | ^^^^^^^^^^^ TC001 + | + = help: Move into type-checking block + +ℹ Unsafe fix +1 1 | from __future__ import annotations +2 2 | +3 |-from . import first_party + 3 |+from typing import TYPE_CHECKING + 4 |+ + 5 |+if TYPE_CHECKING: + 6 |+ from . import first_party +4 7 | +5 8 | +6 9 | def f(x: first_party.foo): ... diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC002_TC002.py.snap b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC002_TC002.py.snap new file mode 100644 index 0000000000..3b34f3141c --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC002_TC002.py.snap @@ -0,0 +1,251 @@ +--- +source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs +--- +TC002.py:5:22: TC002 [*] Move third-party import `pandas` into a type-checking block + | +4 | def f(): +5 | import pandas as pd # TC002 + | ^^ TC002 +6 | +7 | x: pd.DataFrame + | + = help: Move into type-checking block + +ℹ Unsafe fix +1 1 | """Tests to determine accurate detection of typing-only imports.""" + 2 |+from typing import TYPE_CHECKING + 3 |+ + 4 |+if TYPE_CHECKING: + 5 |+ import pandas as pd +2 6 | +3 7 | +4 8 | def f(): +5 |- import pandas as pd # TC002 +6 9 | +7 10 | x: pd.DataFrame +8 11 | + +TC002.py:11:24: TC002 [*] Move third-party import `pandas.DataFrame` into a type-checking block + | +10 | def f(): +11 | from pandas import DataFrame # TC002 + | ^^^^^^^^^ TC002 +12 | +13 | x: DataFrame + | + = help: Move into type-checking block + +ℹ Unsafe fix +1 1 | """Tests to determine accurate detection of typing-only imports.""" + 2 |+from typing import TYPE_CHECKING + 3 |+ + 4 |+if TYPE_CHECKING: + 5 |+ from pandas import DataFrame +2 6 | +3 7 | +4 8 | def f(): +-------------------------------------------------------------------------------- +8 12 | +9 13 | +10 14 | def f(): +11 |- from pandas import DataFrame # TC002 +12 15 | +13 16 | x: DataFrame +14 17 | + +TC002.py:17:37: TC002 [*] Move third-party import `pandas.DataFrame` into a type-checking block + | +16 | def f(): +17 | from pandas import DataFrame as df # TC002 + | ^^ TC002 +18 | +19 | x: df + | + = help: Move into type-checking block + +ℹ Unsafe fix +1 1 | """Tests to determine accurate detection of typing-only imports.""" + 2 |+from typing import TYPE_CHECKING + 3 |+ + 4 |+if TYPE_CHECKING: + 5 |+ from pandas import DataFrame as df +2 6 | +3 7 | +4 8 | def f(): +-------------------------------------------------------------------------------- +14 18 | +15 19 | +16 20 | def f(): +17 |- from pandas import DataFrame as df # TC002 +18 21 | +19 22 | x: df +20 23 | + +TC002.py:23:22: TC002 [*] Move third-party import `pandas` into a type-checking block + | +22 | def f(): +23 | import pandas as pd # TC002 + | ^^ TC002 +24 | +25 | x: pd.DataFrame = 1 + | + = help: Move into type-checking block + +ℹ Unsafe fix +1 1 | """Tests to determine accurate detection of typing-only imports.""" + 2 |+from typing import TYPE_CHECKING + 3 |+ + 4 |+if TYPE_CHECKING: + 5 |+ import pandas as pd +2 6 | +3 7 | +4 8 | def f(): +-------------------------------------------------------------------------------- +20 24 | +21 25 | +22 26 | def f(): +23 |- import pandas as pd # TC002 +24 27 | +25 28 | x: pd.DataFrame = 1 +26 29 | + +TC002.py:29:24: TC002 [*] Move third-party import `pandas.DataFrame` into a type-checking block + | +28 | def f(): +29 | from pandas import DataFrame # TC002 + | ^^^^^^^^^ TC002 +30 | +31 | x: DataFrame = 2 + | + = help: Move into type-checking block + +ℹ Unsafe fix +1 1 | """Tests to determine accurate detection of typing-only imports.""" + 2 |+from typing import TYPE_CHECKING + 3 |+ + 4 |+if TYPE_CHECKING: + 5 |+ from pandas import DataFrame +2 6 | +3 7 | +4 8 | def f(): +-------------------------------------------------------------------------------- +26 30 | +27 31 | +28 32 | def f(): +29 |- from pandas import DataFrame # TC002 +30 33 | +31 34 | x: DataFrame = 2 +32 35 | + +TC002.py:35:37: TC002 [*] Move third-party import `pandas.DataFrame` into a type-checking block + | +34 | def f(): +35 | from pandas import DataFrame as df # TC002 + | ^^ TC002 +36 | +37 | x: df = 3 + | + = help: Move into type-checking block + +ℹ Unsafe fix +1 1 | """Tests to determine accurate detection of typing-only imports.""" + 2 |+from typing import TYPE_CHECKING + 3 |+ + 4 |+if TYPE_CHECKING: + 5 |+ from pandas import DataFrame as df +2 6 | +3 7 | +4 8 | def f(): +-------------------------------------------------------------------------------- +32 36 | +33 37 | +34 38 | def f(): +35 |- from pandas import DataFrame as df # TC002 +36 39 | +37 40 | x: df = 3 +38 41 | + +TC002.py:41:22: TC002 [*] Move third-party import `pandas` into a type-checking block + | +40 | def f(): +41 | import pandas as pd # TC002 + | ^^ TC002 +42 | +43 | x: "pd.DataFrame" = 1 + | + = help: Move into type-checking block + +ℹ Unsafe fix +1 1 | """Tests to determine accurate detection of typing-only imports.""" + 2 |+from typing import TYPE_CHECKING + 3 |+ + 4 |+if TYPE_CHECKING: + 5 |+ import pandas as pd +2 6 | +3 7 | +4 8 | def f(): +-------------------------------------------------------------------------------- +38 42 | +39 43 | +40 44 | def f(): +41 |- import pandas as pd # TC002 +42 45 | +43 46 | x: "pd.DataFrame" = 1 +44 47 | + +TC002.py:47:22: TC002 [*] Move third-party import `pandas` into a type-checking block + | +46 | def f(): +47 | import pandas as pd # TC002 + | ^^ TC002 +48 | +49 | x = dict["pd.DataFrame", "pd.DataFrame"] + | + = help: Move into type-checking block + +ℹ Unsafe fix +1 1 | """Tests to determine accurate detection of typing-only imports.""" + 2 |+from typing import TYPE_CHECKING + 3 |+ + 4 |+if TYPE_CHECKING: + 5 |+ import pandas as pd +2 6 | +3 7 | +4 8 | def f(): +-------------------------------------------------------------------------------- +44 48 | +45 49 | +46 50 | def f(): +47 |- import pandas as pd # TC002 +48 51 | +49 52 | x = dict["pd.DataFrame", "pd.DataFrame"] +50 53 | + +TC002.py:172:24: TC002 [*] Move third-party import `module.Member` into a type-checking block + | +170 | global Member +171 | +172 | from module import Member + | ^^^^^^ TC002 +173 | +174 | x: Member = 1 + | + = help: Move into type-checking block + +ℹ Unsafe fix +1 1 | """Tests to determine accurate detection of typing-only imports.""" + 2 |+from typing import TYPE_CHECKING + 3 |+ + 4 |+if TYPE_CHECKING: + 5 |+ from module import Member +2 6 | +3 7 | +4 8 | def f(): +-------------------------------------------------------------------------------- +169 173 | def f(): +170 174 | global Member +171 175 | +172 |- from module import Member +173 176 | +174 177 | x: Member = 1 +175 178 | diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC003_TC003.py.snap b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC003_TC003.py.snap new file mode 100644 index 0000000000..e7590ea5c2 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__add_future_import__TC003_TC003.py.snap @@ -0,0 +1,28 @@ +--- +source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs +--- +TC003.py:8:12: TC003 [*] Move standard library import `os` into a type-checking block + | + 7 | def f(): + 8 | import os + | ^^ TC003 + 9 | +10 | x: os + | + = help: Move into type-checking block + +ℹ Unsafe fix +2 2 | +3 3 | For typing-only import detection tests, see `TC002.py`. +4 4 | """ + 5 |+from typing import TYPE_CHECKING + 6 |+ + 7 |+if TYPE_CHECKING: + 8 |+ import os +5 9 | +6 10 | +7 11 | def f(): +8 |- import os +9 12 | +10 13 | x: os +11 14 | diff --git a/crates/ruff_linter/src/rules/pyupgrade/mod.rs b/crates/ruff_linter/src/rules/pyupgrade/mod.rs index 76ff4b0983..3f64cb323f 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/mod.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/mod.rs @@ -136,6 +136,23 @@ mod tests { Ok(()) } + #[test_case(Rule::QuotedAnnotation, Path::new("UP037_0.py"))] + #[test_case(Rule::QuotedAnnotation, Path::new("UP037_1.py"))] + #[test_case(Rule::QuotedAnnotation, Path::new("UP037_2.pyi"))] + fn up037_add_future_annotation(rule_code: Rule, path: &Path) -> Result<()> { + let snapshot = format!("add_future_annotation_{}", path.to_string_lossy()); + let diagnostics = test_path( + Path::new("pyupgrade").join(path).as_path(), + &settings::LinterSettings { + preview: PreviewMode::Enabled, + future_annotations: true, + ..settings::LinterSettings::for_rule(rule_code) + }, + )?; + assert_diagnostics!(snapshot, diagnostics); + Ok(()) + } + #[test] fn async_timeout_error_alias_not_applied_py310() -> Result<()> { let diagnostics = test_path( diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/quoted_annotation.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/quoted_annotation.rs index f890b83372..8355f9bd4c 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/quoted_annotation.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/quoted_annotation.rs @@ -57,6 +57,22 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// bar: Bar /// ``` /// +/// ## Preview +/// +/// When [preview] is enabled, if [`lint.future-annotations`] is set to `true`, +/// `from __future__ import annotations` will be added if doing so would allow an annotation to be +/// unquoted. +/// +/// ## Fix safety +/// +/// The rule's fix is marked as safe, unless [preview] and +/// [`lint.future_annotations`] are enabled and a `from __future__ import +/// annotations` import is added. Such an import may change the behavior of all annotations in the +/// file. +/// +/// ## Options +/// - `lint.future-annotations` +/// /// ## See also /// - [`quoted-annotation-in-stub`][PYI020]: A rule that /// removes all quoted annotations from stub files @@ -69,6 +85,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix}; /// /// [PYI020]: https://docs.astral.sh/ruff/rules/quoted-annotation-in-stub/ /// [TC008]: https://docs.astral.sh/ruff/rules/quoted-type-alias/ +/// [preview]: https://docs.astral.sh/ruff/preview/ #[derive(ViolationMetadata)] pub(crate) struct QuotedAnnotation; @@ -85,6 +102,13 @@ impl AlwaysFixableViolation for QuotedAnnotation { /// UP037 pub(crate) fn quoted_annotation(checker: &Checker, annotation: &str, range: TextRange) { + let add_future_import = checker.settings().future_annotations() + && checker.semantic().in_runtime_evaluated_annotation(); + + if !(checker.semantic().in_typing_only_annotation() || add_future_import) { + return; + } + let placeholder_range = TextRange::up_to(annotation.text_len()); let spans_multiple_lines = annotation.contains_line_break(placeholder_range); @@ -103,8 +127,14 @@ pub(crate) fn quoted_annotation(checker: &Checker, annotation: &str, range: Text (true, false) => format!("({annotation})"), (_, true) => format!("({annotation}\n)"), }; - let edit = Edit::range_replacement(new_content, range); - let fix = Fix::safe_edit(edit); + let unquote_edit = Edit::range_replacement(new_content, range); + + let fix = if add_future_import { + let import_edit = checker.importer().add_future_import(); + Fix::unsafe_edits(unquote_edit, [import_edit]) + } else { + Fix::safe_edit(unquote_edit) + }; checker .report_diagnostic(QuotedAnnotation, range) diff --git a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__add_future_annotation_UP037_0.py.snap b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__add_future_annotation_UP037_0.py.snap new file mode 100644 index 0000000000..2549736148 --- /dev/null +++ b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__add_future_annotation_UP037_0.py.snap @@ -0,0 +1,625 @@ +--- +source: crates/ruff_linter/src/rules/pyupgrade/mod.rs +--- +UP037_0.py:18:14: UP037 [*] Remove quotes from type annotation + | +18 | def foo(var: "MyClass") -> "MyClass": + | ^^^^^^^^^ UP037 +19 | x: "MyClass" + | + = help: Remove quotes + +ℹ Safe fix +15 15 | from mypy_extensions import Arg, DefaultArg, DefaultNamedArg, NamedArg, VarArg +16 16 | +17 17 | +18 |-def foo(var: "MyClass") -> "MyClass": + 18 |+def foo(var: MyClass) -> "MyClass": +19 19 | x: "MyClass" +20 20 | +21 21 | + +UP037_0.py:18:28: UP037 [*] Remove quotes from type annotation + | +18 | def foo(var: "MyClass") -> "MyClass": + | ^^^^^^^^^ UP037 +19 | x: "MyClass" + | + = help: Remove quotes + +ℹ Safe fix +15 15 | from mypy_extensions import Arg, DefaultArg, DefaultNamedArg, NamedArg, VarArg +16 16 | +17 17 | +18 |-def foo(var: "MyClass") -> "MyClass": + 18 |+def foo(var: "MyClass") -> MyClass: +19 19 | x: "MyClass" +20 20 | +21 21 | + +UP037_0.py:19:8: UP037 [*] Remove quotes from type annotation + | +18 | def foo(var: "MyClass") -> "MyClass": +19 | x: "MyClass" + | ^^^^^^^^^ UP037 + | + = help: Remove quotes + +ℹ Safe fix +16 16 | +17 17 | +18 18 | def foo(var: "MyClass") -> "MyClass": +19 |- x: "MyClass" + 19 |+ x: MyClass +20 20 | +21 21 | +22 22 | def foo(*, inplace: "bool"): + +UP037_0.py:22:21: UP037 [*] Remove quotes from type annotation + | +22 | def foo(*, inplace: "bool"): + | ^^^^^^ UP037 +23 | pass + | + = help: Remove quotes + +ℹ Safe fix +19 19 | x: "MyClass" +20 20 | +21 21 | +22 |-def foo(*, inplace: "bool"): + 22 |+def foo(*, inplace: bool): +23 23 | pass +24 24 | +25 25 | + +UP037_0.py:26:16: UP037 [*] Remove quotes from type annotation + | +26 | def foo(*args: "str", **kwargs: "int"): + | ^^^^^ UP037 +27 | pass + | + = help: Remove quotes + +ℹ Safe fix +23 23 | pass +24 24 | +25 25 | +26 |-def foo(*args: "str", **kwargs: "int"): + 26 |+def foo(*args: str, **kwargs: "int"): +27 27 | pass +28 28 | +29 29 | + +UP037_0.py:26:33: UP037 [*] Remove quotes from type annotation + | +26 | def foo(*args: "str", **kwargs: "int"): + | ^^^^^ UP037 +27 | pass + | + = help: Remove quotes + +ℹ Safe fix +23 23 | pass +24 24 | +25 25 | +26 |-def foo(*args: "str", **kwargs: "int"): + 26 |+def foo(*args: "str", **kwargs: int): +27 27 | pass +28 28 | +29 29 | + +UP037_0.py:30:10: UP037 [*] Remove quotes from type annotation + | +30 | x: Tuple["MyClass"] + | ^^^^^^^^^ UP037 +31 | +32 | x: Callable[["MyClass"], None] + | + = help: Remove quotes + +ℹ Safe fix +27 27 | pass +28 28 | +29 29 | +30 |-x: Tuple["MyClass"] + 30 |+x: Tuple[MyClass] +31 31 | +32 32 | x: Callable[["MyClass"], None] +33 33 | + +UP037_0.py:32:14: UP037 [*] Remove quotes from type annotation + | +30 | x: Tuple["MyClass"] +31 | +32 | x: Callable[["MyClass"], None] + | ^^^^^^^^^ UP037 + | + = help: Remove quotes + +ℹ Safe fix +29 29 | +30 30 | x: Tuple["MyClass"] +31 31 | +32 |-x: Callable[["MyClass"], None] + 32 |+x: Callable[[MyClass], None] +33 33 | +34 34 | +35 35 | class Foo(NamedTuple): + +UP037_0.py:36:8: UP037 [*] Remove quotes from type annotation + | +35 | class Foo(NamedTuple): +36 | x: "MyClass" + | ^^^^^^^^^ UP037 + | + = help: Remove quotes + +ℹ Safe fix +33 33 | +34 34 | +35 35 | class Foo(NamedTuple): +36 |- x: "MyClass" + 36 |+ x: MyClass +37 37 | +38 38 | +39 39 | class D(TypedDict): + +UP037_0.py:40:27: UP037 [*] Remove quotes from type annotation + | +39 | class D(TypedDict): +40 | E: TypedDict("E", foo="int", total=False) + | ^^^^^ UP037 + | + = help: Remove quotes + +ℹ Safe fix +37 37 | +38 38 | +39 39 | class D(TypedDict): +40 |- E: TypedDict("E", foo="int", total=False) + 40 |+ E: TypedDict("E", foo=int, total=False) +41 41 | +42 42 | +43 43 | class D(TypedDict): + +UP037_0.py:44:31: UP037 [*] Remove quotes from type annotation + | +43 | class D(TypedDict): +44 | E: TypedDict("E", {"foo": "int"}) + | ^^^^^ UP037 + | + = help: Remove quotes + +ℹ Safe fix +41 41 | +42 42 | +43 43 | class D(TypedDict): +44 |- E: TypedDict("E", {"foo": "int"}) + 44 |+ E: TypedDict("E", {"foo": int}) +45 45 | +46 46 | +47 47 | x: Annotated["str", "metadata"] + +UP037_0.py:47:14: UP037 [*] Remove quotes from type annotation + | +47 | x: Annotated["str", "metadata"] + | ^^^^^ UP037 +48 | +49 | x: Arg("str", "name") + | + = help: Remove quotes + +ℹ Safe fix +44 44 | E: TypedDict("E", {"foo": "int"}) +45 45 | +46 46 | +47 |-x: Annotated["str", "metadata"] + 47 |+x: Annotated[str, "metadata"] +48 48 | +49 49 | x: Arg("str", "name") +50 50 | + +UP037_0.py:49:8: UP037 [*] Remove quotes from type annotation + | +47 | x: Annotated["str", "metadata"] +48 | +49 | x: Arg("str", "name") + | ^^^^^ UP037 +50 | +51 | x: DefaultArg("str", "name") + | + = help: Remove quotes + +ℹ Safe fix +46 46 | +47 47 | x: Annotated["str", "metadata"] +48 48 | +49 |-x: Arg("str", "name") + 49 |+x: Arg(str, "name") +50 50 | +51 51 | x: DefaultArg("str", "name") +52 52 | + +UP037_0.py:51:15: UP037 [*] Remove quotes from type annotation + | +49 | x: Arg("str", "name") +50 | +51 | x: DefaultArg("str", "name") + | ^^^^^ UP037 +52 | +53 | x: NamedArg("str", "name") + | + = help: Remove quotes + +ℹ Safe fix +48 48 | +49 49 | x: Arg("str", "name") +50 50 | +51 |-x: DefaultArg("str", "name") + 51 |+x: DefaultArg(str, "name") +52 52 | +53 53 | x: NamedArg("str", "name") +54 54 | + +UP037_0.py:53:13: UP037 [*] Remove quotes from type annotation + | +51 | x: DefaultArg("str", "name") +52 | +53 | x: NamedArg("str", "name") + | ^^^^^ UP037 +54 | +55 | x: DefaultNamedArg("str", "name") + | + = help: Remove quotes + +ℹ Safe fix +50 50 | +51 51 | x: DefaultArg("str", "name") +52 52 | +53 |-x: NamedArg("str", "name") + 53 |+x: NamedArg(str, "name") +54 54 | +55 55 | x: DefaultNamedArg("str", "name") +56 56 | + +UP037_0.py:55:20: UP037 [*] Remove quotes from type annotation + | +53 | x: NamedArg("str", "name") +54 | +55 | x: DefaultNamedArg("str", "name") + | ^^^^^ UP037 +56 | +57 | x: DefaultNamedArg("str", name="name") + | + = help: Remove quotes + +ℹ Safe fix +52 52 | +53 53 | x: NamedArg("str", "name") +54 54 | +55 |-x: DefaultNamedArg("str", "name") + 55 |+x: DefaultNamedArg(str, "name") +56 56 | +57 57 | x: DefaultNamedArg("str", name="name") +58 58 | + +UP037_0.py:57:20: UP037 [*] Remove quotes from type annotation + | +55 | x: DefaultNamedArg("str", "name") +56 | +57 | x: DefaultNamedArg("str", name="name") + | ^^^^^ UP037 +58 | +59 | x: VarArg("str") + | + = help: Remove quotes + +ℹ Safe fix +54 54 | +55 55 | x: DefaultNamedArg("str", "name") +56 56 | +57 |-x: DefaultNamedArg("str", name="name") + 57 |+x: DefaultNamedArg(str, name="name") +58 58 | +59 59 | x: VarArg("str") +60 60 | + +UP037_0.py:59:11: UP037 [*] Remove quotes from type annotation + | +57 | x: DefaultNamedArg("str", name="name") +58 | +59 | x: VarArg("str") + | ^^^^^ UP037 +60 | +61 | x: List[List[List["MyClass"]]] + | + = help: Remove quotes + +ℹ Safe fix +56 56 | +57 57 | x: DefaultNamedArg("str", name="name") +58 58 | +59 |-x: VarArg("str") + 59 |+x: VarArg(str) +60 60 | +61 61 | x: List[List[List["MyClass"]]] +62 62 | + +UP037_0.py:61:19: UP037 [*] Remove quotes from type annotation + | +59 | x: VarArg("str") +60 | +61 | x: List[List[List["MyClass"]]] + | ^^^^^^^^^ UP037 +62 | +63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")]) + | + = help: Remove quotes + +ℹ Safe fix +58 58 | +59 59 | x: VarArg("str") +60 60 | +61 |-x: List[List[List["MyClass"]]] + 61 |+x: List[List[List[MyClass]]] +62 62 | +63 63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")]) +64 64 | + +UP037_0.py:63:29: UP037 [*] Remove quotes from type annotation + | +61 | x: List[List[List["MyClass"]]] +62 | +63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")]) + | ^^^^^ UP037 +64 | +65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) + | + = help: Remove quotes + +ℹ Safe fix +60 60 | +61 61 | x: List[List[List["MyClass"]]] +62 62 | +63 |-x: NamedTuple("X", [("foo", "int"), ("bar", "str")]) + 63 |+x: NamedTuple("X", [("foo", int), ("bar", "str")]) +64 64 | +65 65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) +66 66 | + +UP037_0.py:63:45: UP037 [*] Remove quotes from type annotation + | +61 | x: List[List[List["MyClass"]]] +62 | +63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")]) + | ^^^^^ UP037 +64 | +65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) + | + = help: Remove quotes + +ℹ Safe fix +60 60 | +61 61 | x: List[List[List["MyClass"]]] +62 62 | +63 |-x: NamedTuple("X", [("foo", "int"), ("bar", "str")]) + 63 |+x: NamedTuple("X", [("foo", "int"), ("bar", str)]) +64 64 | +65 65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) +66 66 | + +UP037_0.py:65:29: UP037 [*] Remove quotes from type annotation + | +63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")]) +64 | +65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) + | ^^^^^ UP037 +66 | +67 | x: NamedTuple(typename="X", fields=[("foo", "int")]) + | + = help: Remove quotes + +ℹ Safe fix +62 62 | +63 63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")]) +64 64 | +65 |-x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) + 65 |+x: NamedTuple("X", fields=[(foo, "int"), ("bar", "str")]) +66 66 | +67 67 | x: NamedTuple(typename="X", fields=[("foo", "int")]) +68 68 | + +UP037_0.py:65:36: UP037 [*] Remove quotes from type annotation + | +63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")]) +64 | +65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) + | ^^^^^ UP037 +66 | +67 | x: NamedTuple(typename="X", fields=[("foo", "int")]) + | + = help: Remove quotes + +ℹ Safe fix +62 62 | +63 63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")]) +64 64 | +65 |-x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) + 65 |+x: NamedTuple("X", fields=[("foo", int), ("bar", "str")]) +66 66 | +67 67 | x: NamedTuple(typename="X", fields=[("foo", "int")]) +68 68 | + +UP037_0.py:65:45: UP037 [*] Remove quotes from type annotation + | +63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")]) +64 | +65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) + | ^^^^^ UP037 +66 | +67 | x: NamedTuple(typename="X", fields=[("foo", "int")]) + | + = help: Remove quotes + +ℹ Safe fix +62 62 | +63 63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")]) +64 64 | +65 |-x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) + 65 |+x: NamedTuple("X", fields=[("foo", "int"), (bar, "str")]) +66 66 | +67 67 | x: NamedTuple(typename="X", fields=[("foo", "int")]) +68 68 | + +UP037_0.py:65:52: UP037 [*] Remove quotes from type annotation + | +63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")]) +64 | +65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) + | ^^^^^ UP037 +66 | +67 | x: NamedTuple(typename="X", fields=[("foo", "int")]) + | + = help: Remove quotes + +ℹ Safe fix +62 62 | +63 63 | x: NamedTuple("X", [("foo", "int"), ("bar", "str")]) +64 64 | +65 |-x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) + 65 |+x: NamedTuple("X", fields=[("foo", "int"), ("bar", str)]) +66 66 | +67 67 | x: NamedTuple(typename="X", fields=[("foo", "int")]) +68 68 | + +UP037_0.py:67:24: UP037 [*] Remove quotes from type annotation + | +65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) +66 | +67 | x: NamedTuple(typename="X", fields=[("foo", "int")]) + | ^^^ UP037 +68 | +69 | X: MyCallable("X") + | + = help: Remove quotes + +ℹ Safe fix +64 64 | +65 65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) +66 66 | +67 |-x: NamedTuple(typename="X", fields=[("foo", "int")]) + 67 |+x: NamedTuple(typename=X, fields=[("foo", "int")]) +68 68 | +69 69 | X: MyCallable("X") +70 70 | + +UP037_0.py:67:38: UP037 [*] Remove quotes from type annotation + | +65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) +66 | +67 | x: NamedTuple(typename="X", fields=[("foo", "int")]) + | ^^^^^ UP037 +68 | +69 | X: MyCallable("X") + | + = help: Remove quotes + +ℹ Safe fix +64 64 | +65 65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) +66 66 | +67 |-x: NamedTuple(typename="X", fields=[("foo", "int")]) + 67 |+x: NamedTuple(typename="X", fields=[(foo, "int")]) +68 68 | +69 69 | X: MyCallable("X") +70 70 | + +UP037_0.py:67:45: UP037 [*] Remove quotes from type annotation + | +65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) +66 | +67 | x: NamedTuple(typename="X", fields=[("foo", "int")]) + | ^^^^^ UP037 +68 | +69 | X: MyCallable("X") + | + = help: Remove quotes + +ℹ Safe fix +64 64 | +65 65 | x: NamedTuple("X", fields=[("foo", "int"), ("bar", "str")]) +66 66 | +67 |-x: NamedTuple(typename="X", fields=[("foo", "int")]) + 67 |+x: NamedTuple(typename="X", fields=[("foo", int)]) +68 68 | +69 69 | X: MyCallable("X") +70 70 | + +UP037_0.py:112:12: UP037 [*] Remove quotes from type annotation + | +110 | # Handle end of line comment in string annotation +111 | # See https://github.com/astral-sh/ruff/issues/15816 +112 | def f() -> "Literal[0]#": + | ^^^^^^^^^^^^^ UP037 +113 | return 0 + | + = help: Remove quotes + +ℹ Safe fix +109 109 | +110 110 | # Handle end of line comment in string annotation +111 111 | # See https://github.com/astral-sh/ruff/issues/15816 +112 |-def f() -> "Literal[0]#": + 112 |+def f() -> (Literal[0]# + 113 |+): +113 114 | return 0 +114 115 | +115 116 | def g(x: "Literal['abc']#") -> None: + +UP037_0.py:115:10: UP037 [*] Remove quotes from type annotation + | +113 | return 0 +114 | +115 | def g(x: "Literal['abc']#") -> None: + | ^^^^^^^^^^^^^^^^^ UP037 +116 | return + | + = help: Remove quotes + +ℹ Safe fix +112 112 | def f() -> "Literal[0]#": +113 113 | return 0 +114 114 | +115 |-def g(x: "Literal['abc']#") -> None: + 115 |+def g(x: (Literal['abc']# + 116 |+)) -> None: +116 117 | return +117 118 | +118 119 | def f() -> """Literal[0] + +UP037_0.py:118:12: UP037 [*] Remove quotes from type annotation + | +116 | return +117 | +118 | def f() -> """Literal[0] + | ____________^ +119 | | # +120 | | +121 | | """: + | |_______^ UP037 +122 | return 0 + | + = help: Remove quotes + +ℹ Safe fix +115 115 | def g(x: "Literal['abc']#") -> None: +116 116 | return +117 117 | +118 |-def f() -> """Literal[0] + 118 |+def f() -> (Literal[0] +119 119 | # +120 120 | +121 |- """: + 121 |+ ): +122 122 | return 0 diff --git a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__add_future_annotation_UP037_1.py.snap b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__add_future_annotation_UP037_1.py.snap new file mode 100644 index 0000000000..1f195271c7 --- /dev/null +++ b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__add_future_annotation_UP037_1.py.snap @@ -0,0 +1,42 @@ +--- +source: crates/ruff_linter/src/rules/pyupgrade/mod.rs +--- +UP037_1.py:9:8: UP037 [*] Remove quotes from type annotation + | + 7 | def foo(): + 8 | # UP037 + 9 | x: "Tuple[int, int]" = (0, 0) + | ^^^^^^^^^^^^^^^^^ UP037 +10 | print(x) + | + = help: Remove quotes + +ℹ Safe fix +6 6 | +7 7 | def foo(): +8 8 | # UP037 +9 |- x: "Tuple[int, int]" = (0, 0) + 9 |+ x: Tuple[int, int] = (0, 0) +10 10 | print(x) +11 11 | +12 12 | + +UP037_1.py:14:4: UP037 [*] Remove quotes from type annotation + | +13 | # OK +14 | X: "Tuple[int, int]" = (0, 0) + | ^^^^^^^^^^^^^^^^^ UP037 + | + = help: Remove quotes + +ℹ Unsafe fix + 1 |+from __future__ import annotations +1 2 | from typing import TYPE_CHECKING +2 3 | +3 4 | if TYPE_CHECKING: +-------------------------------------------------------------------------------- +11 12 | +12 13 | +13 14 | # OK +14 |-X: "Tuple[int, int]" = (0, 0) + 15 |+X: Tuple[int, int] = (0, 0) diff --git a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__add_future_annotation_UP037_2.pyi.snap b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__add_future_annotation_UP037_2.pyi.snap new file mode 100644 index 0000000000..dd66f98b51 --- /dev/null +++ b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__add_future_annotation_UP037_2.pyi.snap @@ -0,0 +1,232 @@ +--- +source: crates/ruff_linter/src/rules/pyupgrade/mod.rs +--- +UP037_2.pyi:3:14: UP037 [*] Remove quotes from type annotation + | +1 | # https://github.com/astral-sh/ruff/issues/7102 +2 | +3 | def f(a: Foo['SingleLine # Comment']): ... + | ^^^^^^^^^^^^^^^^^^^^^^^ UP037 + | + = help: Remove quotes + +ℹ Safe fix +1 1 | # https://github.com/astral-sh/ruff/issues/7102 +2 2 | +3 |-def f(a: Foo['SingleLine # Comment']): ... + 3 |+def f(a: Foo[(SingleLine # Comment + 4 |+)]): ... +4 5 | +5 6 | +6 7 | def f(a: Foo['''Bar[ + +UP037_2.pyi:6:14: UP037 [*] Remove quotes from type annotation + | +6 | def f(a: Foo['''Bar[ + | ______________^ +7 | | Multi | +8 | | Line]''']): ... + | |____________^ UP037 + | + = help: Remove quotes + +ℹ Safe fix +3 3 | def f(a: Foo['SingleLine # Comment']): ... +4 4 | +5 5 | +6 |-def f(a: Foo['''Bar[ + 6 |+def f(a: Foo[Bar[ +7 7 | Multi | +8 |- Line]''']): ... + 8 |+ Line]]): ... +9 9 | +10 10 | +11 11 | def f(a: Foo['''Bar[ + +UP037_2.pyi:11:14: UP037 [*] Remove quotes from type annotation + | +11 | def f(a: Foo['''Bar[ + | ______________^ +12 | | Multi | +13 | | Line # Comment +14 | | ]''']): ... + | |____^ UP037 + | + = help: Remove quotes + +ℹ Safe fix +8 8 | Line]''']): ... +9 9 | +10 10 | +11 |-def f(a: Foo['''Bar[ + 11 |+def f(a: Foo[Bar[ +12 12 | Multi | +13 13 | Line # Comment +14 |-]''']): ... + 14 |+]]): ... +15 15 | +16 16 | +17 17 | def f(a: Foo['''Bar[ + +UP037_2.pyi:17:14: UP037 [*] Remove quotes from type annotation + | +17 | def f(a: Foo['''Bar[ + | ______________^ +18 | | Multi | +19 | | Line] # Comment''']): ... + | |_______________________^ UP037 + | + = help: Remove quotes + +ℹ Safe fix +14 14 | ]''']): ... +15 15 | +16 16 | +17 |-def f(a: Foo['''Bar[ + 17 |+def f(a: Foo[(Bar[ +18 18 | Multi | +19 |- Line] # Comment''']): ... + 19 |+ Line] # Comment + 20 |+)]): ... +20 21 | +21 22 | +22 23 | def f(a: Foo[''' + +UP037_2.pyi:22:14: UP037 [*] Remove quotes from type annotation + | +22 | def f(a: Foo[''' + | ______________^ +23 | | Bar[ +24 | | Multi | +25 | | Line] # Comment''']): ... + | |_______________________^ UP037 + | + = help: Remove quotes + +ℹ Safe fix +19 19 | Line] # Comment''']): ... +20 20 | +21 21 | +22 |-def f(a: Foo[''' + 22 |+def f(a: Foo[( +23 23 | Bar[ +24 24 | Multi | +25 |- Line] # Comment''']): ... + 25 |+ Line] # Comment + 26 |+)]): ... +26 27 | +27 28 | +28 29 | def f(a: '''list[int] + +UP037_2.pyi:28:10: UP037 [*] Remove quotes from type annotation + | +28 | def f(a: '''list[int] + | __________^ +29 | | ''' = []): ... + | |_______^ UP037 + | + = help: Remove quotes + +ℹ Safe fix +25 25 | Line] # Comment''']): ... +26 26 | +27 27 | +28 |-def f(a: '''list[int] +29 |- ''' = []): ... + 28 |+def f(a: list[int] + 29 |+ = []): ... +30 30 | +31 31 | +32 32 | a: '''\\ + +UP037_2.pyi:32:4: UP037 [*] Remove quotes from type annotation + | +32 | a: '''\\ + | ____^ +33 | | list[int]''' = [42] + | |____________^ UP037 + | + = help: Remove quotes + +ℹ Safe fix +29 29 | ''' = []): ... +30 30 | +31 31 | +32 |-a: '''\\ +33 |-list[int]''' = [42] + 32 |+a: (\ + 33 |+list[int]) = [42] +34 34 | +35 35 | +36 36 | def f(a: ''' + +UP037_2.pyi:36:10: UP037 [*] Remove quotes from type annotation + | +36 | def f(a: ''' + | __________^ +37 | | list[int] +38 | | ''' = []): ... + | |_______^ UP037 + | + = help: Remove quotes + +ℹ Safe fix +33 33 | list[int]''' = [42] +34 34 | +35 35 | +36 |-def f(a: ''' + 36 |+def f(a: +37 37 | list[int] +38 |- ''' = []): ... + 38 |+ = []): ... +39 39 | +40 40 | +41 41 | def f(a: Foo[''' + +UP037_2.pyi:41:14: UP037 [*] Remove quotes from type annotation + | +41 | def f(a: Foo[''' + | ______________^ +42 | | Bar +43 | | [ +44 | | Multi | +45 | | Line +46 | | ] # Comment''']): ... + | |___________________^ UP037 + | + = help: Remove quotes + +ℹ Safe fix +38 38 | ''' = []): ... +39 39 | +40 40 | +41 |-def f(a: Foo[''' + 41 |+def f(a: Foo[( +42 42 | Bar +43 43 | [ +44 44 | Multi | +45 45 | Line +46 |- ] # Comment''']): ... + 46 |+ ] # Comment + 47 |+)]): ... +47 48 | +48 49 | +49 50 | a: '''list + +UP037_2.pyi:49:4: UP037 [*] Remove quotes from type annotation + | +49 | a: '''list + | ____^ +50 | | [int]''' = [42] + | |________^ UP037 + | + = help: Remove quotes + +ℹ Safe fix +46 46 | ] # Comment''']): ... +47 47 | +48 48 | +49 |-a: '''list +50 |-[int]''' = [42] + 49 |+a: (list + 50 |+[int]) = [42] diff --git a/crates/ruff_linter/src/rules/ruff/mod.rs b/crates/ruff_linter/src/rules/ruff/mod.rs index 0d262b73c1..ef8dd584e5 100644 --- a/crates/ruff_linter/src/rules/ruff/mod.rs +++ b/crates/ruff_linter/src/rules/ruff/mod.rs @@ -599,4 +599,24 @@ mod tests { assert_diagnostics!(snapshot, diagnostics); Ok(()) } + + #[test_case(Rule::ImplicitOptional, Path::new("RUF013_0.py"))] + #[test_case(Rule::ImplicitOptional, Path::new("RUF013_1.py"))] + #[test_case(Rule::ImplicitOptional, Path::new("RUF013_2.py"))] + #[test_case(Rule::ImplicitOptional, Path::new("RUF013_3.py"))] + #[test_case(Rule::ImplicitOptional, Path::new("RUF013_4.py"))] + fn ruf013_add_future_import(rule_code: Rule, path: &Path) -> Result<()> { + let snapshot = format!("add_future_import_{}", path.to_string_lossy()); + let diagnostics = test_path( + Path::new("ruff").join(path).as_path(), + &settings::LinterSettings { + preview: PreviewMode::Enabled, + future_annotations: true, + unresolved_target_version: PythonVersion::PY39.into(), + ..settings::LinterSettings::for_rule(rule_code) + }, + )?; + assert_diagnostics!(snapshot, diagnostics); + Ok(()) + } } diff --git a/crates/ruff_linter/src/rules/ruff/rules/implicit_optional.rs b/crates/ruff_linter/src/rules/ruff/rules/implicit_optional.rs index 1a4653c429..83c0065d8b 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/implicit_optional.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/implicit_optional.rs @@ -71,6 +71,13 @@ use crate::rules::ruff::typing::type_hint_explicitly_allows_none; /// /// ## Options /// - `target-version` +/// - `lint.future-annotations` +/// +/// ## Preview +/// +/// When [preview] is enabled, if [`lint.future-annotations`] is set to `true`, +/// `from __future__ import annotations` will be added if doing so would allow using the `|` +/// operator on a Python version before 3.10. /// /// ## Fix safety /// @@ -136,10 +143,15 @@ fn generate_fix(checker: &Checker, conversion_type: ConversionType, expr: &Expr) node_index: ruff_python_ast::AtomicNodeIndex::dummy(), }); let content = checker.generator().expr(&new_expr); - Ok(Fix::unsafe_edit(Edit::range_replacement( - content, - expr.range(), - ))) + let edit = Edit::range_replacement(content, expr.range()); + if checker.target_version() < PythonVersion::PY310 { + Ok(Fix::unsafe_edits( + edit, + [checker.importer().add_future_import()], + )) + } else { + Ok(Fix::unsafe_edit(edit)) + } } ConversionType::Optional => { let importer = checker @@ -187,6 +199,7 @@ pub(crate) fn implicit_optional(checker: &Checker, parameters: &Parameters) { ) else { continue; }; + let conversion_type = checker.target_version().into(); let mut diagnostic = @@ -202,7 +215,14 @@ pub(crate) fn implicit_optional(checker: &Checker, parameters: &Parameters) { else { continue; }; - let conversion_type = checker.target_version().into(); + + let conversion_type = if checker.target_version() >= PythonVersion::PY310 + || checker.settings().future_annotations() + { + ConversionType::BinOpOr + } else { + ConversionType::Optional + }; let mut diagnostic = checker.report_diagnostic(ImplicitOptional { conversion_type }, expr.range()); diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__add_future_import_RUF013_0.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__add_future_import_RUF013_0.py.snap new file mode 100644 index 0000000000..ac893508e1 --- /dev/null +++ b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__add_future_import_RUF013_0.py.snap @@ -0,0 +1,445 @@ +--- +source: crates/ruff_linter/src/rules/ruff/mod.rs +--- +RUF013_0.py:20:12: RUF013 [*] PEP 484 prohibits implicit `Optional` + | +20 | def f(arg: int = None): # RUF013 + | ^^^ RUF013 +21 | pass + | + = help: Convert to `T | None` + +ℹ Unsafe fix + 1 |+from __future__ import annotations +1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable +2 3 | +3 4 | +-------------------------------------------------------------------------------- +17 18 | pass +18 19 | +19 20 | +20 |-def f(arg: int = None): # RUF013 + 21 |+def f(arg: int | None = None): # RUF013 +21 22 | pass +22 23 | +23 24 | + +RUF013_0.py:24:12: RUF013 [*] PEP 484 prohibits implicit `Optional` + | +24 | def f(arg: str = None): # RUF013 + | ^^^ RUF013 +25 | pass + | + = help: Convert to `T | None` + +ℹ Unsafe fix + 1 |+from __future__ import annotations +1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable +2 3 | +3 4 | +-------------------------------------------------------------------------------- +21 22 | pass +22 23 | +23 24 | +24 |-def f(arg: str = None): # RUF013 + 25 |+def f(arg: str | None = None): # RUF013 +25 26 | pass +26 27 | +27 28 | + +RUF013_0.py:28:12: RUF013 [*] PEP 484 prohibits implicit `Optional` + | +28 | def f(arg: Tuple[str] = None): # RUF013 + | ^^^^^^^^^^ RUF013 +29 | pass + | + = help: Convert to `T | None` + +ℹ Unsafe fix + 1 |+from __future__ import annotations +1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable +2 3 | +3 4 | +-------------------------------------------------------------------------------- +25 26 | pass +26 27 | +27 28 | +28 |-def f(arg: Tuple[str] = None): # RUF013 + 29 |+def f(arg: Tuple[str] | None = None): # RUF013 +29 30 | pass +30 31 | +31 32 | + +RUF013_0.py:58:12: RUF013 [*] PEP 484 prohibits implicit `Optional` + | +58 | def f(arg: Union = None): # RUF013 + | ^^^^^ RUF013 +59 | pass + | + = help: Convert to `T | None` + +ℹ Unsafe fix + 1 |+from __future__ import annotations +1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable +2 3 | +3 4 | +-------------------------------------------------------------------------------- +55 56 | pass +56 57 | +57 58 | +58 |-def f(arg: Union = None): # RUF013 + 59 |+def f(arg: Union | None = None): # RUF013 +59 60 | pass +60 61 | +61 62 | + +RUF013_0.py:62:12: RUF013 [*] PEP 484 prohibits implicit `Optional` + | +62 | def f(arg: Union[int] = None): # RUF013 + | ^^^^^^^^^^ RUF013 +63 | pass + | + = help: Convert to `T | None` + +ℹ Unsafe fix + 1 |+from __future__ import annotations +1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable +2 3 | +3 4 | +-------------------------------------------------------------------------------- +59 60 | pass +60 61 | +61 62 | +62 |-def f(arg: Union[int] = None): # RUF013 + 63 |+def f(arg: Union[int] | None = None): # RUF013 +63 64 | pass +64 65 | +65 66 | + +RUF013_0.py:66:12: RUF013 [*] PEP 484 prohibits implicit `Optional` + | +66 | def f(arg: Union[int, str] = None): # RUF013 + | ^^^^^^^^^^^^^^^ RUF013 +67 | pass + | + = help: Convert to `T | None` + +ℹ Unsafe fix + 1 |+from __future__ import annotations +1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable +2 3 | +3 4 | +-------------------------------------------------------------------------------- +63 64 | pass +64 65 | +65 66 | +66 |-def f(arg: Union[int, str] = None): # RUF013 + 67 |+def f(arg: Union[int, str] | None = None): # RUF013 +67 68 | pass +68 69 | +69 70 | + +RUF013_0.py:85:12: RUF013 [*] PEP 484 prohibits implicit `Optional` + | +85 | def f(arg: int | float = None): # RUF013 + | ^^^^^^^^^^^ RUF013 +86 | pass + | + = help: Convert to `T | None` + +ℹ Unsafe fix + 1 |+from __future__ import annotations +1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable +2 3 | +3 4 | +-------------------------------------------------------------------------------- +82 83 | pass +83 84 | +84 85 | +85 |-def f(arg: int | float = None): # RUF013 + 86 |+def f(arg: int | float | None = None): # RUF013 +86 87 | pass +87 88 | +88 89 | + +RUF013_0.py:89:12: RUF013 [*] PEP 484 prohibits implicit `Optional` + | +89 | def f(arg: int | float | str | bytes = None): # RUF013 + | ^^^^^^^^^^^^^^^^^^^^^^^^^ RUF013 +90 | pass + | + = help: Convert to `T | None` + +ℹ Unsafe fix + 1 |+from __future__ import annotations +1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable +2 3 | +3 4 | +-------------------------------------------------------------------------------- +86 87 | pass +87 88 | +88 89 | +89 |-def f(arg: int | float | str | bytes = None): # RUF013 + 90 |+def f(arg: int | float | str | bytes | None = None): # RUF013 +90 91 | pass +91 92 | +92 93 | + +RUF013_0.py:108:12: RUF013 [*] PEP 484 prohibits implicit `Optional` + | +108 | def f(arg: Literal[1] = None): # RUF013 + | ^^^^^^^^^^ RUF013 +109 | pass + | + = help: Convert to `T | None` + +ℹ Unsafe fix + 1 |+from __future__ import annotations +1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable +2 3 | +3 4 | +-------------------------------------------------------------------------------- +105 106 | pass +106 107 | +107 108 | +108 |-def f(arg: Literal[1] = None): # RUF013 + 109 |+def f(arg: Literal[1] | None = None): # RUF013 +109 110 | pass +110 111 | +111 112 | + +RUF013_0.py:112:12: RUF013 [*] PEP 484 prohibits implicit `Optional` + | +112 | def f(arg: Literal[1, "foo"] = None): # RUF013 + | ^^^^^^^^^^^^^^^^^ RUF013 +113 | pass + | + = help: Convert to `T | None` + +ℹ Unsafe fix + 1 |+from __future__ import annotations +1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable +2 3 | +3 4 | +-------------------------------------------------------------------------------- +109 110 | pass +110 111 | +111 112 | +112 |-def f(arg: Literal[1, "foo"] = None): # RUF013 + 113 |+def f(arg: Literal[1, "foo"] | None = None): # RUF013 +113 114 | pass +114 115 | +115 116 | + +RUF013_0.py:131:22: RUF013 [*] PEP 484 prohibits implicit `Optional` + | +131 | def f(arg: Annotated[int, ...] = None): # RUF013 + | ^^^ RUF013 +132 | pass + | + = help: Convert to `T | None` + +ℹ Unsafe fix + 1 |+from __future__ import annotations +1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable +2 3 | +3 4 | +-------------------------------------------------------------------------------- +128 129 | pass +129 130 | +130 131 | +131 |-def f(arg: Annotated[int, ...] = None): # RUF013 + 132 |+def f(arg: Annotated[int | None, ...] = None): # RUF013 +132 133 | pass +133 134 | +134 135 | + +RUF013_0.py:135:32: RUF013 [*] PEP 484 prohibits implicit `Optional` + | +135 | def f(arg: Annotated[Annotated[int | str, ...], ...] = None): # RUF013 + | ^^^^^^^^^ RUF013 +136 | pass + | + = help: Convert to `T | None` + +ℹ Unsafe fix + 1 |+from __future__ import annotations +1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable +2 3 | +3 4 | +-------------------------------------------------------------------------------- +132 133 | pass +133 134 | +134 135 | +135 |-def f(arg: Annotated[Annotated[int | str, ...], ...] = None): # RUF013 + 136 |+def f(arg: Annotated[Annotated[int | str | None, ...], ...] = None): # RUF013 +136 137 | pass +137 138 | +138 139 | + +RUF013_0.py:151:11: RUF013 [*] PEP 484 prohibits implicit `Optional` + | +150 | def f( +151 | arg1: int = None, # RUF013 + | ^^^ RUF013 +152 | arg2: Union[int, float] = None, # RUF013 +153 | arg3: Literal[1, 2, 3] = None, # RUF013 + | + = help: Convert to `T | None` + +ℹ Unsafe fix + 1 |+from __future__ import annotations +1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable +2 3 | +3 4 | +-------------------------------------------------------------------------------- +148 149 | +149 150 | +150 151 | def f( +151 |- arg1: int = None, # RUF013 + 152 |+ arg1: int | None = None, # RUF013 +152 153 | arg2: Union[int, float] = None, # RUF013 +153 154 | arg3: Literal[1, 2, 3] = None, # RUF013 +154 155 | ): + +RUF013_0.py:152:11: RUF013 [*] PEP 484 prohibits implicit `Optional` + | +150 | def f( +151 | arg1: int = None, # RUF013 +152 | arg2: Union[int, float] = None, # RUF013 + | ^^^^^^^^^^^^^^^^^ RUF013 +153 | arg3: Literal[1, 2, 3] = None, # RUF013 +154 | ): + | + = help: Convert to `T | None` + +ℹ Unsafe fix + 1 |+from __future__ import annotations +1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable +2 3 | +3 4 | +-------------------------------------------------------------------------------- +149 150 | +150 151 | def f( +151 152 | arg1: int = None, # RUF013 +152 |- arg2: Union[int, float] = None, # RUF013 + 153 |+ arg2: Union[int, float] | None = None, # RUF013 +153 154 | arg3: Literal[1, 2, 3] = None, # RUF013 +154 155 | ): +155 156 | pass + +RUF013_0.py:153:11: RUF013 [*] PEP 484 prohibits implicit `Optional` + | +151 | arg1: int = None, # RUF013 +152 | arg2: Union[int, float] = None, # RUF013 +153 | arg3: Literal[1, 2, 3] = None, # RUF013 + | ^^^^^^^^^^^^^^^^ RUF013 +154 | ): +155 | pass + | + = help: Convert to `T | None` + +ℹ Unsafe fix + 1 |+from __future__ import annotations +1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable +2 3 | +3 4 | +-------------------------------------------------------------------------------- +150 151 | def f( +151 152 | arg1: int = None, # RUF013 +152 153 | arg2: Union[int, float] = None, # RUF013 +153 |- arg3: Literal[1, 2, 3] = None, # RUF013 + 154 |+ arg3: Literal[1, 2, 3] | None = None, # RUF013 +154 155 | ): +155 156 | pass +156 157 | + +RUF013_0.py:181:12: RUF013 [*] PEP 484 prohibits implicit `Optional` + | +181 | def f(arg: Union[Annotated[int, ...], Union[str, bytes]] = None): # RUF013 + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF013 +182 | pass + | + = help: Convert to `T | None` + +ℹ Unsafe fix + 1 |+from __future__ import annotations +1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable +2 3 | +3 4 | +-------------------------------------------------------------------------------- +178 179 | pass +179 180 | +180 181 | +181 |-def f(arg: Union[Annotated[int, ...], Union[str, bytes]] = None): # RUF013 + 182 |+def f(arg: Union[Annotated[int, ...], Union[str, bytes]] | None = None): # RUF013 +182 183 | pass +183 184 | +184 185 | + +RUF013_0.py:188:13: RUF013 [*] PEP 484 prohibits implicit `Optional` + | +188 | def f(arg: "int" = None): # RUF013 + | ^^^ RUF013 +189 | pass + | + = help: Convert to `Optional[T]` + +ℹ Unsafe fix +185 185 | # Quoted +186 186 | +187 187 | +188 |-def f(arg: "int" = None): # RUF013 + 188 |+def f(arg: "Optional[int]" = None): # RUF013 +189 189 | pass +190 190 | +191 191 | + +RUF013_0.py:192:13: RUF013 [*] PEP 484 prohibits implicit `Optional` + | +192 | def f(arg: "str" = None): # RUF013 + | ^^^ RUF013 +193 | pass + | + = help: Convert to `Optional[T]` + +ℹ Unsafe fix +189 189 | pass +190 190 | +191 191 | +192 |-def f(arg: "str" = None): # RUF013 + 192 |+def f(arg: "Optional[str]" = None): # RUF013 +193 193 | pass +194 194 | +195 195 | + +RUF013_0.py:196:12: RUF013 PEP 484 prohibits implicit `Optional` + | +196 | def f(arg: "st" "r" = None): # RUF013 + | ^^^^^^^^ RUF013 +197 | pass + | + = help: Convert to `Optional[T]` + +RUF013_0.py:204:12: RUF013 [*] PEP 484 prohibits implicit `Optional` + | +204 | def f(arg: Union["int", "str"] = None): # RUF013 + | ^^^^^^^^^^^^^^^^^^^ RUF013 +205 | pass + | + = help: Convert to `T | None` + +ℹ Unsafe fix + 1 |+from __future__ import annotations +1 2 | from typing import Annotated, Any, Literal, Optional, Tuple, Union, Hashable +2 3 | +3 4 | +-------------------------------------------------------------------------------- +201 202 | pass +202 203 | +203 204 | +204 |-def f(arg: Union["int", "str"] = None): # RUF013 + 205 |+def f(arg: Union["int", "str"] | None = None): # RUF013 +205 206 | pass +206 207 | +207 208 | diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__add_future_import_RUF013_1.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__add_future_import_RUF013_1.py.snap new file mode 100644 index 0000000000..f770d07c98 --- /dev/null +++ b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__add_future_import_RUF013_1.py.snap @@ -0,0 +1,19 @@ +--- +source: crates/ruff_linter/src/rules/ruff/mod.rs +--- +RUF013_1.py:4:12: RUF013 [*] PEP 484 prohibits implicit `Optional` + | +4 | def f(arg: int = None): # RUF013 + | ^^^ RUF013 +5 | pass + | + = help: Convert to `T | None` + +ℹ Unsafe fix +1 1 | # No `typing.Optional` import + 2 |+from __future__ import annotations +2 3 | +3 4 | +4 |-def f(arg: int = None): # RUF013 + 5 |+def f(arg: int | None = None): # RUF013 +5 6 | pass diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__add_future_import_RUF013_2.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__add_future_import_RUF013_2.py.snap new file mode 100644 index 0000000000..7f58cfd724 --- /dev/null +++ b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__add_future_import_RUF013_2.py.snap @@ -0,0 +1,4 @@ +--- +source: crates/ruff_linter/src/rules/ruff/mod.rs +--- + diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__add_future_import_RUF013_3.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__add_future_import_RUF013_3.py.snap new file mode 100644 index 0000000000..4ec9031ff1 --- /dev/null +++ b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__add_future_import_RUF013_3.py.snap @@ -0,0 +1,65 @@ +--- +source: crates/ruff_linter/src/rules/ruff/mod.rs +--- +RUF013_3.py:4:12: RUF013 [*] PEP 484 prohibits implicit `Optional` + | +4 | def f(arg: typing.List[str] = None): # RUF013 + | ^^^^^^^^^^^^^^^^ RUF013 +5 | pass + | + = help: Convert to `T | None` + +ℹ Unsafe fix + 1 |+from __future__ import annotations +1 2 | import typing +2 3 | +3 4 | +4 |-def f(arg: typing.List[str] = None): # RUF013 + 5 |+def f(arg: typing.List[str] | None = None): # RUF013 +5 6 | pass +6 7 | +7 8 | + +RUF013_3.py:22:12: RUF013 [*] PEP 484 prohibits implicit `Optional` + | +22 | def f(arg: typing.Union[int, str] = None): # RUF013 + | ^^^^^^^^^^^^^^^^^^^^^^ RUF013 +23 | pass + | + = help: Convert to `T | None` + +ℹ Unsafe fix + 1 |+from __future__ import annotations +1 2 | import typing +2 3 | +3 4 | +-------------------------------------------------------------------------------- +19 20 | pass +20 21 | +21 22 | +22 |-def f(arg: typing.Union[int, str] = None): # RUF013 + 23 |+def f(arg: typing.Union[int, str] | None = None): # RUF013 +23 24 | pass +24 25 | +25 26 | + +RUF013_3.py:29:12: RUF013 [*] PEP 484 prohibits implicit `Optional` + | +29 | def f(arg: typing.Literal[1, "foo", True] = None): # RUF013 + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF013 +30 | pass + | + = help: Convert to `T | None` + +ℹ Unsafe fix + 1 |+from __future__ import annotations +1 2 | import typing +2 3 | +3 4 | +-------------------------------------------------------------------------------- +26 27 | # Literal +27 28 | +28 29 | +29 |-def f(arg: typing.Literal[1, "foo", True] = None): # RUF013 + 30 |+def f(arg: typing.Literal[1, "foo", True] | None = None): # RUF013 +30 31 | pass diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__add_future_import_RUF013_4.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__add_future_import_RUF013_4.py.snap new file mode 100644 index 0000000000..6aee14956d --- /dev/null +++ b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__add_future_import_RUF013_4.py.snap @@ -0,0 +1,25 @@ +--- +source: crates/ruff_linter/src/rules/ruff/mod.rs +--- +RUF013_4.py:15:61: RUF013 [*] PEP 484 prohibits implicit `Optional` + | +15 | def multiple_2(arg1: Optional, arg2: Optional = None, arg3: int = None): ... + | ^^^ RUF013 + | + = help: Convert to `T | None` + +ℹ Unsafe fix +1 1 | # https://github.com/astral-sh/ruff/issues/13833 + 2 |+from __future__ import annotations +2 3 | +3 4 | from typing import Optional +4 5 | +-------------------------------------------------------------------------------- +12 13 | def multiple_1(arg1: Optional, arg2: Optional = None): ... +13 14 | +14 15 | +15 |-def multiple_2(arg1: Optional, arg2: Optional = None, arg3: int = None): ... + 16 |+def multiple_2(arg1: Optional, arg2: Optional = None, arg3: int | None = None): ... +16 17 | +17 18 | +18 19 | def return_type(arg: Optional = None) -> Optional: ... diff --git a/crates/ruff_linter/src/settings/mod.rs b/crates/ruff_linter/src/settings/mod.rs index bfec035a59..4f0aa79194 100644 --- a/crates/ruff_linter/src/settings/mod.rs +++ b/crates/ruff_linter/src/settings/mod.rs @@ -210,6 +210,7 @@ macro_rules! display_settings { } #[derive(Debug, Clone, CacheKey)] +#[expect(clippy::struct_excessive_bools)] pub struct LinterSettings { pub exclude: FilePatternSet, pub extension: ExtensionMapping, @@ -251,6 +252,7 @@ pub struct LinterSettings { pub task_tags: Vec, pub typing_modules: Vec, pub typing_extensions: bool, + pub future_annotations: bool, // Plugins pub flake8_annotations: flake8_annotations::settings::Settings, @@ -453,6 +455,7 @@ impl LinterSettings { explicit_preview_rules: false, extension: ExtensionMapping::default(), typing_extensions: true, + future_annotations: false, } } @@ -472,6 +475,11 @@ impl LinterSettings { .is_match(path) .map_or(self.unresolved_target_version, TargetVersion::from) } + + pub fn future_annotations(&self) -> bool { + // TODO(brent) we can just access the field directly once this is stabilized. + self.future_annotations && crate::preview::is_add_future_annotations_imports_enabled(self) + } } impl Default for LinterSettings { diff --git a/crates/ruff_workspace/src/configuration.rs b/crates/ruff_workspace/src/configuration.rs index a844d6403a..3e44b31596 100644 --- a/crates/ruff_workspace/src/configuration.rs +++ b/crates/ruff_workspace/src/configuration.rs @@ -250,6 +250,14 @@ impl Configuration { conflicting_import_settings(&isort, &flake8_import_conventions)?; + let future_annotations = lint.future_annotations.unwrap_or_default(); + if lint_preview.is_disabled() && future_annotations { + warn_user_once!( + "The `lint.future-annotations` setting will have no effect \ + because `preview` is disabled" + ); + } + Ok(Settings { cache_dir: self .cache_dir @@ -432,6 +440,7 @@ impl Configuration { .map(RuffOptions::into_settings) .unwrap_or_default(), typing_extensions: lint.typing_extensions.unwrap_or(true), + future_annotations, }, formatter, @@ -636,6 +645,7 @@ pub struct LintConfiguration { pub task_tags: Option>, pub typing_modules: Option>, pub typing_extensions: Option, + pub future_annotations: Option, // Plugins pub flake8_annotations: Option, @@ -752,6 +762,7 @@ impl LintConfiguration { logger_objects: options.common.logger_objects, typing_modules: options.common.typing_modules, typing_extensions: options.typing_extensions, + future_annotations: options.future_annotations, // Plugins flake8_annotations: options.common.flake8_annotations, @@ -1179,6 +1190,7 @@ impl LintConfiguration { pyupgrade: self.pyupgrade.combine(config.pyupgrade), ruff: self.ruff.combine(config.ruff), typing_extensions: self.typing_extensions.or(config.typing_extensions), + future_annotations: self.future_annotations.or(config.future_annotations), } } } diff --git a/crates/ruff_workspace/src/options.rs b/crates/ruff_workspace/src/options.rs index f9dc3b5c9f..a296adc773 100644 --- a/crates/ruff_workspace/src/options.rs +++ b/crates/ruff_workspace/src/options.rs @@ -529,6 +529,24 @@ pub struct LintOptions { "# )] pub typing_extensions: Option, + + /// Whether to allow rules to add `from __future__ import annotations` in cases where this would + /// simplify a fix or enable a new diagnostic. + /// + /// For example, `TC001`, `TC002`, and `TC003` can move more imports into `TYPE_CHECKING` blocks + /// if `__future__` annotations are enabled. + /// + /// This setting is currently in [preview](https://docs.astral.sh/ruff/preview/) and requires + /// preview mode to be enabled to have any effect. + #[option( + default = "false", + value_type = "bool", + example = r#" + # Enable `from __future__ import annotations` imports + future-annotations = true + "# + )] + pub future_annotations: Option, } /// Newtype wrapper for [`LintCommonOptions`] that allows customizing the JSON schema and omitting the fields from the [`OptionsMetadata`]. @@ -3896,6 +3914,7 @@ pub struct LintOptionsWire { ruff: Option, preview: Option, typing_extensions: Option, + future_annotations: Option, } impl From for LintOptions { @@ -3951,6 +3970,7 @@ impl From for LintOptions { ruff, preview, typing_extensions, + future_annotations, } = value; LintOptions { @@ -4007,6 +4027,7 @@ impl From for LintOptions { ruff, preview, typing_extensions, + future_annotations, } } } diff --git a/ruff.schema.json b/ruff.schema.json index 0489f90b8a..999daebd2f 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -2281,6 +2281,13 @@ } ] }, + "future-annotations": { + "description": "Whether to allow rules to add `from __future__ import annotations` in cases where this would simplify a fix or enable a new diagnostic.\n\nFor example, `TC001`, `TC002`, and `TC003` can move more imports into `TYPE_CHECKING` blocks if `__future__` annotations are enabled.\n\nThis setting is currently in [preview](https://docs.astral.sh/ruff/preview/) and requires preview mode to be enabled to have any effect.", + "type": [ + "boolean", + "null" + ] + }, "ignore": { "description": "A list of rule codes or prefixes to ignore. Prefixes can specify exact rules (like `F841`), entire categories (like `F`), or anything in between.\n\nWhen breaking ties between enabled and disabled rules (via `select` and `ignore`, respectively), more specific prefixes override less specific prefixes. `ignore` takes precedence over `select` if the same prefix appears in both.", "type": [