Compare commits

...

2 Commits

Author SHA1 Message Date
Alex Waygood
3ef117ed95 [ty] Fix simplification of T & ~T for non-fully-static types 2025-10-03 12:47:31 +01:00
Dan Parizher
f9688bd05c [flake8-annotations] Fix return type annotations to handle shadowed builtin symbols (ANN201, ANN202, ANN204, ANN205, ANN206) (#20612)
## Summary

Fixes #20610

---------

Co-authored-by: Brent Westbrook <brentrwestbrook@gmail.com>
2025-10-02 22:44:06 +00:00
7 changed files with 299 additions and 30 deletions

View File

@@ -0,0 +1,21 @@
from collections import UserString as str
from typing import override
def foo():
return "!"
def _foo():
return "!"
class C:
@override
def __str__(self):
return "!"
@staticmethod
def foo():
return "!"
@classmethod
def bar(cls):
return "!"

View File

@@ -142,23 +142,25 @@ impl AutoPythonType {
});
Some((expr, vec![no_return_edit]))
}
AutoPythonType::Atom(python_type) => {
let expr = type_expr(python_type)?;
Some((expr, vec![]))
}
AutoPythonType::Atom(python_type) => type_expr(python_type, checker, at),
AutoPythonType::Union(python_types) => {
if target_version >= PythonVersion::PY310 {
// Aggregate all the individual types (e.g., `int`, `float`).
let mut all_edits = Vec::new();
let names = python_types
.iter()
.sorted_unstable()
.map(|python_type| type_expr(*python_type))
.map(|python_type| {
let (expr, mut edits) = type_expr(*python_type, checker, at)?;
all_edits.append(&mut edits);
Some(expr)
})
.collect::<Option<Vec<_>>>()?;
// Wrap in a bitwise union (e.g., `int | float`).
let expr = pep_604_union(&names);
Some((expr, vec![]))
Some((expr, all_edits))
} else {
let python_types = python_types
.into_iter()
@@ -167,7 +169,7 @@ impl AutoPythonType {
match python_types.as_slice() {
[python_type, PythonType::None] | [PythonType::None, python_type] => {
let element = type_expr(*python_type)?;
let (element, mut edits) = type_expr(*python_type, checker, at)?;
// Ex) `Optional[int]`
let (optional_edit, binding) = checker
@@ -175,12 +177,18 @@ impl AutoPythonType {
.import(at)
.ok()?;
let expr = typing_optional(element, Name::from(binding));
Some((expr, vec![optional_edit]))
edits.push(optional_edit);
Some((expr, edits))
}
_ => {
let mut all_edits = Vec::new();
let elements = python_types
.into_iter()
.map(type_expr)
.map(|python_type| {
let (expr, mut edits) = type_expr(python_type, checker, at)?;
all_edits.append(&mut edits);
Some(expr)
})
.collect::<Option<Vec<_>>>()?;
// Ex) `Union[int, str]`
@@ -189,7 +197,8 @@ impl AutoPythonType {
.import(at)
.ok()?;
let expr = typing_union(&elements, Name::from(binding));
Some((expr, vec![union_edit]))
all_edits.push(union_edit);
Some((expr, all_edits))
}
}
}
@@ -199,26 +208,44 @@ impl AutoPythonType {
}
/// Given a [`PythonType`], return an [`Expr`] that resolves to that type.
fn type_expr(python_type: PythonType) -> Option<Expr> {
fn name(name: &str) -> Expr {
Expr::Name(ast::ExprName {
id: name.into(),
///
/// If the [`Expr`] relies on importing any external symbols, those imports will be returned as
/// additional edits.
pub(crate) fn type_expr(
python_type: PythonType,
checker: &Checker,
at: TextSize,
) -> Option<(Expr, Vec<Edit>)> {
fn name(name: &str, checker: &Checker, at: TextSize) -> Option<(Expr, Vec<Edit>)> {
let (edit, binding) = checker
.importer()
.get_or_import_builtin_symbol(name, at, checker.semantic())
.ok()?;
let expr = Expr::Name(ast::ExprName {
id: binding.into(),
range: TextRange::default(),
node_index: ruff_python_ast::AtomicNodeIndex::NONE,
ctx: ExprContext::Load,
})
});
Some((expr, edit.map_or_else(Vec::new, |edit| vec![edit])))
}
match python_type {
PythonType::String => Some(name("str")),
PythonType::Bytes => Some(name("bytes")),
PythonType::Number(number) => match number {
NumberLike::Integer => Some(name("int")),
NumberLike::Float => Some(name("float")),
NumberLike::Complex => Some(name("complex")),
NumberLike::Bool => Some(name("bool")),
},
PythonType::None => Some(name("None")),
PythonType::String => name("str", checker, at),
PythonType::Bytes => name("bytes", checker, at),
PythonType::Number(number) => {
let symbol = match number {
NumberLike::Integer => "int",
NumberLike::Float => "float",
NumberLike::Complex => "complex",
NumberLike::Bool => "bool",
};
name(symbol, checker, at)
}
PythonType::None => {
let expr = Expr::NoneLiteral(ast::ExprNoneLiteral::default());
Some((expr, vec![]))
}
PythonType::Ellipsis => None,
PythonType::Dict => None,
PythonType::List => None,

View File

@@ -229,4 +229,20 @@ mod tests {
assert_diagnostics!(diagnostics);
Ok(())
}
#[test]
fn shadowed_builtins() -> Result<()> {
let diagnostics = test_path(
Path::new("flake8_annotations/shadowed_builtins.py"),
&LinterSettings::for_rules(vec![
Rule::MissingReturnTypeUndocumentedPublicFunction,
Rule::MissingReturnTypePrivateFunction,
Rule::MissingReturnTypeSpecialMethod,
Rule::MissingReturnTypeStaticMethod,
Rule::MissingReturnTypeClassMethod,
]),
)?;
assert_diagnostics!(diagnostics);
Ok(())
}
}

View File

@@ -4,13 +4,14 @@ use ruff_python_ast::identifier::Identifier;
use ruff_python_ast::visitor::Visitor;
use ruff_python_ast::{self as ast, Expr, Stmt};
use ruff_python_semantic::Definition;
use ruff_python_semantic::analyze::type_inference::{NumberLike, PythonType};
use ruff_python_semantic::analyze::visibility;
use ruff_python_stdlib::typing::simple_magic_return_type;
use ruff_text_size::Ranged;
use crate::checkers::ast::{Checker, DiagnosticGuard};
use crate::registry::Rule;
use crate::rules::flake8_annotations::helpers::auto_return_type;
use crate::rules::flake8_annotations::helpers::{auto_return_type, type_expr};
use crate::rules::ruff::typing::type_hint_resolves_to_any;
use crate::{Edit, Fix, FixAvailability, Violation};
@@ -825,11 +826,31 @@ pub(crate) fn definition(
},
function.identifier(),
);
if let Some(return_type) = return_type {
diagnostic.set_fix(Fix::unsafe_edit(Edit::insertion(
format!(" -> {return_type}"),
function.parameters.end(),
)));
if let Some(return_type_str) = return_type {
// Convert the simple return type to a proper expression that handles shadowed builtins
let python_type = match return_type_str {
"str" => PythonType::String,
"bytes" => PythonType::Bytes,
"int" => PythonType::Number(NumberLike::Integer),
"float" => PythonType::Number(NumberLike::Float),
"complex" => PythonType::Number(NumberLike::Complex),
"bool" => PythonType::Number(NumberLike::Bool),
"None" => PythonType::None,
_ => return, // Unknown type, skip
};
if let Some((expr, edits)) =
type_expr(python_type, checker, function.parameters.start())
{
let return_type_expr = checker.generator().expr(&expr);
diagnostic.set_fix(Fix::unsafe_edits(
Edit::insertion(
format!(" -> {return_type_expr}"),
function.parameters.end(),
),
edits,
));
}
}
diagnostics.push(diagnostic);
}

View File

@@ -0,0 +1,124 @@
---
source: crates/ruff_linter/src/rules/flake8_annotations/mod.rs
---
ANN201 [*] Missing return type annotation for public function `foo`
--> shadowed_builtins.py:4:5
|
2 | from typing import override
3 |
4 | def foo():
| ^^^
5 | return "!"
|
help: Add return type annotation: `builtins.str`
1 | from collections import UserString as str
2 | from typing import override
3 + import builtins
4 |
- def foo():
5 + def foo() -> builtins.str:
6 | return "!"
7 |
8 | def _foo():
note: This is an unsafe fix and may change runtime behavior
ANN202 [*] Missing return type annotation for private function `_foo`
--> shadowed_builtins.py:7:5
|
5 | return "!"
6 |
7 | def _foo():
| ^^^^
8 | return "!"
|
help: Add return type annotation: `builtins.str`
1 | from collections import UserString as str
2 | from typing import override
3 + import builtins
4 |
5 | def foo():
6 | return "!"
7 |
- def _foo():
8 + def _foo() -> builtins.str:
9 | return "!"
10 |
11 | class C:
note: This is an unsafe fix and may change runtime behavior
ANN204 [*] Missing return type annotation for special method `__str__`
--> shadowed_builtins.py:12:9
|
10 | class C:
11 | @override
12 | def __str__(self):
| ^^^^^^^
13 | return "!"
|
help: Add return type annotation: `str`
1 | from collections import UserString as str
2 | from typing import override
3 + import builtins
4 |
5 | def foo():
6 | return "!"
--------------------------------------------------------------------------------
10 |
11 | class C:
12 | @override
- def __str__(self):
13 + def __str__(self) -> builtins.str:
14 | return "!"
15 |
16 | @staticmethod
note: This is an unsafe fix and may change runtime behavior
ANN205 [*] Missing return type annotation for staticmethod `foo`
--> shadowed_builtins.py:16:9
|
15 | @staticmethod
16 | def foo():
| ^^^
17 | return "!"
|
help: Add return type annotation: `builtins.str`
1 | from collections import UserString as str
2 | from typing import override
3 + import builtins
4 |
5 | def foo():
6 | return "!"
--------------------------------------------------------------------------------
14 | return "!"
15 |
16 | @staticmethod
- def foo():
17 + def foo() -> builtins.str:
18 | return "!"
19 |
20 | @classmethod
note: This is an unsafe fix and may change runtime behavior
ANN206 [*] Missing return type annotation for classmethod `bar`
--> shadowed_builtins.py:20:9
|
19 | @classmethod
20 | def bar(cls):
| ^^^
21 | return "!"
|
help: Add return type annotation: `builtins.str`
1 | from collections import UserString as str
2 | from typing import override
3 + import builtins
4 |
5 | def foo():
6 | return "!"
--------------------------------------------------------------------------------
18 | return "!"
19 |
20 | @classmethod
- def bar(cls):
21 + def bar(cls) -> builtins.str:
22 | return "!"
note: This is an unsafe fix and may change runtime behavior

View File

@@ -907,6 +907,11 @@ def unknown(
Dynamic types do not cancel each other out. Intersecting an unknown set of values with the negation
of another unknown set of values is not necessarily empty, so we keep the positive contribution:
```toml
[environment]
python-version = "3.12"
```
```py
from typing import Any
from ty_extensions import Intersection, Not, Unknown
@@ -924,6 +929,50 @@ def unknown(
) -> None:
reveal_type(i1) # revealed: Unknown
reveal_type(i2) # revealed: Unknown
class Covariant[T]:
def get(self) -> T:
raise NotImplementedError
def covariant(
i1: Intersection[Covariant[Any], Not[Covariant[Any]]],
i2: Intersection[Not[Covariant[Any]], Covariant[Any]],
) -> None:
reveal_type(i1) # revealed: Covariant[Any]
reveal_type(i2) # revealed: Covariant[Any]
class Contravariant[T]:
def receive(self, input: T): ...
def contravariant(
i1: Intersection[Contravariant[Any], Not[Contravariant[Any]]],
i2: Intersection[Not[Contravariant[Any]], Contravariant[Any]],
) -> None:
reveal_type(i1) # revealed: Contravariant[Any]
reveal_type(i2) # revealed: Contravariant[Any]
class Invariant[T]:
mutable_attribute: T
def invariant(
i1: Intersection[Invariant[Any], Not[Invariant[Any]]],
i2: Intersection[Not[Invariant[Any]], Invariant[Any]],
) -> None:
reveal_type(i1) # revealed: Invariant[Any]
reveal_type(i2) # revealed: Invariant[Any]
class Bivariant[T]: ...
# Because of bivariance, the specialisation here is meaningless;
# `Bivariant[Any]` is arguably still a fully static type, even
# though it is specialised with a gradual form! Thus self-cancellation
# here is fine:
def bivariant(
i1: Intersection[Bivariant[Any], Not[Bivariant[Any]]],
i2: Intersection[Not[Bivariant[Any]], Bivariant[Any]],
) -> None:
reveal_type(i1) # revealed: Never
reveal_type(i2) # revealed: Never
```
### Mixed dynamic types

View File

@@ -958,6 +958,12 @@ impl<'db> InnerIntersectionBuilder<'db> {
self.positive.insert(Type::Never);
return;
}
// `T & ~T` = `T` if `T` is not a subtype of `T`
// (this can only be true if `T` is a non-fully-static type)
if existing_negative.is_equivalent_to(db, new_positive) {
to_remove.push(index);
continue;
}
// A & ~B = A if A and B are disjoint
if existing_negative.is_disjoint_from(db, new_positive) {
to_remove.push(index);
@@ -1048,6 +1054,11 @@ impl<'db> InnerIntersectionBuilder<'db> {
self.positive.insert(Type::Never);
return;
}
// `T & ~T` = `T` if `T` is not a subtype of `T`
// (this can only be true if `T` is a non-fully-static type)
if existing_positive.is_equivalent_to(db, new_negative) {
return;
}
// A & ~B = A if A and B are disjoint
if existing_positive.is_disjoint_from(db, new_negative) {
return;