flake8-annotations: add ignore-fully-untyped (#2128)

This PR adds a configuration option to inhibit ANN* violations for functions that have no other annotations either, for easier gradual typing of a large codebase.
This commit is contained in:
Aarni Koskela 2023-02-07 18:35:57 +02:00 committed by GitHub
parent 4e36225145
commit 2bc16eb4e3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 468 additions and 250 deletions

View File

@ -2663,6 +2663,25 @@ allow-star-arg-any = true
---
#### [`ignore-fully-untyped`](#ignore-fully-untyped)
Whether to suppress `ANN*` rules for any declaration
that hasn't been typed at all.
This makes it easier to gradually add types to a codebase.
**Default value**: `false`
**Type**: `bool`
**Example usage**:
```toml
[tool.ruff.flake8-annotations]
ignore-fully-untyped = true
```
---
#### [`mypy-init-return`](#mypy-init-return)
Whether to allow the omission of a return type hint for `__init__` if at

View File

@ -0,0 +1,44 @@
"""Test case expected to be run with `ignore_fully_untyped = True`."""
def ok_fully_untyped_1(a, b):
pass
def ok_fully_untyped_2():
pass
def ok_fully_typed_1(a: int, b: int) -> int:
pass
def ok_fully_typed_2() -> int:
pass
def ok_fully_typed_3(a: int, *args: str, **kwargs: str) -> int:
pass
def error_partially_typed_1(a: int, b):
pass
def error_partially_typed_2(a: int, b) -> int:
pass
def error_partially_typed_3(a: int, b: int):
pass
class X:
def ok_untyped_method_with_arg(self, a):
pass
def ok_untyped_method(self):
pass
def error_typed_self(self: X):
pass

View File

@ -53,3 +53,8 @@ def foo():
return True
else:
return
# Error (on the argument, but not the return type)
def foo(a):
a = 2 + 2

View File

@ -5079,7 +5079,12 @@ impl<'a> Checker<'a> {
&overloaded_name,
)
}) {
flake8_annotations::rules::definition(self, &definition, &visibility);
self.diagnostics
.extend(flake8_annotations::rules::definition(
self,
&definition,
&visibility,
));
}
overloaded_name = flake8_annotations::helpers::overloaded_name(self, &definition);
}

View File

@ -39,16 +39,42 @@ mod tests {
Ok(())
}
#[test]
fn ignore_fully_untyped() -> Result<()> {
let diagnostics = test_path(
Path::new("flake8_annotations/ignore_fully_untyped.py"),
&Settings {
flake8_annotations: super::settings::Settings {
ignore_fully_untyped: true,
..Default::default()
},
..Settings::for_rules(vec![
Rule::MissingTypeFunctionArgument,
Rule::MissingTypeArgs,
Rule::MissingTypeKwargs,
Rule::MissingTypeSelf,
Rule::MissingTypeCls,
Rule::MissingReturnTypePublicFunction,
Rule::MissingReturnTypePrivateFunction,
Rule::MissingReturnTypeSpecialMethod,
Rule::MissingReturnTypeStaticMethod,
Rule::MissingReturnTypeClassMethod,
Rule::DynamicallyTypedExpression,
])
},
)?;
assert_yaml_snapshot!(diagnostics);
Ok(())
}
#[test]
fn suppress_dummy_args() -> Result<()> {
let diagnostics = test_path(
Path::new("flake8_annotations/suppress_dummy_args.py"),
&Settings {
flake8_annotations: super::settings::Settings {
mypy_init_return: false,
suppress_dummy_args: true,
suppress_none_returning: false,
allow_star_arg_any: false,
..Default::default()
},
..Settings::for_rules(vec![
Rule::MissingTypeFunctionArgument,
@ -70,9 +96,7 @@ mod tests {
&Settings {
flake8_annotations: super::settings::Settings {
mypy_init_return: true,
suppress_dummy_args: false,
suppress_none_returning: false,
allow_star_arg_any: false,
..Default::default()
},
..Settings::for_rules(vec![
Rule::MissingReturnTypePublicFunction,
@ -83,7 +107,7 @@ mod tests {
])
},
)?;
assert_yaml_snapshot!(diagnostics);
insta::assert_yaml_snapshot!(diagnostics);
Ok(())
}
@ -93,17 +117,21 @@ mod tests {
Path::new("flake8_annotations/suppress_none_returning.py"),
&Settings {
flake8_annotations: super::settings::Settings {
mypy_init_return: false,
suppress_dummy_args: false,
suppress_none_returning: true,
allow_star_arg_any: false,
..Default::default()
},
..Settings::for_rules(vec![
Rule::MissingTypeFunctionArgument,
Rule::MissingTypeArgs,
Rule::MissingTypeKwargs,
Rule::MissingTypeSelf,
Rule::MissingTypeCls,
Rule::MissingReturnTypePublicFunction,
Rule::MissingReturnTypePrivateFunction,
Rule::MissingReturnTypeSpecialMethod,
Rule::MissingReturnTypeStaticMethod,
Rule::MissingReturnTypeClassMethod,
Rule::DynamicallyTypedExpression,
])
},
)?;
@ -117,10 +145,8 @@ mod tests {
Path::new("flake8_annotations/allow_star_arg_any.py"),
&Settings {
flake8_annotations: super::settings::Settings {
mypy_init_return: false,
suppress_dummy_args: false,
suppress_none_returning: false,
allow_star_arg_any: true,
..Default::default()
},
..Settings::for_rules(vec![Rule::DynamicallyTypedExpression])
},

View File

@ -185,12 +185,16 @@ fn is_none_returning(body: &[Stmt]) -> bool {
}
/// ANN401
fn check_dynamically_typed<F>(checker: &mut Checker, annotation: &Expr, func: F)
where
fn check_dynamically_typed<F>(
checker: &Checker,
annotation: &Expr,
func: F,
diagnostics: &mut Vec<Diagnostic>,
) where
F: FnOnce() -> String,
{
if checker.match_typing_expr(annotation, "Any") {
checker.diagnostics.push(Diagnostic::new(
diagnostics.push(Diagnostic::new(
DynamicallyTypedExpression { name: func() },
Range::from_located(annotation),
));
@ -198,273 +202,301 @@ where
}
/// Generate flake8-annotation checks for a given `Definition`.
pub fn definition(checker: &mut Checker, definition: &Definition, visibility: &Visibility) {
pub fn definition(
checker: &Checker,
definition: &Definition,
visibility: &Visibility,
) -> Vec<Diagnostic> {
// TODO(charlie): Consider using the AST directly here rather than `Definition`.
// We could adhere more closely to `flake8-annotations` by defining public
// vs. secret vs. protected.
match &definition.kind {
DefinitionKind::Module => {}
DefinitionKind::Package => {}
DefinitionKind::Class(_) => {}
DefinitionKind::NestedClass(_) => {}
DefinitionKind::Function(stmt)
| DefinitionKind::NestedFunction(stmt)
| DefinitionKind::Method(stmt) => {
let is_method = matches!(definition.kind, DefinitionKind::Method(_));
let (name, args, returns, body) = match_function_def(stmt);
let mut has_any_typed_arg = false;
if let DefinitionKind::Function(stmt)
| DefinitionKind::NestedFunction(stmt)
| DefinitionKind::Method(stmt) = &definition.kind
{
let is_method = matches!(definition.kind, DefinitionKind::Method(_));
let (name, args, returns, body) = match_function_def(stmt);
// Keep track of whether we've seen any typed arguments or return values.
let mut has_any_typed_arg = false; // Any argument has been typed?
let mut has_typed_return = false; // Return value has been typed?
let mut has_typed_self_or_cls = false; // Has a typed `self` or `cls` argument?
// ANN001, ANN401
for arg in args
.args
.iter()
.chain(args.posonlyargs.iter())
.chain(args.kwonlyargs.iter())
.skip(
// If this is a non-static method, skip `cls` or `self`.
usize::from(
is_method
&& !visibility::is_staticmethod(checker, cast::decorator_list(stmt)),
),
)
{
// ANN401 for dynamically typed arguments
if let Some(annotation) = &arg.node.annotation {
has_any_typed_arg = true;
if checker
.settings
.rules
.enabled(&Rule::DynamicallyTypedExpression)
{
check_dynamically_typed(checker, annotation, || arg.node.arg.to_string());
}
} else {
if !(checker.settings.flake8_annotations.suppress_dummy_args
&& checker.settings.dummy_variable_rgx.is_match(&arg.node.arg))
{
if checker
.settings
.rules
.enabled(&Rule::MissingTypeFunctionArgument)
{
checker.diagnostics.push(Diagnostic::new(
MissingTypeFunctionArgument {
name: arg.node.arg.to_string(),
},
Range::from_located(arg),
));
}
}
}
}
// Temporary storage for diagnostics; we emit them at the end
// unless configured to suppress ANN* for declarations that are fully untyped.
let mut diagnostics = Vec::new();
// ANN002, ANN401
if let Some(arg) = &args.vararg {
if let Some(expr) = &arg.node.annotation {
has_any_typed_arg = true;
if !checker.settings.flake8_annotations.allow_star_arg_any {
if checker
.settings
.rules
.enabled(&Rule::DynamicallyTypedExpression)
{
let name = &arg.node.arg;
check_dynamically_typed(checker, expr, || format!("*{name}"));
}
}
} else {
if !(checker.settings.flake8_annotations.suppress_dummy_args
&& checker.settings.dummy_variable_rgx.is_match(&arg.node.arg))
{
if checker.settings.rules.enabled(&Rule::MissingTypeArgs) {
checker.diagnostics.push(Diagnostic::new(
MissingTypeArgs {
name: arg.node.arg.to_string(),
},
Range::from_located(arg),
));
}
}
}
}
// ANN003, ANN401
if let Some(arg) = &args.kwarg {
if let Some(expr) = &arg.node.annotation {
has_any_typed_arg = true;
if !checker.settings.flake8_annotations.allow_star_arg_any {
if checker
.settings
.rules
.enabled(&Rule::DynamicallyTypedExpression)
{
let name = &arg.node.arg;
check_dynamically_typed(checker, expr, || format!("**{name}"));
}
}
} else {
if !(checker.settings.flake8_annotations.suppress_dummy_args
&& checker.settings.dummy_variable_rgx.is_match(&arg.node.arg))
{
if checker.settings.rules.enabled(&Rule::MissingTypeKwargs) {
checker.diagnostics.push(Diagnostic::new(
MissingTypeKwargs {
name: arg.node.arg.to_string(),
},
Range::from_located(arg),
));
}
}
}
}
// ANN101, ANN102
if is_method && !visibility::is_staticmethod(checker, cast::decorator_list(stmt)) {
if let Some(arg) = args.args.first() {
if arg.node.annotation.is_none() {
if visibility::is_classmethod(checker, cast::decorator_list(stmt)) {
if checker.settings.rules.enabled(&Rule::MissingTypeCls) {
checker.diagnostics.push(Diagnostic::new(
MissingTypeCls {
name: arg.node.arg.to_string(),
},
Range::from_located(arg),
));
}
} else {
if checker.settings.rules.enabled(&Rule::MissingTypeSelf) {
checker.diagnostics.push(Diagnostic::new(
MissingTypeSelf {
name: arg.node.arg.to_string(),
},
Range::from_located(arg),
));
}
}
}
}
}
// ANN201, ANN202, ANN401
if let Some(expr) = &returns {
// ANN001, ANN401
for arg in args
.args
.iter()
.chain(args.posonlyargs.iter())
.chain(args.kwonlyargs.iter())
.skip(
// If this is a non-static method, skip `cls` or `self`.
usize::from(
is_method && !visibility::is_staticmethod(checker, cast::decorator_list(stmt)),
),
)
{
// ANN401 for dynamically typed arguments
if let Some(annotation) = &arg.node.annotation {
has_any_typed_arg = true;
if checker
.settings
.rules
.enabled(&Rule::DynamicallyTypedExpression)
{
check_dynamically_typed(checker, expr, || name.to_string());
check_dynamically_typed(
checker,
annotation,
|| arg.node.arg.to_string(),
&mut diagnostics,
);
}
} else {
// Allow omission of return annotation if the function only returns `None`
// (explicitly or implicitly).
if checker.settings.flake8_annotations.suppress_none_returning
&& is_none_returning(body)
if !(checker.settings.flake8_annotations.suppress_dummy_args
&& checker.settings.dummy_variable_rgx.is_match(&arg.node.arg))
{
return;
if checker
.settings
.rules
.enabled(&Rule::MissingTypeFunctionArgument)
{
diagnostics.push(Diagnostic::new(
MissingTypeFunctionArgument {
name: arg.node.arg.to_string(),
},
Range::from_located(arg),
));
}
}
}
}
if is_method && visibility::is_classmethod(checker, cast::decorator_list(stmt)) {
// ANN002, ANN401
if let Some(arg) = &args.vararg {
if let Some(expr) = &arg.node.annotation {
has_any_typed_arg = true;
if !checker.settings.flake8_annotations.allow_star_arg_any {
if checker
.settings
.rules
.enabled(&Rule::MissingReturnTypeClassMethod)
.enabled(&Rule::DynamicallyTypedExpression)
{
checker.diagnostics.push(Diagnostic::new(
MissingReturnTypeClassMethod {
name: name.to_string(),
},
helpers::identifier_range(stmt, checker.locator),
));
let name = &arg.node.arg;
check_dynamically_typed(
checker,
expr,
|| format!("*{name}"),
&mut diagnostics,
);
}
} else if is_method
&& visibility::is_staticmethod(checker, cast::decorator_list(stmt))
}
} else {
if !(checker.settings.flake8_annotations.suppress_dummy_args
&& checker.settings.dummy_variable_rgx.is_match(&arg.node.arg))
{
if checker
.settings
.rules
.enabled(&Rule::MissingReturnTypeStaticMethod)
{
checker.diagnostics.push(Diagnostic::new(
MissingReturnTypeStaticMethod {
name: name.to_string(),
if checker.settings.rules.enabled(&Rule::MissingTypeArgs) {
diagnostics.push(Diagnostic::new(
MissingTypeArgs {
name: arg.node.arg.to_string(),
},
helpers::identifier_range(stmt, checker.locator),
Range::from_located(arg),
));
}
} else if is_method && visibility::is_init(cast::name(stmt)) {
// Allow omission of return annotation in `__init__` functions, as long as at
// least one argument is typed.
}
}
}
// ANN003, ANN401
if let Some(arg) = &args.kwarg {
if let Some(expr) = &arg.node.annotation {
has_any_typed_arg = true;
if !checker.settings.flake8_annotations.allow_star_arg_any {
if checker
.settings
.rules
.enabled(&Rule::MissingReturnTypeSpecialMethod)
.enabled(&Rule::DynamicallyTypedExpression)
{
if !(checker.settings.flake8_annotations.mypy_init_return
&& has_any_typed_arg)
{
let mut diagnostic = Diagnostic::new(
MissingReturnTypeSpecialMethod {
name: name.to_string(),
let name = &arg.node.arg;
check_dynamically_typed(
checker,
expr,
|| format!("**{name}"),
&mut diagnostics,
);
}
}
} else {
if !(checker.settings.flake8_annotations.suppress_dummy_args
&& checker.settings.dummy_variable_rgx.is_match(&arg.node.arg))
{
if checker.settings.rules.enabled(&Rule::MissingTypeKwargs) {
diagnostics.push(Diagnostic::new(
MissingTypeKwargs {
name: arg.node.arg.to_string(),
},
Range::from_located(arg),
));
}
}
}
}
// ANN101, ANN102
if is_method && !visibility::is_staticmethod(checker, cast::decorator_list(stmt)) {
if let Some(arg) = args.args.first() {
if arg.node.annotation.is_none() {
if visibility::is_classmethod(checker, cast::decorator_list(stmt)) {
if checker.settings.rules.enabled(&Rule::MissingTypeCls) {
diagnostics.push(Diagnostic::new(
MissingTypeCls {
name: arg.node.arg.to_string(),
},
helpers::identifier_range(stmt, checker.locator),
);
if checker.patch(diagnostic.kind.rule()) {
match fixes::add_return_none_annotation(checker.locator, stmt) {
Ok(fix) => {
diagnostic.amend(fix);
}
Err(e) => error!("Failed to generate fix: {e}"),
}
}
checker.diagnostics.push(diagnostic);
Range::from_located(arg),
));
}
} else {
if checker.settings.rules.enabled(&Rule::MissingTypeSelf) {
diagnostics.push(Diagnostic::new(
MissingTypeSelf {
name: arg.node.arg.to_string(),
},
Range::from_located(arg),
));
}
}
} else if is_method && visibility::is_magic(cast::name(stmt)) {
if checker
.settings
.rules
.enabled(&Rule::MissingReturnTypeSpecialMethod)
} else {
has_typed_self_or_cls = true;
}
}
}
// ANN201, ANN202, ANN401
if let Some(expr) = &returns {
has_typed_return = true;
if checker
.settings
.rules
.enabled(&Rule::DynamicallyTypedExpression)
{
check_dynamically_typed(checker, expr, || name.to_string(), &mut diagnostics);
}
} else if !(
// Allow omission of return annotation if the function only returns `None`
// (explicitly or implicitly).
checker.settings.flake8_annotations.suppress_none_returning && is_none_returning(body)
) {
if is_method && visibility::is_classmethod(checker, cast::decorator_list(stmt)) {
if checker
.settings
.rules
.enabled(&Rule::MissingReturnTypeClassMethod)
{
diagnostics.push(Diagnostic::new(
MissingReturnTypeClassMethod {
name: name.to_string(),
},
helpers::identifier_range(stmt, checker.locator),
));
}
} else if is_method && visibility::is_staticmethod(checker, cast::decorator_list(stmt))
{
if checker
.settings
.rules
.enabled(&Rule::MissingReturnTypeStaticMethod)
{
diagnostics.push(Diagnostic::new(
MissingReturnTypeStaticMethod {
name: name.to_string(),
},
helpers::identifier_range(stmt, checker.locator),
));
}
} else if is_method && visibility::is_init(cast::name(stmt)) {
// Allow omission of return annotation in `__init__` functions, as long as at
// least one argument is typed.
if checker
.settings
.rules
.enabled(&Rule::MissingReturnTypeSpecialMethod)
{
if !(checker.settings.flake8_annotations.mypy_init_return && has_any_typed_arg)
{
checker.diagnostics.push(Diagnostic::new(
let mut diagnostic = Diagnostic::new(
MissingReturnTypeSpecialMethod {
name: name.to_string(),
},
helpers::identifier_range(stmt, checker.locator),
));
}
} else {
match visibility {
Visibility::Public => {
if checker
.settings
.rules
.enabled(&Rule::MissingReturnTypePublicFunction)
{
checker.diagnostics.push(Diagnostic::new(
MissingReturnTypePublicFunction {
name: name.to_string(),
},
helpers::identifier_range(stmt, checker.locator),
));
);
if checker.patch(diagnostic.kind.rule()) {
match fixes::add_return_none_annotation(checker.locator, stmt) {
Ok(fix) => {
diagnostic.amend(fix);
}
Err(e) => error!("Failed to generate fix: {e}"),
}
}
Visibility::Private => {
if checker
.settings
.rules
.enabled(&Rule::MissingReturnTypePrivateFunction)
{
checker.diagnostics.push(Diagnostic::new(
MissingReturnTypePrivateFunction {
name: name.to_string(),
},
helpers::identifier_range(stmt, checker.locator),
));
}
diagnostics.push(diagnostic);
}
}
} else if is_method && visibility::is_magic(cast::name(stmt)) {
if checker
.settings
.rules
.enabled(&Rule::MissingReturnTypeSpecialMethod)
{
diagnostics.push(Diagnostic::new(
MissingReturnTypeSpecialMethod {
name: name.to_string(),
},
helpers::identifier_range(stmt, checker.locator),
));
}
} else {
match visibility {
Visibility::Public => {
if checker
.settings
.rules
.enabled(&Rule::MissingReturnTypePublicFunction)
{
diagnostics.push(Diagnostic::new(
MissingReturnTypePublicFunction {
name: name.to_string(),
},
helpers::identifier_range(stmt, checker.locator),
));
}
}
Visibility::Private => {
if checker
.settings
.rules
.enabled(&Rule::MissingReturnTypePrivateFunction)
{
diagnostics.push(Diagnostic::new(
MissingReturnTypePrivateFunction {
name: name.to_string(),
},
helpers::identifier_range(stmt, checker.locator),
));
}
}
}
}
}
// If settings say so, don't report any of the
// diagnostics gathered here if there were no type annotations at all.
if checker.settings.flake8_annotations.ignore_fully_untyped
&& !(has_any_typed_arg || has_typed_self_or_cls || has_typed_return)
{
vec![]
} else {
diagnostics
}
} else {
vec![]
}
}

View File

@ -49,6 +49,15 @@ pub struct Options {
/// Whether to suppress `ANN401` for dynamically typed `*args` and
/// `**kwargs` arguments.
pub allow_star_arg_any: Option<bool>,
#[option(
default = "false",
value_type = "bool",
example = "ignore-fully-untyped = true"
)]
/// Whether to suppress `ANN*` rules for any declaration
/// that hasn't been typed at all.
/// This makes it easier to gradually add types to a codebase.
pub ignore_fully_untyped: Option<bool>,
}
#[derive(Debug, Default, Hash)]
@ -58,6 +67,7 @@ pub struct Settings {
pub suppress_dummy_args: bool,
pub suppress_none_returning: bool,
pub allow_star_arg_any: bool,
pub ignore_fully_untyped: bool,
}
impl From<Options> for Settings {
@ -67,6 +77,7 @@ impl From<Options> for Settings {
suppress_dummy_args: options.suppress_dummy_args.unwrap_or(false),
suppress_none_returning: options.suppress_none_returning.unwrap_or(false),
allow_star_arg_any: options.allow_star_arg_any.unwrap_or(false),
ignore_fully_untyped: options.ignore_fully_untyped.unwrap_or(false),
}
}
}
@ -78,6 +89,7 @@ impl From<Settings> for Options {
suppress_dummy_args: Some(settings.suppress_dummy_args),
suppress_none_returning: Some(settings.suppress_none_returning),
allow_star_arg_any: Some(settings.allow_star_arg_any),
ignore_fully_untyped: Some(settings.ignore_fully_untyped),
}
}
}

View File

@ -0,0 +1,60 @@
---
source: crates/ruff/src/rules/flake8_annotations/mod.rs
expression: diagnostics
---
- kind:
MissingReturnTypePublicFunction:
name: error_partially_typed_1
location:
row: 24
column: 4
end_location:
row: 24
column: 27
fix: ~
parent: ~
- kind:
MissingTypeFunctionArgument:
name: b
location:
row: 24
column: 36
end_location:
row: 24
column: 37
fix: ~
parent: ~
- kind:
MissingTypeFunctionArgument:
name: b
location:
row: 28
column: 36
end_location:
row: 28
column: 37
fix: ~
parent: ~
- kind:
MissingReturnTypePublicFunction:
name: error_partially_typed_3
location:
row: 32
column: 4
end_location:
row: 32
column: 27
fix: ~
parent: ~
- kind:
MissingReturnTypePublicFunction:
name: error_typed_self
location:
row: 43
column: 8
end_location:
row: 43
column: 24
fix: ~
parent: ~

View File

@ -1,5 +1,5 @@
---
source: src/rules/flake8_annotations/mod.rs
source: crates/ruff/src/rules/flake8_annotations/mod.rs
expression: diagnostics
---
- kind:
@ -12,8 +12,7 @@ expression: diagnostics
row: 5
column: 16
fix:
content:
- " -> None"
content: " -> None"
location:
row: 5
column: 22
@ -31,8 +30,7 @@ expression: diagnostics
row: 11
column: 16
fix:
content:
- " -> None"
content: " -> None"
location:
row: 11
column: 27
@ -61,8 +59,7 @@ expression: diagnostics
row: 47
column: 16
fix:
content:
- " -> None"
content: " -> None"
location:
row: 47
column: 28

View File

@ -1,5 +1,5 @@
---
source: src/rules/flake8_annotations/mod.rs
source: crates/ruff/src/rules/flake8_annotations/mod.rs
expression: diagnostics
---
- kind:
@ -24,4 +24,15 @@ expression: diagnostics
column: 7
fix: ~
parent: ~
- kind:
MissingTypeFunctionArgument:
name: a
location:
row: 59
column: 8
end_location:
row: 59
column: 9
fix: ~
parent: ~

View File

@ -541,6 +541,13 @@
"null"
]
},
"ignore-fully-untyped": {
"description": "Whether to suppress `ANN*` rules for any declaration that hasn't been typed at all. This makes it easier to gradually add types to a codebase.",
"type": [
"boolean",
"null"
]
},
"mypy-init-return": {
"description": "Whether to allow the omission of a return type hint for `__init__` if at least one argument is annotated.",
"type": [