Skip to content

Commit 6ddfb51

Browse files
authored
[flake8-bugbear] Mark fix as unsafe for non-NFKC attribute names (B009, B010) (#21131)
1 parent e017b03 commit 6ddfb51

File tree

5 files changed

+105
-4
lines changed

5 files changed

+105
-4
lines changed

crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B009_B010.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,12 @@
7070

7171
# Regression test for: https://github.com/astral-sh/ruff/issues/18353
7272
setattr(foo, "__debug__", 0)
73+
74+
# Regression test for: https://github.com/astral-sh/ruff/issues/21126
75+
# Non-NFKC attribute names should be marked as unsafe. Python normalizes identifiers in
76+
# attribute access (obj.attr) using NFKC, but does not normalize string
77+
# arguments passed to getattr/setattr. Rewriting `getattr(ns, "ſ")` to
78+
# `ns.ſ` would be interpreted as `ns.s` at runtime, changing behavior.
79+
# Example: the long s character "ſ" normalizes to "s" under NFKC.
80+
getattr(foo, "ſ")
81+
setattr(foo, "ſ", 1)

crates/ruff_linter/src/rules/flake8_bugbear/rules/getattr_with_constant.rs

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use ruff_python_ast::{self as ast, Expr};
33
use ruff_python_stdlib::identifiers::{is_identifier, is_mangled_private};
44
use ruff_source_file::LineRanges;
55
use ruff_text_size::Ranged;
6+
use unicode_normalization::UnicodeNormalization;
67

78
use crate::checkers::ast::Checker;
89
use crate::fix::edits::pad;
@@ -29,6 +30,21 @@ use crate::{AlwaysFixableViolation, Edit, Fix};
2930
/// obj.foo
3031
/// ```
3132
///
33+
/// ## Fix safety
34+
/// The fix is marked as unsafe for attribute names that are not in NFKC (Normalization Form KC)
35+
/// normalization. Python normalizes identifiers using NFKC when using attribute access syntax
36+
/// (e.g., `obj.attr`), but does not normalize string arguments passed to `getattr`. Rewriting
37+
/// `getattr(obj, "ſ")` to `obj.ſ` would be interpreted as `obj.s` at runtime, changing behavior.
38+
///
39+
/// For example, the long s character `"ſ"` normalizes to `"s"` under NFKC, so:
40+
/// ```python
41+
/// # This accesses an attribute with the exact name "ſ" (if it exists)
42+
/// value = getattr(obj, "ſ")
43+
///
44+
/// # But this would normalize to "s" and access a different attribute
45+
/// obj.ſ # This is interpreted as obj.s, not obj.ſ
46+
/// ```
47+
///
3248
/// ## References
3349
/// - [Python documentation: `getattr`](https://docs.python.org/3/library/functions.html#getattr)
3450
#[derive(ViolationMetadata)]
@@ -69,8 +85,14 @@ pub(crate) fn getattr_with_constant(checker: &Checker, expr: &Expr, func: &Expr,
6985
return;
7086
}
7187

88+
// Mark fixes as unsafe for non-NFKC attribute names. Python normalizes identifiers using NFKC, so using
89+
// attribute syntax (e.g., `obj.attr`) would normalize the name and potentially change
90+
// program behavior.
91+
let attr_name = value.to_str();
92+
let is_unsafe = attr_name.nfkc().collect::<String>() != attr_name;
93+
7294
let mut diagnostic = checker.report_diagnostic(GetAttrWithConstant, expr.range());
73-
diagnostic.set_fix(Fix::safe_edit(Edit::range_replacement(
95+
let edit = Edit::range_replacement(
7496
pad(
7597
if matches!(
7698
obj,
@@ -88,5 +110,11 @@ pub(crate) fn getattr_with_constant(checker: &Checker, expr: &Expr, func: &Expr,
88110
checker.locator(),
89111
),
90112
expr.range(),
91-
)));
113+
);
114+
let fix = if is_unsafe {
115+
Fix::unsafe_edit(edit)
116+
} else {
117+
Fix::safe_edit(edit)
118+
};
119+
diagnostic.set_fix(fix);
92120
}

crates/ruff_linter/src/rules/flake8_bugbear/rules/setattr_with_constant.rs

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use ruff_text_size::{Ranged, TextRange};
44
use ruff_macros::{ViolationMetadata, derive_message_formats};
55
use ruff_python_codegen::Generator;
66
use ruff_python_stdlib::identifiers::{is_identifier, is_mangled_private};
7+
use unicode_normalization::UnicodeNormalization;
78

89
use crate::checkers::ast::Checker;
910
use crate::{AlwaysFixableViolation, Edit, Fix};
@@ -28,6 +29,23 @@ use crate::{AlwaysFixableViolation, Edit, Fix};
2829
/// obj.foo = 42
2930
/// ```
3031
///
32+
/// ## Fix safety
33+
/// The fix is marked as unsafe for attribute names that are not in NFKC (Normalization Form KC)
34+
/// normalization. Python normalizes identifiers using NFKC when using attribute access syntax
35+
/// (e.g., `obj.attr = value`), but does not normalize string arguments passed to `setattr`.
36+
/// Rewriting `setattr(obj, "ſ", 1)` to `obj.ſ = 1` would be interpreted as `obj.s = 1` at
37+
/// runtime, changing behavior.
38+
///
39+
/// For example, the long s character `"ſ"` normalizes to `"s"` under NFKC, so:
40+
/// ```python
41+
/// # This creates an attribute with the exact name "ſ"
42+
/// setattr(obj, "ſ", 1)
43+
/// getattr(obj, "ſ") # Returns 1
44+
///
45+
/// # But this would normalize to "s" and set a different attribute
46+
/// obj.ſ = 1 # This is interpreted as obj.s = 1, not obj.ſ = 1
47+
/// ```
48+
///
3149
/// ## References
3250
/// - [Python documentation: `setattr`](https://docs.python.org/3/library/functions.html#setattr)
3351
#[derive(ViolationMetadata)]
@@ -89,6 +107,12 @@ pub(crate) fn setattr_with_constant(checker: &Checker, expr: &Expr, func: &Expr,
89107
return;
90108
}
91109

110+
// Mark fixes as unsafe for non-NFKC attribute names. Python normalizes identifiers using NFKC, so using
111+
// attribute syntax (e.g., `obj.attr = value`) would normalize the name and potentially change
112+
// program behavior.
113+
let attr_name = name.to_str();
114+
let is_unsafe = attr_name.nfkc().collect::<String>() != attr_name;
115+
92116
// We can only replace a `setattr` call (which is an `Expr`) with an assignment
93117
// (which is a `Stmt`) if the `Expr` is already being used as a `Stmt`
94118
// (i.e., it's directly within an `Stmt::Expr`).
@@ -100,10 +124,16 @@ pub(crate) fn setattr_with_constant(checker: &Checker, expr: &Expr, func: &Expr,
100124
{
101125
if expr == child.as_ref() {
102126
let mut diagnostic = checker.report_diagnostic(SetAttrWithConstant, expr.range());
103-
diagnostic.set_fix(Fix::safe_edit(Edit::range_replacement(
127+
let edit = Edit::range_replacement(
104128
assignment(obj, name.to_str(), value, checker.generator()),
105129
expr.range(),
106-
)));
130+
);
131+
let fix = if is_unsafe {
132+
Fix::unsafe_edit(edit)
133+
} else {
134+
Fix::safe_edit(edit)
135+
};
136+
diagnostic.set_fix(fix);
107137
}
108138
}
109139
}

crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B009_B009_B010.py.snap

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,3 +360,21 @@ help: Replace `getattr` with attribute access
360360
70 |
361361
71 | # Regression test for: https://github.com/astral-sh/ruff/issues/18353
362362
72 | setattr(foo, "__debug__", 0)
363+
364+
B009 [*] Do not call `getattr` with a constant attribute value. It is not any safer than normal property access.
365+
--> B009_B010.py:80:1
366+
|
367+
78 | # `ns.ſ` would be interpreted as `ns.s` at runtime, changing behavior.
368+
79 | # Example: the long s character "ſ" normalizes to "s" under NFKC.
369+
80 | getattr(foo, "ſ")
370+
| ^^^^^^^^^^^^^^^^^
371+
81 | setattr(foo, "ſ", 1)
372+
|
373+
help: Replace `getattr` with attribute access
374+
77 | # arguments passed to getattr/setattr. Rewriting `getattr(ns, "ſ")` to
375+
78 | # `ns.ſ` would be interpreted as `ns.s` at runtime, changing behavior.
376+
79 | # Example: the long s character "ſ" normalizes to "s" under NFKC.
377+
- getattr(foo, "ſ")
378+
80 + foo.ſ
379+
81 | setattr(foo, "ſ", 1)
380+
note: This is an unsafe fix and may change runtime behavior

crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B010_B009_B010.py.snap

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,19 @@ help: Replace `setattr` with assignment
118118
56 |
119119
57 | # Regression test for: https://github.com/astral-sh/ruff/issues/7455#issuecomment-1722458885
120120
58 | assert getattr(func, '_rpc')is True
121+
122+
B010 [*] Do not call `setattr` with a constant attribute value. It is not any safer than normal property access.
123+
--> B009_B010.py:81:1
124+
|
125+
79 | # Example: the long s character "ſ" normalizes to "s" under NFKC.
126+
80 | getattr(foo, "ſ")
127+
81 | setattr(foo, "ſ", 1)
128+
| ^^^^^^^^^^^^^^^^^^^^
129+
|
130+
help: Replace `setattr` with assignment
131+
78 | # `ns.ſ` would be interpreted as `ns.s` at runtime, changing behavior.
132+
79 | # Example: the long s character "ſ" normalizes to "s" under NFKC.
133+
80 | getattr(foo, "ſ")
134+
- setattr(foo, "ſ", 1)
135+
81 + foo.ſ = 1
136+
note: This is an unsafe fix and may change runtime behavior

0 commit comments

Comments
 (0)