Skip to content

Commit 1a65e54

Browse files
Allow flake8-type-checking rules to automatically quote runtime-evaluated references (#6001)
## Summary This allows us to fix usages like: ```python from pandas import DataFrame def baz() -> DataFrame: ... ``` By quoting the `DataFrame` in `-> DataFrame`. Without quotes, moving `from pandas import DataFrame` into an `if TYPE_CHECKING:` block will fail at runtime, since Python tries to evaluate the annotation to add it to the function's `__annotations__`. Unfortunately, this does require us to split our "annotation kind" flags into three categories, rather than two: - `typing-only`: The annotation is only evaluated at type-checking-time. - `runtime-evaluated`: Python will evaluate the annotation at runtime (like above) -- but we're willing to quote it. - `runtime-required`: Python will evaluate the annotation at runtime (like above), and some library (like Pydantic) needs it to be available at runtime, so we _can't_ quote it. This functionality is gated behind a setting (`flake8-type-checking.quote-annotations`). Closes #5559.
1 parent 4d2ee5b commit 1a65e54

18 files changed

+1033
-207
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
def f():
2+
from pandas import DataFrame
3+
4+
def baz() -> DataFrame:
5+
...
6+
7+
8+
def f():
9+
from pandas import DataFrame
10+
11+
def baz() -> DataFrame[int]:
12+
...
13+
14+
15+
def f():
16+
from pandas import DataFrame
17+
18+
def baz() -> DataFrame["int"]:
19+
...
20+
21+
22+
def f():
23+
import pandas as pd
24+
25+
def baz() -> pd.DataFrame:
26+
...
27+
28+
29+
def f():
30+
import pandas as pd
31+
32+
def baz() -> pd.DataFrame.Extra:
33+
...
34+
35+
36+
def f():
37+
import pandas as pd
38+
39+
def baz() -> pd.DataFrame | int:
40+
...
41+
42+
43+
44+
def f():
45+
from pandas import DataFrame
46+
47+
def baz() -> DataFrame():
48+
...
49+
50+
51+
def f():
52+
from typing import Literal
53+
54+
from pandas import DataFrame
55+
56+
def baz() -> DataFrame[Literal["int"]]:
57+
...
58+
59+
60+
def f():
61+
from typing import TYPE_CHECKING
62+
63+
if TYPE_CHECKING:
64+
from pandas import DataFrame
65+
66+
def func(value: DataFrame):
67+
...

crates/ruff_linter/src/checkers/ast/analyze/deferred_scopes.rs

+1
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ pub(crate) fn deferred_scopes(checker: &mut Checker) {
5959
flake8_type_checking::helpers::is_valid_runtime_import(
6060
binding,
6161
&checker.semantic,
62+
&checker.settings.flake8_type_checking,
6263
)
6364
})
6465
.collect()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
use ruff_python_semantic::{ScopeKind, SemanticModel};
2+
3+
use crate::rules::flake8_type_checking;
4+
use crate::settings::LinterSettings;
5+
6+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7+
pub(super) enum AnnotationContext {
8+
/// Python will evaluate the annotation at runtime, but it's not _required_ and, as such, could
9+
/// be quoted to convert it into a typing-only annotation.
10+
///
11+
/// For example:
12+
/// ```python
13+
/// from pandas import DataFrame
14+
///
15+
/// def foo() -> DataFrame:
16+
/// ...
17+
/// ```
18+
///
19+
/// Above, Python will evaluate `DataFrame` at runtime in order to add it to `__annotations__`.
20+
RuntimeEvaluated,
21+
/// Python will evaluate the annotation at runtime, and it's required to be available at
22+
/// runtime, as a library (like Pydantic) needs access to it.
23+
RuntimeRequired,
24+
/// The annotation is only evaluated at type-checking time.
25+
TypingOnly,
26+
}
27+
28+
impl AnnotationContext {
29+
pub(super) fn from_model(semantic: &SemanticModel, settings: &LinterSettings) -> Self {
30+
// If the annotation is in a class scope (e.g., an annotated assignment for a
31+
// class field), and that class is marked as annotation as runtime-required.
32+
if semantic
33+
.current_scope()
34+
.kind
35+
.as_class()
36+
.is_some_and(|class_def| {
37+
flake8_type_checking::helpers::runtime_required_class(
38+
class_def,
39+
&settings.flake8_type_checking.runtime_required_base_classes,
40+
&settings.flake8_type_checking.runtime_required_decorators,
41+
semantic,
42+
)
43+
})
44+
{
45+
return Self::RuntimeRequired;
46+
}
47+
48+
// If `__future__` annotations are enabled, then annotations are never evaluated
49+
// at runtime, so we can treat them as typing-only.
50+
if semantic.future_annotations() {
51+
return Self::TypingOnly;
52+
}
53+
54+
// Otherwise, if we're in a class or module scope, then the annotation needs to
55+
// be available at runtime.
56+
// See: https://docs.python.org/3/reference/simple_stmts.html#annotated-assignment-statements
57+
if matches!(
58+
semantic.current_scope().kind,
59+
ScopeKind::Class(_) | ScopeKind::Module
60+
) {
61+
return Self::RuntimeEvaluated;
62+
}
63+
64+
Self::TypingOnly
65+
}
66+
}

crates/ruff_linter/src/checkers/ast/mod.rs

+28-40
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ use ruff_python_semantic::{
5858
use ruff_python_stdlib::builtins::{IPYTHON_BUILTINS, MAGIC_GLOBALS, PYTHON_BUILTINS};
5959
use ruff_source_file::Locator;
6060

61+
use crate::checkers::ast::annotation::AnnotationContext;
6162
use crate::checkers::ast::deferred::Deferred;
6263
use crate::docstrings::extraction::ExtractionTarget;
6364
use crate::importer::Importer;
@@ -68,6 +69,7 @@ use crate::settings::{flags, LinterSettings};
6869
use crate::{docstrings, noqa};
6970

7071
mod analyze;
72+
mod annotation;
7173
mod deferred;
7274

7375
pub(crate) struct Checker<'a> {
@@ -515,8 +517,10 @@ where
515517
.chain(&parameters.kwonlyargs)
516518
{
517519
if let Some(expr) = &parameter_with_default.parameter.annotation {
518-
if runtime_annotation || singledispatch {
519-
self.visit_runtime_annotation(expr);
520+
if singledispatch {
521+
self.visit_runtime_required_annotation(expr);
522+
} else if runtime_annotation {
523+
self.visit_runtime_evaluated_annotation(expr);
520524
} else {
521525
self.visit_annotation(expr);
522526
};
@@ -529,7 +533,7 @@ where
529533
if let Some(arg) = &parameters.vararg {
530534
if let Some(expr) = &arg.annotation {
531535
if runtime_annotation {
532-
self.visit_runtime_annotation(expr);
536+
self.visit_runtime_evaluated_annotation(expr);
533537
} else {
534538
self.visit_annotation(expr);
535539
};
@@ -538,15 +542,15 @@ where
538542
if let Some(arg) = &parameters.kwarg {
539543
if let Some(expr) = &arg.annotation {
540544
if runtime_annotation {
541-
self.visit_runtime_annotation(expr);
545+
self.visit_runtime_evaluated_annotation(expr);
542546
} else {
543547
self.visit_annotation(expr);
544548
};
545549
}
546550
}
547551
for expr in returns {
548552
if runtime_annotation {
549-
self.visit_runtime_annotation(expr);
553+
self.visit_runtime_evaluated_annotation(expr);
550554
} else {
551555
self.visit_annotation(expr);
552556
};
@@ -677,40 +681,16 @@ where
677681
value,
678682
..
679683
}) => {
680-
// If we're in a class or module scope, then the annotation needs to be
681-
// available at runtime.
682-
// See: https://docs.python.org/3/reference/simple_stmts.html#annotated-assignment-statements
683-
let runtime_annotation = if self.semantic.future_annotations() {
684-
self.semantic
685-
.current_scope()
686-
.kind
687-
.as_class()
688-
.is_some_and(|class_def| {
689-
flake8_type_checking::helpers::runtime_evaluated_class(
690-
class_def,
691-
&self
692-
.settings
693-
.flake8_type_checking
694-
.runtime_evaluated_base_classes,
695-
&self
696-
.settings
697-
.flake8_type_checking
698-
.runtime_evaluated_decorators,
699-
&self.semantic,
700-
)
701-
})
702-
} else {
703-
matches!(
704-
self.semantic.current_scope().kind,
705-
ScopeKind::Class(_) | ScopeKind::Module
706-
)
707-
};
708-
709-
if runtime_annotation {
710-
self.visit_runtime_annotation(annotation);
711-
} else {
712-
self.visit_annotation(annotation);
684+
match AnnotationContext::from_model(&self.semantic, self.settings) {
685+
AnnotationContext::RuntimeRequired => {
686+
self.visit_runtime_required_annotation(annotation);
687+
}
688+
AnnotationContext::RuntimeEvaluated => {
689+
self.visit_runtime_evaluated_annotation(annotation);
690+
}
691+
AnnotationContext::TypingOnly => self.visit_annotation(annotation),
713692
}
693+
714694
if let Some(expr) = value {
715695
if self.semantic.match_typing_expr(annotation, "TypeAlias") {
716696
self.visit_type_definition(expr);
@@ -1527,10 +1507,18 @@ impl<'a> Checker<'a> {
15271507
self.semantic.flags = snapshot;
15281508
}
15291509

1510+
/// Visit an [`Expr`], and treat it as a runtime-evaluated type annotation.
1511+
fn visit_runtime_evaluated_annotation(&mut self, expr: &'a Expr) {
1512+
let snapshot = self.semantic.flags;
1513+
self.semantic.flags |= SemanticModelFlags::RUNTIME_EVALUATED_ANNOTATION;
1514+
self.visit_type_definition(expr);
1515+
self.semantic.flags = snapshot;
1516+
}
1517+
15301518
/// Visit an [`Expr`], and treat it as a runtime-required type annotation.
1531-
fn visit_runtime_annotation(&mut self, expr: &'a Expr) {
1519+
fn visit_runtime_required_annotation(&mut self, expr: &'a Expr) {
15321520
let snapshot = self.semantic.flags;
1533-
self.semantic.flags |= SemanticModelFlags::RUNTIME_ANNOTATION;
1521+
self.semantic.flags |= SemanticModelFlags::RUNTIME_REQUIRED_ANNOTATION;
15341522
self.visit_type_definition(expr);
15351523
self.semantic.flags = snapshot;
15361524
}

0 commit comments

Comments
 (0)