Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[flake8-type-checking] Allows TC001-004 to quote more expressions #14787

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ info:
- check
- "--show-settings"
- unformatted.py
snapshot_kind: text
---
success: true
exit_code: 0
Expand Down Expand Up @@ -303,7 +302,7 @@ linter.flake8_type_checking.exempt_modules = [
]
linter.flake8_type_checking.runtime_required_base_classes = []
linter.flake8_type_checking.runtime_required_decorators = []
linter.flake8_type_checking.quote_annotations = false
linter.flake8_type_checking.quote_type_expressions = "none"
linter.flake8_unused_arguments.ignore_variadic_names = false
linter.isort.required_imports = []
linter.isort.combine_as_imports = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,3 @@ def f():

def test_annotated_non_typing_reference(user: Annotated[str, Depends(get_foo)]):
pass


def f():
from typing import TypeAlias, TYPE_CHECKING

if TYPE_CHECKING:
from pandas import DataFrame

x: TypeAlias = DataFrame | None
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
def f():
from pandas import DataFrame

def baz() -> DataFrame[int]:
...


def f():
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from pandas import DataFrame

def baz() -> DataFrame[int]:
...


def f():
from typing import TypeAlias, TYPE_CHECKING

if TYPE_CHECKING:
from pandas import DataFrame

x: TypeAlias = DataFrame | None


def f():
from typing import TypeAlias

from pandas import DataFrame

x: TypeAlias = DataFrame | None


def f():
from typing import cast, TYPE_CHECKING

from .foo import get_foo

if TYPE_CHECKING:
from pandas import DataFrame

foo = cast(DataFrame, get_foo())


def f():
from typing import cast

from pandas import DataFrame

from .foo import get_foo

foo = cast(DataFrame, get_foo())


def f():
from typing import TypeAlias, TYPE_CHECKING

if TYPE_CHECKING:
from pandas import DataFrame

x: TypeAlias = DataFrame | dict

assert isinstance({}, x) # runtime use of type alias


def f():
from typing import TypeAlias

from pandas import DataFrame

x: TypeAlias = DataFrame | dict

assert isinstance({}, x) # runtime use of type alias


def f():
from typing import Annotated, TypeAlias
from expensive import Foo
from expensive2 import Foo2

Y: TypeAlias = Annotated[Foo, "metadata"]
some_object = Y()

class Z(Annotated[Foo2, "metadata"]): ...
11 changes: 10 additions & 1 deletion crates/ruff_linter/src/checkers/ast/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1280,7 +1280,7 @@ impl<'a> Visitor<'a> for Checker<'a> {
Some(typing::Callable::Cast) => {
let mut args = arguments.args.iter();
if let Some(arg) = args.next() {
self.visit_type_definition(arg);
self.visit_cast_type_expression(arg);

if self.enabled(Rule::RuntimeCastValue) {
flake8_type_checking::rules::runtime_cast_value(self, arg);
Expand Down Expand Up @@ -1889,6 +1889,15 @@ impl<'a> Checker<'a> {
self.semantic.flags = snapshot;
}

/// Visit an [`Expr`], and treat it as a the type expression
/// of a `typing.cast` call.
fn visit_cast_type_expression(&mut self, expr: &'a Expr) {
let snapshot = self.semantic.flags;
self.semantic.flags |= SemanticModelFlags::CAST_TYPE_EXPRESSION;
self.visit_type_definition(expr);
self.semantic.flags = snapshot;
}

/// Visit an [`Expr`], and treat it as a type definition.
fn visit_type_definition(&mut self, expr: &'a Expr) {
let snapshot = self.semantic.flags;
Expand Down
42 changes: 39 additions & 3 deletions crates/ruff_linter/src/rules/flake8_type_checking/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,52 @@ use ruff_text_size::{Ranged, TextRange};
use crate::rules::flake8_type_checking::settings::Settings;
use crate::Locator;

use super::settings::QuoteTypeExpressions;

/// 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 {
pub(crate) fn is_typing_reference(
semantic: &SemanticModel,
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())))
|| (settings.quote_type_expressions >= QuoteTypeExpressions::Safe && reference.in_cast_type_expression())
|| (settings.quote_type_expressions >= QuoteTypeExpressions::Balanced && reference.in_runtime_evaluated_annotation())
|| (settings.quote_type_expressions >= QuoteTypeExpressions::Eager && reference.in_annotated_type_alias_value() && !parent_type_alias_has_runtime_references(semantic, reference))))
}

/// Find the [`Binding`] defined by the [PEP 613] type alias from a [`Reference`]
/// originating from its value expression and check whether or not the binding has
/// any runtime references
///
/// [PEP 613]: https://peps.python.org/pep-0613/
pub(crate) fn parent_type_alias_has_runtime_references(
semantic: &SemanticModel,
reference: &ResolvedReference,
) -> bool {
let Some(expression_id) = reference.expression_id() else {
return false;
};
let statement = semantic.statement(expression_id);
let scope = &semantic.scopes[reference.scope_id()];
for (_, binding_id) in scope.bindings() {
let binding = semantic.binding(binding_id);
let Some(binding_statement) = binding.statement(semantic) else {
continue;
};
if statement == binding_statement {
return binding
.references()
.any(|reference_id| semantic.reference(reference_id).in_runtime_context());
}
}
false
}

/// Returns `true` if the [`Binding`] represents a runtime-required import.
Expand All @@ -41,7 +77,7 @@ pub(crate) fn is_valid_runtime_import(
&& binding
.references()
.map(|reference_id| semantic.reference(reference_id))
.any(|reference| !is_typing_reference(reference, settings))
.any(|reference| !is_typing_reference(semantic, reference, settings))
} else {
false
}
Expand Down
57 changes: 55 additions & 2 deletions crates/ruff_linter/src/rules/flake8_type_checking/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ mod tests {
use test_case::test_case;

use crate::registry::{Linter, Rule};
use crate::rules::flake8_type_checking::settings::QuoteTypeExpressions;
use crate::test::{test_path, test_snippet};
use crate::{assert_messages, settings};

Expand Down Expand Up @@ -80,19 +81,71 @@ mod tests {
Ok(())
}

#[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("quote4.py"))]
#[test_case(Rule::TypingOnlyThirdPartyImport, Path::new("quote4.py"))]
fn quote_casts(rule_code: Rule, path: &Path) -> Result<()> {
let snapshot = format!(
"quote_casts_{}_{}",
rule_code.as_ref(),
path.to_string_lossy()
);
let diagnostics = test_path(
Path::new("flake8_type_checking").join(path).as_path(),
&settings::LinterSettings {
flake8_type_checking: super::settings::Settings {
quote_type_expressions: QuoteTypeExpressions::Safe,
..Default::default()
},
..settings::LinterSettings::for_rule(rule_code)
},
)?;
assert_messages!(snapshot, diagnostics);
Ok(())
}

#[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("quote.py"))]
#[test_case(Rule::TypingOnlyThirdPartyImport, Path::new("quote.py"))]
#[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("quote2.py"))]
#[test_case(Rule::TypingOnlyThirdPartyImport, Path::new("quote2.py"))]
#[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("quote3.py"))]
#[test_case(Rule::TypingOnlyThirdPartyImport, Path::new("quote3.py"))]
#[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("quote4.py"))]
#[test_case(Rule::TypingOnlyThirdPartyImport, Path::new("quote4.py"))]
fn quote_annotations(rule_code: Rule, path: &Path) -> Result<()> {
let snapshot = format!(
"quote_annotations_{}_{}",
rule_code.as_ref(),
path.to_string_lossy()
);
let diagnostics = test_path(
Path::new("flake8_type_checking").join(path).as_path(),
&settings::LinterSettings {
flake8_type_checking: super::settings::Settings {
quote_type_expressions: QuoteTypeExpressions::Balanced,
..Default::default()
},
..settings::LinterSettings::for_rule(rule_code)
},
)?;
assert_messages!(snapshot, diagnostics);
Ok(())
}

#[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("quote.py"))]
#[test_case(Rule::TypingOnlyThirdPartyImport, Path::new("quote.py"))]
#[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("quote2.py"))]
#[test_case(Rule::TypingOnlyThirdPartyImport, Path::new("quote2.py"))]
#[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("quote3.py"))]
#[test_case(Rule::TypingOnlyThirdPartyImport, Path::new("quote3.py"))]
fn quote(rule_code: Rule, path: &Path) -> Result<()> {
#[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("quote4.py"))]
#[test_case(Rule::TypingOnlyThirdPartyImport, Path::new("quote4.py"))]
fn quote_all(rule_code: Rule, path: &Path) -> Result<()> {
let snapshot = format!("quote_{}_{}", rule_code.as_ref(), path.to_string_lossy());
let diagnostics = test_path(
Path::new("flake8_type_checking").join(path).as_path(),
&settings::LinterSettings {
flake8_type_checking: super::settings::Settings {
quote_annotations: true,
quote_type_expressions: QuoteTypeExpressions::Eager,
..Default::default()
},
..settings::LinterSettings::for_rule(rule_code)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ use crate::checkers::ast::Checker;
use crate::codes::Rule;
use crate::fix;
use crate::importer::ImportedMembers;
use crate::rules::flake8_type_checking::helpers::{filter_contained, quote_annotation};
use crate::rules::flake8_type_checking::helpers::{
filter_contained, parent_type_alias_has_runtime_references, quote_annotation,
};
use crate::rules::flake8_type_checking::imports::ImportBinding;
use crate::rules::flake8_type_checking::settings::QuoteTypeExpressions;

/// ## What it does
/// Checks for runtime imports defined in a type-checking block.
Expand All @@ -22,9 +25,9 @@ use crate::rules::flake8_type_checking::imports::ImportBinding;
/// The type-checking block is not executed at runtime, so the import will not
/// be available at runtime.
///
/// If [`lint.flake8-type-checking.quote-annotations`] is set to `true`,
/// annotations will be wrapped in quotes if doing so would enable the
/// corresponding import to remain in the type-checking block.
/// Changing [`lint.flake8-type-checking.quote-type-expressions`] allows some
/// type expressions to be wrapped in quotes if doing so would enable the
/// corresponding import to be moved into an `if TYPE_CHECKING:` block.
///
/// ## Example
/// ```python
Expand All @@ -48,7 +51,7 @@ use crate::rules::flake8_type_checking::imports::ImportBinding;
/// ```
///
/// ## Options
/// - `lint.flake8-type-checking.quote-annotations`
/// - `lint.flake8-type-checking.quote-type-expressions`
///
/// ## References
/// - [PEP 563: Runtime annotation resolution and `TYPE_CHECKING`](https://peps.python.org/pep-0563/#runtime-annotation-resolution-and-type-checking)
Expand Down Expand Up @@ -136,6 +139,14 @@ pub(crate) fn runtime_import_in_type_checking_block(
parent_range: binding.parent_range(checker.semantic()),
};

// TODO: We should consider giving TC007 precedence, when it is
// enabled in order to better match what the original plugin
// does, i.e. if all runtime uses occur in annotated type aliases
// values, then TC004 could be avoided by fixing TC007 instead
// this is less broad than `quote-annotated-type-alias-values`
// so should generally be safer. We would need to be careful
// with ignored TC007 violations however, since that is a
// signal that the user actually wants TC004 to trigger.
if checker.rule_is_ignored(Rule::RuntimeImportInTypeCheckingBlock, import.start())
|| import.parent_range.is_some_and(|parent_range| {
checker.rule_is_ignored(
Expand All @@ -151,18 +162,21 @@ pub(crate) fn runtime_import_in_type_checking_block(
} else {
// Determine whether the member should be fixed by moving the import out of the
// type-checking block, or by quoting its references.
// TODO: We should check `reference.in_annotated_type_alias()`
// as well to match the behavior of the flake8 plugin
// although maybe the best way forward is to add an
// additional setting to configure whether quoting
// or moving the import is preferred for type aliases
// since some people will consistently use their
// type aliases at runtimes, while others won't, so
// the best solution is unclear.
if checker.settings.flake8_type_checking.quote_annotations
let settings = &checker.settings.flake8_type_checking;
if settings.quote_type_expressions > QuoteTypeExpressions::None
&& binding.references().all(|reference_id| {
let reference = checker.semantic().reference(reference_id);
reference.in_typing_context() || reference.in_runtime_evaluated_annotation()
reference.in_typing_context()
|| (settings.quote_type_expressions >= QuoteTypeExpressions::Safe
&& reference.in_cast_type_expression())
|| (settings.quote_type_expressions >= QuoteTypeExpressions::Balanced
&& reference.in_runtime_evaluated_annotation())
|| (settings.quote_type_expressions >= QuoteTypeExpressions::Eager
&& reference.in_annotated_type_alias_value()
&& !parent_type_alias_has_runtime_references(
checker.semantic(),
reference,
))
})
{
actions
Expand Down
Loading
Loading