From dd91f9337458f64c38fd4605bb37ce433cc3cb05 Mon Sep 17 00:00:00 2001 From: baltasarblanco Date: Tue, 14 Jul 2026 15:51:59 -0300 Subject: [PATCH 1/2] [`flake8-comprehensions`] NFKC-normalize keyword names in `C408` fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python normalizes identifiers to NFKC, but does not normalize string literals. The fix for `unnecessary-collection-call` (C408) reparses the call with libcst, which — unlike Ruff's own parser — does not normalize identifiers, and then emits the raw source text of each keyword argument as a dictionary key. This changed the key at runtime: `dict(ℼ=3.14)` has the key `π`, but was rewritten to `{"ℼ": 3.14}`. Normalize the keyword name before quoting it, matching what `fix::codemods` already does for qualified names built from libCST nodes. Fixes #16234 --- .../fixtures/flake8_comprehensions/C408.py | 9 +++ .../src/rules/flake8_comprehensions/fixes.rs | 9 ++- ...8_comprehensions__tests__C408_C408.py.snap | 78 +++++++++++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C408.py b/crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C408.py index c1ac839e27f3d..d2ffaa73528c8 100644 --- a/crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C408.py +++ b/crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C408.py @@ -37,3 +37,12 @@ def list(): t"{ dict(x='y') | dict(y='z') }" t"a {dict(x='y') | dict(y='z')} b" t"a { dict(x='y') | dict(y='z') } b" + +# https://github.com/astral-sh/ruff/issues/16234 +# Python normalizes identifiers to NFKC, but does not normalize string literals, so the fix has to +# normalize the keyword name to preserve the dictionary key at runtime. The character "ℼ" normalizes +# to "π", and "ſ" normalizes to "s". +dict(ℼ=3.14) +dict(ſ=1) +dict(𝕒=1, b=2) +dict(a=1, b=2) # already NFKC-normalized: unchanged diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/fixes.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/fixes.rs index 32d850820b456..67fa58c8e3540 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/fixes.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/fixes.rs @@ -14,6 +14,7 @@ use ruff_python_ast::{self as ast, Expr, ExprCall}; use ruff_python_codegen::Stylist; use ruff_python_semantic::SemanticModel; use ruff_text_size::{Ranged, TextRange}; +use unicode_normalization::UnicodeNormalization; use crate::Locator; use crate::cst::helpers::{negate, space}; @@ -241,6 +242,10 @@ pub(crate) fn fix_unnecessary_collection_call( .unwrap_or(stylist.quote()); // Quote each argument. + // + // Python normalizes identifiers to NFKC, but string literals are not normalized. Emitting the + // raw source text of a keyword argument would change the dictionary key at runtime, so the + // name has to be normalized. See https://github.com/astral-sh/ruff/issues/16234. for arg in &call.args { let quoted = format!( "{}{}{}", @@ -248,7 +253,9 @@ pub(crate) fn fix_unnecessary_collection_call( arg.keyword .as_ref() .expect("Expected dictionary argument to be kwarg") - .value, + .value + .nfkc() + .collect::(), quote, ); arena.push(quoted); diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C408_C408.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C408_C408.py.snap index d3fa03663c686..0002bcc838712 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C408_C408.py.snap +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C408_C408.py.snap @@ -529,12 +529,15 @@ C408 [*] Unnecessary `dict()` call (rewrite as a literal) 38 | t"a {dict(x='y') | dict(y='z')} b" 39 | t"a { dict(x='y') | dict(y='z') } b" | ^^^^^^^^^^^ +40 | +41 | # https://github.com/astral-sh/ruff/issues/16234 | help: Rewrite as a literal | 38 | t"a {dict(x='y') | dict(y='z')} b" - t"a { dict(x='y') | dict(y='z') } b" 39 + t"a { {'x': 'y'} | dict(y='z') } b" +40 | | note: This is an unsafe fix and may change runtime behavior @@ -545,11 +548,86 @@ C408 [*] Unnecessary `dict()` call (rewrite as a literal) 38 | t"a {dict(x='y') | dict(y='z')} b" 39 | t"a { dict(x='y') | dict(y='z') } b" | ^^^^^^^^^^^ +40 | +41 | # https://github.com/astral-sh/ruff/issues/16234 | help: Rewrite as a literal | 38 | t"a {dict(x='y') | dict(y='z')} b" - t"a { dict(x='y') | dict(y='z') } b" 39 + t"a { dict(x='y') | {'y': 'z'} } b" +40 | + | +note: This is an unsafe fix and may change runtime behavior + +C408 [*] Unnecessary `dict()` call (rewrite as a literal) + --> C408.py:45:1 + | +43 | # normalize the keyword name to preserve the dictionary key at runtime. The character "ℼ" normalizes +44 | # to "π", and "ſ" normalizes to "s". +45 | dict(ℼ=3.14) + | ^^^^^^^^^^^^ +46 | dict(ſ=1) +47 | dict(𝕒=1, b=2) + | +help: Rewrite as a literal + | +44 | # to "π", and "ſ" normalizes to "s". + - dict(ℼ=3.14) +45 + {"π": 3.14} +46 | dict(ſ=1) + | +note: This is an unsafe fix and may change runtime behavior + +C408 [*] Unnecessary `dict()` call (rewrite as a literal) + --> C408.py:46:1 + | +44 | # to "π", and "ſ" normalizes to "s". +45 | dict(ℼ=3.14) +46 | dict(ſ=1) + | ^^^^^^^^^ +47 | dict(𝕒=1, b=2) +48 | dict(a=1, b=2) # already NFKC-normalized: unchanged + | +help: Rewrite as a literal + | +45 | dict(ℼ=3.14) + - dict(ſ=1) +46 + {"s": 1} +47 | dict(𝕒=1, b=2) + | +note: This is an unsafe fix and may change runtime behavior + +C408 [*] Unnecessary `dict()` call (rewrite as a literal) + --> C408.py:47:1 + | +45 | dict(ℼ=3.14) +46 | dict(ſ=1) +47 | dict(𝕒=1, b=2) + | ^^^^^^^^^^^^^^ +48 | dict(a=1, b=2) # already NFKC-normalized: unchanged + | +help: Rewrite as a literal + | +46 | dict(ſ=1) + - dict(𝕒=1, b=2) +47 + {"a": 1, "b": 2} +48 | dict(a=1, b=2) # already NFKC-normalized: unchanged + | +note: This is an unsafe fix and may change runtime behavior + +C408 [*] Unnecessary `dict()` call (rewrite as a literal) + --> C408.py:48:1 + | +46 | dict(ſ=1) +47 | dict(𝕒=1, b=2) +48 | dict(a=1, b=2) # already NFKC-normalized: unchanged + | ^^^^^^^^^^^^^^ + | +help: Rewrite as a literal + | +47 | dict(𝕒=1, b=2) + - dict(a=1, b=2) # already NFKC-normalized: unchanged +48 + {"a": 1, "b": 2} # already NFKC-normalized: unchanged | note: This is an unsafe fix and may change runtime behavior From e439157456f030bc9d5f36a07058f1d40194f4ea Mon Sep 17 00:00:00 2001 From: Brent Westbrook <36778786+ntBre@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:06:33 -0400 Subject: [PATCH 2/2] Update crates/ruff_linter/src/rules/flake8_comprehensions/fixes.rs --- crates/ruff_linter/src/rules/flake8_comprehensions/fixes.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/fixes.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/fixes.rs index 67fa58c8e3540..e04d218d9029a 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/fixes.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/fixes.rs @@ -254,8 +254,7 @@ pub(crate) fn fix_unnecessary_collection_call( .as_ref() .expect("Expected dictionary argument to be kwarg") .value - .nfkc() - .collect::(), + .nfkc(), quote, ); arena.push(quoted);