From 160b5f257b7fb891fa2753081fbf0ae0efda3829 Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 10 Nov 2025 23:46:50 -0500 Subject: [PATCH 1/2] fix-21347 --- .../resources/test/fixtures/pyupgrade/UP007.py | 12 ++++++++++++ .../rules/pyupgrade/rules/use_pep604_annotation.rs | 6 ++++++ 2 files changed, 18 insertions(+) diff --git a/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP007.py b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP007.py index dd60d4c833..d730210f92 100644 --- a/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP007.py +++ b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP007.py @@ -139,3 +139,15 @@ b_string_te_1: "UnionTE[NamedTupleTE]" = None b_string_te_2: "UnionTE[NamedTupleTE, None]" = None b_string_typing_1: "typing.Union[typing.NamedTuple]" = None b_string_typing_2: "typing.Union[typing.NamedTuple, None]" = None + + +# Regression test for https://github.com/astral-sh/ruff/issues/21347 +# Don't emit lint for dynamic Union creation (e.g., Union[types] where types is a variable) +def f(types: tuple[type, ...]): + return Union[types] + + +if __name__ == "__main__": + u = f((int, str, float)) + print(u) # typing.Union[int, str, float] + print(type(u)) # diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/use_pep604_annotation.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/use_pep604_annotation.rs index aabbb65d15..ee5a62fa6a 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/use_pep604_annotation.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/use_pep604_annotation.rs @@ -205,6 +205,12 @@ pub(crate) fn non_pep604_annotation( return; } + // Skip dynamic Union creation (e.g., `Union[types]` where `types` is a variable). + // This cannot be converted to PEP 604 syntax because the types are determined at runtime. + if matches!(slice, Expr::Name(_)) { + return; + } + let mut diagnostic = checker.report_diagnostic(NonPEP604AnnotationUnion, expr.range()); if fixable { match slice { From bb61ed58ccfd85e0256b4a50d3079ce1e00edf70 Mon Sep 17 00:00:00 2001 From: Dan Date: Tue, 11 Nov 2025 17:22:44 -0500 Subject: [PATCH 2/2] Enhance PEP 604 annotation handling by skipping dynamic Union creation in non-type definition contexts. Added support for implicit type aliases at the module level. --- .../test/fixtures/pyupgrade/UP007.py | 12 +++++++ .../pyupgrade/rules/use_pep604_annotation.rs | 33 +++++++++++++++++-- ...er__rules__pyupgrade__tests__UP007.py.snap | 24 +++++++------- 3 files changed, 54 insertions(+), 15 deletions(-) diff --git a/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP007.py b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP007.py index d730210f92..2e630dcdf9 100644 --- a/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP007.py +++ b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP007.py @@ -147,6 +147,18 @@ def f(types: tuple[type, ...]): return Union[types] +# Don't emit lint for dynamic Union creation with function calls (e.g., Union[foo()]) +def get_types(): + return (int, str, float) + + +def g(): + return Union[get_types()] + + +# Implicit type alias at module level - should be flagged +IntOrStr = Union[int, str] + if __name__ == "__main__": u = f((int, str, float)) print(u) # typing.Union[int, str, float] diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/use_pep604_annotation.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/use_pep604_annotation.rs index ee5a62fa6a..f858872b0a 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/rules/use_pep604_annotation.rs +++ b/crates/ruff_linter/src/rules/pyupgrade/rules/use_pep604_annotation.rs @@ -205,9 +205,15 @@ pub(crate) fn non_pep604_annotation( return; } - // Skip dynamic Union creation (e.g., `Union[types]` where `types` is a variable). - // This cannot be converted to PEP 604 syntax because the types are determined at runtime. - if matches!(slice, Expr::Name(_)) { + // Only apply this rule in type annotation/definition positions, including implicit + // type aliases (e.g., `X = Union[int, str]` at module or class level). + // Skip dynamic Union creation (e.g., `Union[types]` or `Union[foo()]`) when not in + // type annotations, as these cannot be converted to PEP 604 syntax since the types + // are determined at runtime. + let in_type_definition = checker.semantic().in_type_definition(); + let in_implicit_type_alias = is_implicit_type_alias(checker, expr); + + if !in_type_definition && !in_implicit_type_alias { return; } @@ -327,3 +333,24 @@ fn is_named_tuple(checker: &Checker, expr: &Expr) -> bool { fn is_optional_none(operator: Pep604Operator, slice: &Expr) -> bool { matches!(operator, Pep604Operator::Optional) && matches!(slice, Expr::NoneLiteral(_)) } + +/// Return `true` if the expression is part of an implicit type alias (e.g., `X = Union[int, str]` +/// at module or class level). +fn is_implicit_type_alias(checker: &Checker, expr: &Expr) -> bool { + let semantic = checker.semantic(); + let scope = semantic.current_scope(); + + // Only consider module-level or class-level assignments as potential type aliases + if scope.kind.is_function() { + return false; + } + + // Check if the current statement is a simple assignment + if let ast::Stmt::Assign(ast::StmtAssign { value, .. }) = semantic.current_statement() { + // Check if the Union expression is part of the assignment value + // We check if the expression's range is within the value's range + expr.range().start() >= value.range().start() && expr.range().end() <= value.range().end() + } else { + false + } +} diff --git a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP007.py.snap b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP007.py.snap index d2ce0fd604..fcc27bf643 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP007.py.snap +++ b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP007.py.snap @@ -205,17 +205,6 @@ help: Convert to `X | Y` 43 | 44 | -UP007 Use `X | Y` for type annotations - --> UP007.py:46:9 - | -45 | def f() -> None: -46 | x = Union[str, int] - | ^^^^^^^^^^^^^^^ -47 | x = Union["str", "int"] -48 | x: Union[str, int] - | -help: Convert to `X | Y` - UP007 [*] Use `X | Y` for type annotations --> UP007.py:48:8 | @@ -342,4 +331,15 @@ help: Convert to `X | Y` 91 + def myfunc(param: "tuple[int | 'AClass' | None, str]"): 92 | print(param) 93 | -94 | +94 | + +UP007 Use `X | Y` for type annotations + --> UP007.py:160:12 + | +159 | # Implicit type alias at module level - should be flagged +160 | IntOrStr = Union[int, str] + | ^^^^^^^^^^^^^^^ +161 | +162 | if __name__ == "__main__": + | +help: Convert to `X | Y`