Implement flake8-future-annotations FA100 (#3979)

This commit is contained in:
Tyler Yep 2023-05-13 20:00:06 -07:00 committed by GitHub
parent cd2e7fa72a
commit 01b372a75c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 375 additions and 15 deletions

View file

@ -263,6 +263,7 @@ quality tools, including:
- [flake8-eradicate](https://pypi.org/project/flake8-eradicate/) - [flake8-eradicate](https://pypi.org/project/flake8-eradicate/)
- [flake8-errmsg](https://pypi.org/project/flake8-errmsg/) - [flake8-errmsg](https://pypi.org/project/flake8-errmsg/)
- [flake8-executable](https://pypi.org/project/flake8-executable/) - [flake8-executable](https://pypi.org/project/flake8-executable/)
- [flake8-future-annotations](https://pypi.org/project/flake8-future-annotations/)
- [flake8-gettext](https://pypi.org/project/flake8-gettext/) - [flake8-gettext](https://pypi.org/project/flake8-gettext/)
- [flake8-implicit-str-concat](https://pypi.org/project/flake8-implicit-str-concat/) - [flake8-implicit-str-concat](https://pypi.org/project/flake8-implicit-str-concat/)
- [flake8-import-conventions](https://github.com/joaopalmeiro/flake8-import-conventions) - [flake8-import-conventions](https://github.com/joaopalmeiro/flake8-import-conventions)

View file

@ -0,0 +1,7 @@
from typing import List
import typing as t
def main(_: List[int]) -> None:
a_list: t.List[str] = []
a_list.append("hello")

View file

@ -0,0 +1,6 @@
from typing import List
def main() -> None:
a_list: List[str] = []
a_list.append("hello")

View file

@ -0,0 +1,8 @@
from typing import Dict, List, Optional, Set, Union, cast
def main() -> None:
a_list: List[Optional[str]] = []
a_list.append("hello")
a_dict = cast(Dict[int | None, Union[int, Set[bool]]], {})
a_dict[1] = {True, False}

View file

@ -0,0 +1,6 @@
import typing
def main() -> None:
a_list: typing.List[str] = []
a_list.append("hello")

View file

@ -0,0 +1,6 @@
import typing as t
def main() -> None:
a_list: t.List[str] = []
a_list.append("hello")

View file

@ -0,0 +1,7 @@
def main() -> None:
a_list: list[str] = []
a_list.append("hello")
def hello(y: dict[str, int]) -> None:
del y

View file

@ -0,0 +1,7 @@
def main() -> None:
a_list: list[str] | None = []
a_list.append("hello")
def hello(y: dict[str, int] | None) -> None:
del y

View file

@ -0,0 +1,8 @@
def main() -> None:
a_list: list[str | None] = []
a_list.append("hello")
def hello(y: dict[str | None, int]) -> None:
z: tuple[str, str | None, str] = tuple(y)
del z

View file

@ -0,0 +1,3 @@
def main() -> str:
a_str = "hello"
return a_str

View file

@ -0,0 +1,10 @@
from typing import NamedTuple
class Stuff(NamedTuple):
x: int
def main() -> None:
a_list = Stuff(5)
print(a_list)

View file

@ -0,0 +1,6 @@
from __future__ import annotations
def main() -> None:
a_list: list[str] = []
a_list.append("hello")

View file

@ -0,0 +1,8 @@
import typing
IRRELEVANT = typing.TypeVar
def main() -> None:
List: list[str] = []
List.append("hello")

View file

@ -44,12 +44,12 @@ use crate::registry::{AsRule, Rule};
use crate::rules::{ use crate::rules::{
flake8_2020, flake8_annotations, flake8_bandit, flake8_blind_except, flake8_boolean_trap, flake8_2020, flake8_annotations, flake8_bandit, flake8_blind_except, flake8_boolean_trap,
flake8_bugbear, flake8_builtins, flake8_comprehensions, flake8_datetimez, flake8_debugger, flake8_bugbear, flake8_builtins, flake8_comprehensions, flake8_datetimez, flake8_debugger,
flake8_django, flake8_errmsg, flake8_gettext, flake8_implicit_str_concat, flake8_django, flake8_errmsg, flake8_future_annotations, flake8_gettext,
flake8_import_conventions, flake8_logging_format, flake8_pie, flake8_print, flake8_pyi, flake8_implicit_str_concat, flake8_import_conventions, flake8_logging_format, flake8_pie,
flake8_pytest_style, flake8_raise, flake8_return, flake8_self, flake8_simplify, flake8_print, flake8_pyi, flake8_pytest_style, flake8_raise, flake8_return, flake8_self,
flake8_tidy_imports, flake8_type_checking, flake8_unused_arguments, flake8_use_pathlib, flynt, flake8_simplify, flake8_tidy_imports, flake8_type_checking, flake8_unused_arguments,
mccabe, numpy, pandas_vet, pep8_naming, pycodestyle, pydocstyle, pyflakes, pygrep_hooks, flake8_use_pathlib, flynt, mccabe, numpy, pandas_vet, pep8_naming, pycodestyle, pydocstyle,
pylint, pyupgrade, ruff, tryceratops, pyflakes, pygrep_hooks, pylint, pyupgrade, ruff, tryceratops,
}; };
use crate::settings::types::PythonVersion; use crate::settings::types::PythonVersion;
use crate::settings::{flags, Settings}; use crate::settings::{flags, Settings};
@ -1077,7 +1077,6 @@ where
pyupgrade::rules::unnecessary_builtin_import(self, stmt, module, names); pyupgrade::rules::unnecessary_builtin_import(self, stmt, module, names);
} }
} }
if self.settings.rules.enabled(Rule::BannedApi) { if self.settings.rules.enabled(Rule::BannedApi) {
if let Some(module) = if let Some(module) =
helpers::resolve_imported_module_path(level, module, self.module_path) helpers::resolve_imported_module_path(level, module, self.module_path)
@ -2258,12 +2257,25 @@ where
match &expr.node { match &expr.node {
ExprKind::Subscript(ast::ExprSubscript { value, slice, .. }) => { ExprKind::Subscript(ast::ExprSubscript { value, slice, .. }) => {
// Ex) Optional[...], Union[...] // Ex) Optional[...], Union[...]
if self
.settings
.rules
.enabled(Rule::MissingFutureAnnotationsImport)
&& (self.settings.target_version < PythonVersion::Py310
&& (self.settings.target_version >= PythonVersion::Py37
&& !self.ctx.future_annotations()
&& self.ctx.in_annotation()))
&& analyze::typing::is_pep604_builtin(value, &self.ctx)
{
flake8_future_annotations::rules::missing_future_annotations(self, value);
}
if !self.settings.pyupgrade.keep_runtime_typing if !self.settings.pyupgrade.keep_runtime_typing
&& self.settings.rules.enabled(Rule::NonPEP604Annotation) && self.settings.rules.enabled(Rule::NonPEP604Annotation)
&& (self.settings.target_version >= PythonVersion::Py310 && (self.settings.target_version >= PythonVersion::Py310
|| (self.settings.target_version >= PythonVersion::Py37 || (self.settings.target_version >= PythonVersion::Py37
&& self.ctx.future_annotations() && self.ctx.future_annotations()
&& self.ctx.in_annotation())) && self.ctx.in_annotation()))
&& analyze::typing::is_pep604_builtin(value, &self.ctx)
{ {
pyupgrade::rules::use_pep604_annotation(self, expr, value, slice); pyupgrade::rules::use_pep604_annotation(self, expr, value, slice);
} }
@ -2321,6 +2333,20 @@ where
} }
// Ex) List[...] // Ex) List[...]
if self
.settings
.rules
.enabled(Rule::MissingFutureAnnotationsImport)
&& (self.settings.target_version < PythonVersion::Py39
&& (self.settings.target_version >= PythonVersion::Py37
&& !self.ctx.future_annotations()
&& self.ctx.in_annotation()))
&& analyze::typing::is_pep585_builtin(expr, &self.ctx)
{
flake8_future_annotations::rules::missing_future_annotations(
self, expr,
);
}
if !self.settings.pyupgrade.keep_runtime_typing if !self.settings.pyupgrade.keep_runtime_typing
&& self.settings.rules.enabled(Rule::NonPEP585Annotation) && self.settings.rules.enabled(Rule::NonPEP585Annotation)
&& (self.settings.target_version >= PythonVersion::Py39 && (self.settings.target_version >= PythonVersion::Py39
@ -2372,6 +2398,18 @@ where
} }
ExprKind::Attribute(ast::ExprAttribute { attr, value, .. }) => { ExprKind::Attribute(ast::ExprAttribute { attr, value, .. }) => {
// Ex) typing.List[...] // Ex) typing.List[...]
if self
.settings
.rules
.enabled(Rule::MissingFutureAnnotationsImport)
&& (self.settings.target_version < PythonVersion::Py39
&& (self.settings.target_version >= PythonVersion::Py37
&& !self.ctx.future_annotations()
&& self.ctx.in_annotation()))
&& analyze::typing::is_pep585_builtin(expr, &self.ctx)
{
flake8_future_annotations::rules::missing_future_annotations(self, expr);
}
if !self.settings.pyupgrade.keep_runtime_typing if !self.settings.pyupgrade.keep_runtime_typing
&& self.settings.rules.enabled(Rule::NonPEP585Annotation) && self.settings.rules.enabled(Rule::NonPEP585Annotation)
&& (self.settings.target_version >= PythonVersion::Py39 && (self.settings.target_version >= PythonVersion::Py39

View file

@ -331,6 +331,9 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<Rule> {
(Flake8Annotations, "206") => Rule::MissingReturnTypeClassMethod, (Flake8Annotations, "206") => Rule::MissingReturnTypeClassMethod,
(Flake8Annotations, "401") => Rule::AnyType, (Flake8Annotations, "401") => Rule::AnyType,
// flake8-future-annotations
(Flake8FutureAnnotations, "100") => Rule::MissingFutureAnnotationsImport,
// flake8-2020 // flake8-2020
(Flake82020, "101") => Rule::SysVersionSlice3, (Flake82020, "101") => Rule::SysVersionSlice3,
(Flake82020, "102") => Rule::SysVersion2, (Flake82020, "102") => Rule::SysVersion2,

View file

@ -290,6 +290,8 @@ ruff_macros::register_rules!(
rules::flake8_annotations::rules::MissingReturnTypeStaticMethod, rules::flake8_annotations::rules::MissingReturnTypeStaticMethod,
rules::flake8_annotations::rules::MissingReturnTypeClassMethod, rules::flake8_annotations::rules::MissingReturnTypeClassMethod,
rules::flake8_annotations::rules::AnyType, rules::flake8_annotations::rules::AnyType,
// flake8-future-annotations
rules::flake8_future_annotations::rules::MissingFutureAnnotationsImport,
// flake8-2020 // flake8-2020
rules::flake8_2020::rules::SysVersionSlice3, rules::flake8_2020::rules::SysVersionSlice3,
rules::flake8_2020::rules::SysVersion2, rules::flake8_2020::rules::SysVersion2,
@ -769,6 +771,9 @@ pub enum Linter {
/// [flake8-executable](https://pypi.org/project/flake8-executable/) /// [flake8-executable](https://pypi.org/project/flake8-executable/)
#[prefix = "EXE"] #[prefix = "EXE"]
Flake8Executable, Flake8Executable,
/// [flake8-future-annotations](https://pypi.org/project/flake8-future-annotations/)
#[prefix = "FA"]
Flake8FutureAnnotations,
/// [flake8-implicit-str-concat](https://pypi.org/project/flake8-implicit-str-concat/) /// [flake8-implicit-str-concat](https://pypi.org/project/flake8-implicit-str-concat/)
#[prefix = "ISC"] #[prefix = "ISC"]
Flake8ImplicitStrConcat, Flake8ImplicitStrConcat,

View file

@ -0,0 +1,40 @@
//! Rules from [flake8-future-annotations](https://pypi.org/project/flake8-future-annotations/).
pub(crate) mod rules;
#[cfg(test)]
mod tests {
use std::path::Path;
use anyhow::Result;
use test_case::test_case;
use crate::registry::Rule;
use crate::settings::types::PythonVersion;
use crate::test::test_path;
use crate::{assert_messages, settings};
#[test_case(Path::new("edge_case.py"); "edge_case")]
#[test_case(Path::new("from_typing_import.py"); "from_typing_import")]
#[test_case(Path::new("from_typing_import_many.py"); "from_typing_import_many")]
#[test_case(Path::new("import_typing.py"); "import_typing")]
#[test_case(Path::new("import_typing_as.py"); "import_typing_as")]
#[test_case(Path::new("no_future_import_uses_lowercase.py"); "no_future_import_uses_lowercase")]
#[test_case(Path::new("no_future_import_uses_union.py"); "no_future_import_uses_union")]
#[test_case(Path::new("no_future_import_uses_union_inner.py"); "no_future_import_uses_union_inner")]
#[test_case(Path::new("ok_no_types.py"); "ok_no_types")]
#[test_case(Path::new("ok_non_simplifiable_types.py"); "ok_non_simplifiable_types")]
#[test_case(Path::new("ok_uses_future.py"); "ok_uses_future")]
#[test_case(Path::new("ok_variable_name.py"); "ok_variable_name")]
fn rules(path: &Path) -> Result<()> {
let snapshot = path.to_string_lossy().into_owned();
let diagnostics = test_path(
Path::new("flake8_future_annotations").join(path).as_path(),
&settings::Settings {
target_version: PythonVersion::Py37,
..settings::Settings::for_rule(Rule::MissingFutureAnnotationsImport)
},
)?;
assert_messages!(snapshot, diagnostics);
Ok(())
}
}

View file

@ -0,0 +1,78 @@
use rustpython_parser::ast::Expr;
use ruff_diagnostics::{Diagnostic, Violation};
use ruff_macros::{derive_message_formats, violation};
use ruff_python_ast::call_path::format_call_path;
use crate::checkers::ast::Checker;
/// ## What it does
/// Checks for missing `from __future__ import annotations` imports upon
/// detecting type annotations that can be written more succinctly under
/// PEP 563.
///
/// ## Why is this bad?
/// PEP 563 enabled the use of a number of convenient type annotations, such as
/// `list[str]` instead of `List[str]`, or `str | None` instead of
/// `Optional[str]`. However, these annotations are only available on Python
/// 3.9 and higher, _unless_ the `from __future__ import annotations` import is present.
///
/// By adding the `__future__` import, the pyupgrade rules can automatically
/// migrate existing code to use the new syntax, even for older Python versions.
/// This rule thus pairs well with pyupgrade and with Ruff's pyupgrade rules.
///
/// ## Example
/// ```python
/// from typing import List, Dict, Optional
///
///
/// def function(a_dict: Dict[str, Optional[int]]) -> None:
/// a_list: List[str] = []
/// a_list.append("hello")
/// ```
///
/// Use instead:
/// ```python
/// from __future__ import annotations
///
/// from typing import List, Dict, Optional
///
///
/// def function(a_dict: Dict[str, Optional[int]]) -> None:
/// a_list: List[str] = []
/// a_list.append("hello")
/// ```
///
/// After running the additional pyupgrade rules:
/// ```python
/// from __future__ import annotations
///
///
/// def function(a_dict: dict[str, int | None]) -> None:
/// a_list: list[str] = []
/// a_list.append("hello")
/// ```
#[violation]
pub struct MissingFutureAnnotationsImport {
name: String,
}
impl Violation for MissingFutureAnnotationsImport {
#[derive_message_formats]
fn message(&self) -> String {
let MissingFutureAnnotationsImport { name } = self;
format!("Missing `from __future__ import annotations`, but uses `{name}`")
}
}
/// FA100
pub(crate) fn missing_future_annotations(checker: &mut Checker, expr: &Expr) {
if let Some(binding) = checker.ctx.resolve_call_path(expr) {
checker.diagnostics.push(Diagnostic::new(
MissingFutureAnnotationsImport {
name: format_call_path(&binding),
},
expr.range(),
));
}
}

View file

@ -0,0 +1,12 @@
---
source: crates/ruff/src/rules/flake8_future_annotations/mod.rs
---
edge_case.py:6:13: FA100 Missing `from __future__ import annotations`, but uses `typing.List`
|
6 | def main(_: List[int]) -> None:
7 | a_list: t.List[str] = []
| ^^^^^^ FA100
8 | a_list.append("hello")
|

View file

@ -0,0 +1,12 @@
---
source: crates/ruff/src/rules/flake8_future_annotations/mod.rs
---
from_typing_import.py:5:13: FA100 Missing `from __future__ import annotations`, but uses `typing.List`
|
5 | def main() -> None:
6 | a_list: List[str] = []
| ^^^^ FA100
7 | a_list.append("hello")
|

View file

@ -0,0 +1,22 @@
---
source: crates/ruff/src/rules/flake8_future_annotations/mod.rs
---
from_typing_import_many.py:5:13: FA100 Missing `from __future__ import annotations`, but uses `typing.List`
|
5 | def main() -> None:
6 | a_list: List[Optional[str]] = []
| ^^^^ FA100
7 | a_list.append("hello")
8 | a_dict = cast(Dict[int | None, Union[int, Set[bool]]], {})
|
from_typing_import_many.py:5:18: FA100 Missing `from __future__ import annotations`, but uses `typing.Optional`
|
5 | def main() -> None:
6 | a_list: List[Optional[str]] = []
| ^^^^^^^^ FA100
7 | a_list.append("hello")
8 | a_dict = cast(Dict[int | None, Union[int, Set[bool]]], {})
|

View file

@ -0,0 +1,12 @@
---
source: crates/ruff/src/rules/flake8_future_annotations/mod.rs
---
import_typing.py:5:13: FA100 Missing `from __future__ import annotations`, but uses `typing.List`
|
5 | def main() -> None:
6 | a_list: typing.List[str] = []
| ^^^^^^^^^^^ FA100
7 | a_list.append("hello")
|

View file

@ -0,0 +1,12 @@
---
source: crates/ruff/src/rules/flake8_future_annotations/mod.rs
---
import_typing_as.py:5:13: FA100 Missing `from __future__ import annotations`, but uses `typing.List`
|
5 | def main() -> None:
6 | a_list: t.List[str] = []
| ^^^^^^ FA100
7 | a_list.append("hello")
|

View file

@ -0,0 +1,4 @@
---
source: crates/ruff/src/rules/flake8_future_annotations/mod.rs
---

View file

@ -0,0 +1,4 @@
---
source: crates/ruff/src/rules/flake8_future_annotations/mod.rs
---

View file

@ -0,0 +1,4 @@
---
source: crates/ruff/src/rules/flake8_future_annotations/mod.rs
---

View file

@ -0,0 +1,4 @@
---
source: crates/ruff/src/rules/flake8_future_annotations/mod.rs
---

View file

@ -0,0 +1,4 @@
---
source: crates/ruff/src/rules/flake8_future_annotations/mod.rs
---

View file

@ -0,0 +1,4 @@
---
source: crates/ruff/src/rules/flake8_future_annotations/mod.rs
---

View file

@ -0,0 +1,4 @@
---
source: crates/ruff/src/rules/flake8_future_annotations/mod.rs
---

View file

@ -14,6 +14,7 @@ pub mod flake8_debugger;
pub mod flake8_django; pub mod flake8_django;
pub mod flake8_errmsg; pub mod flake8_errmsg;
pub mod flake8_executable; pub mod flake8_executable;
pub mod flake8_future_annotations;
pub mod flake8_gettext; pub mod flake8_gettext;
pub mod flake8_implicit_str_concat; pub mod flake8_implicit_str_concat;
pub mod flake8_import_conventions; pub mod flake8_import_conventions;

View file

@ -70,6 +70,15 @@ pub fn is_pep585_builtin(expr: &Expr, context: &Context) -> bool {
}) })
} }
/// Returns `true` if `Expr` represents a reference to a typing object with a
/// PEP 603 built-in.
pub fn is_pep604_builtin(expr: &Expr, context: &Context) -> bool {
context.resolve_call_path(expr).map_or(false, |call_path| {
context.match_typing_call_path(&call_path, "Optional")
|| context.match_typing_call_path(&call_path, "Union")
})
}
pub fn is_immutable_annotation(context: &Context, expr: &Expr) -> bool { pub fn is_immutable_annotation(context: &Context, expr: &Expr) -> bool {
match &expr.node { match &expr.node {
ExprKind::Name(_) | ExprKind::Attribute(_) => { ExprKind::Name(_) | ExprKind::Attribute(_) => {

4
ruff.schema.json generated
View file

@ -1816,6 +1816,10 @@
"F9", "F9",
"F90", "F90",
"F901", "F901",
"FA",
"FA1",
"FA10",
"FA100",
"FBT", "FBT",
"FBT0", "FBT0",
"FBT00", "FBT00",

View file

@ -5,6 +5,7 @@ Example usage:
scripts/check_ecosystem.py <path/to/ruff1> <path/to/ruff2> scripts/check_ecosystem.py <path/to/ruff1> <path/to/ruff2>
""" """
from __future__ import annotations
import argparse import argparse
import asyncio import asyncio
@ -18,7 +19,7 @@ import time
from asyncio.subprocess import PIPE, create_subprocess_exec from asyncio.subprocess import PIPE, create_subprocess_exec
from contextlib import asynccontextmanager, nullcontext from contextlib import asynccontextmanager, nullcontext
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, NamedTuple, Optional, Self from typing import TYPE_CHECKING, NamedTuple, Self
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import AsyncIterator, Iterator, Sequence from collections.abc import AsyncIterator, Iterator, Sequence
@ -31,13 +32,13 @@ class Repository(NamedTuple):
org: str org: str
repo: str repo: str
ref: Optional[str] ref: str | None
select: str = "" select: str = ""
ignore: str = "" ignore: str = ""
exclude: str = "" exclude: str = ""
@asynccontextmanager @asynccontextmanager
async def clone(self: Self, checkout_dir: Path) -> "AsyncIterator[Path]": async def clone(self: Self, checkout_dir: Path) -> AsyncIterator[Path]:
"""Shallow clone this repository to a temporary directory.""" """Shallow clone this repository to a temporary directory."""
if checkout_dir.exists(): if checkout_dir.exists():
logger.debug(f"Reusing {self.org}/{self.repo}") logger.debug(f"Reusing {self.org}/{self.repo}")
@ -101,7 +102,7 @@ async def check(
select: str = "", select: str = "",
ignore: str = "", ignore: str = "",
exclude: str = "", exclude: str = "",
) -> "Sequence[str]": ) -> Sequence[str]:
"""Run the given ruff binary against the specified path.""" """Run the given ruff binary against the specified path."""
logger.debug(f"Checking {name} with {ruff}") logger.debug(f"Checking {name} with {ruff}")
ruff_args = ["check", "--no-cache", "--exit-zero"] ruff_args = ["check", "--no-cache", "--exit-zero"]
@ -148,7 +149,7 @@ class Diff(NamedTuple):
"""Return true if this diff is non-empty.""" """Return true if this diff is non-empty."""
return bool(self.removed or self.added) return bool(self.removed or self.added)
def __iter__(self: Self) -> "Iterator[str]": def __iter__(self: Self) -> Iterator[str]:
"""Iterate through the changed lines in diff format.""" """Iterate through the changed lines in diff format."""
for line in heapq.merge(sorted(self.removed), sorted(self.added)): for line in heapq.merge(sorted(self.removed), sorted(self.added)):
if line in self.removed: if line in self.removed:
@ -161,7 +162,7 @@ async def compare(
ruff1: Path, ruff1: Path,
ruff2: Path, ruff2: Path,
repo: Repository, repo: Repository,
checkouts: Optional[Path] = None, checkouts: Path | None = None,
) -> Diff | None: ) -> Diff | None:
"""Check a specific repository against two versions of ruff.""" """Check a specific repository against two versions of ruff."""
removed, added = set(), set() removed, added = set(), set()
@ -254,8 +255,8 @@ async def main(
*, *,
ruff1: Path, ruff1: Path,
ruff2: Path, ruff2: Path,
projects_jsonl: Optional[Path], projects_jsonl: Path | None,
checkouts: Optional[Path] = None, checkouts: Path | None = None,
) -> None: ) -> None:
"""Check two versions of ruff against a corpus of open-source code.""" """Check two versions of ruff against a corpus of open-source code."""
if projects_jsonl: if projects_jsonl: