Skip to content

Commit e66f182

Browse files
dericcragontBreMichaReiser
authored
[ruff] Added cls.__dict__.get('__annotations__') check (RUF063) (#18233)
Added `cls.__dict__.get('__annotations__')` check for Python 3.10+ and Python < 3.10 with `typing-extensions` enabled. Closes #17853 <!-- Thank you for contributing to Ruff/ty! To help us out with reviewing, please consider the following: - Does this pull request include a summary of the change? (See below.) - Does this pull request include a descriptive title? (Please prefix with `[ty]` for ty pull requests.) - Does this pull request include references to any relevant issues? --> ## Summary Added `cls.__dict__.get('__annotations__')` check for Python 3.10+ and Python < 3.10 with `typing-extensions` enabled. ## Test Plan `cargo test` --------- Co-authored-by: Brent Westbrook <36778786+ntBre@users.noreply.github.com> Co-authored-by: Micha Reiser <micha@reiser.io>
1 parent f544026 commit e66f182

11 files changed

+391
-0
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# RUF063
2+
# Cases that should trigger the violation
3+
4+
foo.__dict__.get("__annotations__") # RUF063
5+
foo.__dict__.get("__annotations__", None) # RUF063
6+
foo.__dict__.get("__annotations__", {}) # RUF063
7+
foo.__dict__["__annotations__"] # RUF063
8+
9+
# Cases that should NOT trigger the violation
10+
11+
foo.__dict__.get("not__annotations__")
12+
foo.__dict__.get("not__annotations__", None)
13+
foo.__dict__.get("not__annotations__", {})
14+
foo.__dict__["not__annotations__"]
15+
foo.__annotations__
16+
foo.get("__annotations__")
17+
foo.get("__annotations__", None)
18+
foo.get("__annotations__", {})

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,9 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) {
179179
if checker.enabled(Rule::MissingMaxsplitArg) {
180180
pylint::rules::missing_maxsplit_arg(checker, value, slice, expr);
181181
}
182+
if checker.enabled(Rule::AccessAnnotationsFromClassDict) {
183+
ruff::rules::access_annotations_from_class_dict_by_key(checker, subscript);
184+
}
182185
pandas_vet::rules::subscript(checker, value, expr);
183186
}
184187
Expr::Tuple(ast::ExprTuple {
@@ -1196,6 +1199,9 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) {
11961199
if checker.enabled(Rule::StarmapZip) {
11971200
ruff::rules::starmap_zip(checker, call);
11981201
}
1202+
if checker.enabled(Rule::AccessAnnotationsFromClassDict) {
1203+
ruff::rules::access_annotations_from_class_dict_with_get(checker, call);
1204+
}
11991205
if checker.enabled(Rule::LogExceptionOutsideExceptHandler) {
12001206
flake8_logging::rules::log_exception_outside_except_handler(checker, call);
12011207
}

crates/ruff_linter/src/codes.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1028,6 +1028,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
10281028
(Ruff, "059") => (RuleGroup::Preview, rules::ruff::rules::UnusedUnpackedVariable),
10291029
(Ruff, "060") => (RuleGroup::Preview, rules::ruff::rules::InEmptyCollection),
10301030
(Ruff, "061") => (RuleGroup::Preview, rules::ruff::rules::LegacyFormPytestRaises),
1031+
(Ruff, "063") => (RuleGroup::Preview, rules::ruff::rules::AccessAnnotationsFromClassDict),
10311032
(Ruff, "064") => (RuleGroup::Preview, rules::ruff::rules::NonOctalPermissions),
10321033
(Ruff, "100") => (RuleGroup::Stable, rules::ruff::rules::UnusedNOQA),
10331034
(Ruff, "101") => (RuleGroup::Stable, rules::ruff::rules::RedirectedNOQA),

crates/ruff_linter/src/rules/ruff/mod.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,60 @@ mod tests {
171171
Ok(())
172172
}
173173

174+
#[test]
175+
fn access_annotations_from_class_dict_py39_no_typing_extensions() -> Result<()> {
176+
let diagnostics = test_path(
177+
Path::new("ruff/RUF063.py"),
178+
&LinterSettings {
179+
typing_extensions: false,
180+
unresolved_target_version: PythonVersion::PY39.into(),
181+
..LinterSettings::for_rule(Rule::AccessAnnotationsFromClassDict)
182+
},
183+
)?;
184+
assert_diagnostics!(diagnostics);
185+
Ok(())
186+
}
187+
188+
#[test]
189+
fn access_annotations_from_class_dict_py39_with_typing_extensions() -> Result<()> {
190+
let diagnostics = test_path(
191+
Path::new("ruff/RUF063.py"),
192+
&LinterSettings {
193+
typing_extensions: true,
194+
unresolved_target_version: PythonVersion::PY39.into(),
195+
..LinterSettings::for_rule(Rule::AccessAnnotationsFromClassDict)
196+
},
197+
)?;
198+
assert_diagnostics!(diagnostics);
199+
Ok(())
200+
}
201+
202+
#[test]
203+
fn access_annotations_from_class_dict_py310() -> Result<()> {
204+
let diagnostics = test_path(
205+
Path::new("ruff/RUF063.py"),
206+
&LinterSettings {
207+
unresolved_target_version: PythonVersion::PY310.into(),
208+
..LinterSettings::for_rule(Rule::AccessAnnotationsFromClassDict)
209+
},
210+
)?;
211+
assert_diagnostics!(diagnostics);
212+
Ok(())
213+
}
214+
215+
#[test]
216+
fn access_annotations_from_class_dict_py314() -> Result<()> {
217+
let diagnostics = test_path(
218+
Path::new("ruff/RUF063.py"),
219+
&LinterSettings {
220+
unresolved_target_version: PythonVersion::PY314.into(),
221+
..LinterSettings::for_rule(Rule::AccessAnnotationsFromClassDict)
222+
},
223+
)?;
224+
assert_diagnostics!(diagnostics);
225+
Ok(())
226+
}
227+
174228
#[test]
175229
fn confusables() -> Result<()> {
176230
let diagnostics = test_path(
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
use crate::checkers::ast::Checker;
2+
use crate::{FixAvailability, Violation};
3+
use ruff_macros::{ViolationMetadata, derive_message_formats};
4+
use ruff_python_ast::{Expr, ExprCall, ExprSubscript, PythonVersion};
5+
use ruff_text_size::Ranged;
6+
7+
/// ## What it does
8+
/// Checks for uses of `foo.__dict__.get("__annotations__")` or
9+
/// `foo.__dict__["__annotations__"]` on Python 3.10+ and Python < 3.10 when
10+
/// [typing-extensions](https://docs.astral.sh/ruff/settings/#lint_typing-extensions)
11+
/// is enabled.
12+
///
13+
/// ## Why is this bad?
14+
/// Starting with Python 3.14, directly accessing `__annotations__` via
15+
/// `foo.__dict__.get("__annotations__")` or `foo.__dict__["__annotations__"]`
16+
/// will only return annotations if the class is defined under
17+
/// `from __future__ import annotations`.
18+
///
19+
/// Therefore, it is better to use dedicated library functions like
20+
/// `annotationlib.get_annotations` (Python 3.14+), `inspect.get_annotations`
21+
/// (Python 3.10+), or `typing_extensions.get_annotations` (for Python < 3.10 if
22+
/// [typing-extensions](https://pypi.org/project/typing-extensions/) is
23+
/// available).
24+
///
25+
/// The benefits of using these functions include:
26+
/// 1. **Avoiding Undocumented Internals:** They provide a stable, public API,
27+
/// unlike direct `__dict__` access which relies on implementation details.
28+
/// 2. **Forward-Compatibility:** They are designed to handle changes in
29+
/// Python's annotation system across versions, ensuring your code remains
30+
/// robust (e.g., correctly handling the Python 3.14 behavior mentioned
31+
/// above).
32+
///
33+
/// See [Python Annotations Best Practices](https://docs.python.org/3.14/howto/annotations.html)
34+
/// for alternatives.
35+
///
36+
/// ## Example
37+
///
38+
/// ```python
39+
/// foo.__dict__.get("__annotations__", {})
40+
/// # or
41+
/// foo.__dict__["__annotations__"]
42+
/// ```
43+
///
44+
/// On Python 3.14+, use instead:
45+
/// ```python
46+
/// import annotationlib
47+
///
48+
/// annotationlib.get_annotations(foo)
49+
/// ```
50+
///
51+
/// On Python 3.10+, use instead:
52+
/// ```python
53+
/// import inspect
54+
///
55+
/// inspect.get_annotations(foo)
56+
/// ```
57+
///
58+
/// On Python < 3.10 with [typing-extensions](https://pypi.org/project/typing-extensions/)
59+
/// installed, use instead:
60+
/// ```python
61+
/// import typing_extensions
62+
///
63+
/// typing_extensions.get_annotations(foo)
64+
/// ```
65+
///
66+
/// ## Fix safety
67+
///
68+
/// No autofix is currently provided for this rule.
69+
///
70+
/// ## Fix availability
71+
///
72+
/// No autofix is currently provided for this rule.
73+
///
74+
/// ## References
75+
/// - [Python Annotations Best Practices](https://docs.python.org/3.14/howto/annotations.html)
76+
#[derive(ViolationMetadata)]
77+
pub(crate) struct AccessAnnotationsFromClassDict {
78+
python_version: PythonVersion,
79+
}
80+
81+
impl Violation for AccessAnnotationsFromClassDict {
82+
const FIX_AVAILABILITY: FixAvailability = FixAvailability::None;
83+
84+
#[derive_message_formats]
85+
fn message(&self) -> String {
86+
let suggestion = if self.python_version >= PythonVersion::PY314 {
87+
"annotationlib.get_annotations"
88+
} else if self.python_version >= PythonVersion::PY310 {
89+
"inspect.get_annotations"
90+
} else {
91+
"typing_extensions.get_annotations"
92+
};
93+
format!("Use `{suggestion}` instead of `__dict__` access")
94+
}
95+
}
96+
97+
/// RUF063
98+
pub(crate) fn access_annotations_from_class_dict_with_get(checker: &Checker, call: &ExprCall) {
99+
let python_version = checker.target_version();
100+
let typing_extensions = checker.settings.typing_extensions;
101+
102+
// Only apply this rule for Python 3.10 and newer unless `typing-extensions` is enabled.
103+
if python_version < PythonVersion::PY310 && !typing_extensions {
104+
return;
105+
}
106+
107+
// Expected pattern: foo.__dict__.get("__annotations__" [, <default>])
108+
// Here, `call` is the `.get(...)` part.
109+
110+
// 1. Check that the `call.func` is `get`
111+
let get_attribute = match call.func.as_ref() {
112+
Expr::Attribute(attr) if attr.attr.as_str() == "get" => attr,
113+
_ => return,
114+
};
115+
116+
// 2. Check that the `get_attribute.value` is `__dict__`
117+
match get_attribute.value.as_ref() {
118+
Expr::Attribute(attr) if attr.attr.as_str() == "__dict__" => {}
119+
_ => return,
120+
}
121+
122+
// At this point, we have `foo.__dict__.get`.
123+
124+
// 3. Check arguments to `.get()`:
125+
// - No keyword arguments.
126+
// - One or two positional arguments.
127+
// - First positional argument must be the string literal "__annotations__".
128+
// - The value of the second positional argument (if present) does not affect the match.
129+
if !call.arguments.keywords.is_empty() || call.arguments.len() > 2 {
130+
return;
131+
}
132+
133+
let Some(first_arg) = &call.arguments.find_positional(0) else {
134+
return;
135+
};
136+
137+
let is_first_arg_correct = first_arg
138+
.as_string_literal_expr()
139+
.is_some_and(|s| s.value.to_str() == "__annotations__");
140+
141+
if is_first_arg_correct {
142+
checker.report_diagnostic(
143+
AccessAnnotationsFromClassDict { python_version },
144+
call.range(),
145+
);
146+
}
147+
}
148+
149+
/// RUF063
150+
pub(crate) fn access_annotations_from_class_dict_by_key(
151+
checker: &Checker,
152+
subscript: &ExprSubscript,
153+
) {
154+
let python_version = checker.target_version();
155+
let typing_extensions = checker.settings.typing_extensions;
156+
157+
// Only apply this rule for Python 3.10 and newer unless `typing-extensions` is enabled.
158+
if python_version < PythonVersion::PY310 && !typing_extensions {
159+
return;
160+
}
161+
162+
// Expected pattern: foo.__dict__["__annotations__"]
163+
164+
// 1. Check that the slice is a string literal "__annotations__"
165+
if subscript
166+
.slice
167+
.as_string_literal_expr()
168+
.is_none_or(|s| s.value.to_str() != "__annotations__")
169+
{
170+
return;
171+
}
172+
173+
// 2. Check that the `subscript.value` is `__dict__`
174+
let is_value_correct = subscript
175+
.value
176+
.as_attribute_expr()
177+
.is_some_and(|attr| attr.attr.as_str() == "__dict__");
178+
179+
if is_value_correct {
180+
checker.report_diagnostic(
181+
AccessAnnotationsFromClassDict { python_version },
182+
subscript.range(),
183+
);
184+
}
185+
}

crates/ruff_linter/src/rules/ruff/rules/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
pub(crate) use access_annotations_from_class_dict::*;
12
pub(crate) use ambiguous_unicode_character::*;
23
pub(crate) use assert_with_print_message::*;
34
pub(crate) use assignment_in_assert::*;
@@ -59,6 +60,7 @@ pub(crate) use used_dummy_variable::*;
5960
pub(crate) use useless_if_else::*;
6061
pub(crate) use zip_instead_of_pairwise::*;
6162

63+
mod access_annotations_from_class_dict;
6264
mod ambiguous_unicode_character;
6365
mod assert_with_print_message;
6466
mod assignment_in_assert;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
source: crates/ruff_linter/src/rules/ruff/mod.rs
3+
---
4+
RUF063.py:4:1: RUF063 Use `inspect.get_annotations` instead of `__dict__` access
5+
|
6+
2 | # Cases that should trigger the violation
7+
3 |
8+
4 | foo.__dict__.get("__annotations__") # RUF063
9+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF063
10+
5 | foo.__dict__.get("__annotations__", None) # RUF063
11+
6 | foo.__dict__.get("__annotations__", {}) # RUF063
12+
|
13+
14+
RUF063.py:5:1: RUF063 Use `inspect.get_annotations` instead of `__dict__` access
15+
|
16+
4 | foo.__dict__.get("__annotations__") # RUF063
17+
5 | foo.__dict__.get("__annotations__", None) # RUF063
18+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF063
19+
6 | foo.__dict__.get("__annotations__", {}) # RUF063
20+
7 | foo.__dict__["__annotations__"] # RUF063
21+
|
22+
23+
RUF063.py:6:1: RUF063 Use `inspect.get_annotations` instead of `__dict__` access
24+
|
25+
4 | foo.__dict__.get("__annotations__") # RUF063
26+
5 | foo.__dict__.get("__annotations__", None) # RUF063
27+
6 | foo.__dict__.get("__annotations__", {}) # RUF063
28+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF063
29+
7 | foo.__dict__["__annotations__"] # RUF063
30+
|
31+
32+
RUF063.py:7:1: RUF063 Use `inspect.get_annotations` instead of `__dict__` access
33+
|
34+
5 | foo.__dict__.get("__annotations__", None) # RUF063
35+
6 | foo.__dict__.get("__annotations__", {}) # RUF063
36+
7 | foo.__dict__["__annotations__"] # RUF063
37+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF063
38+
8 |
39+
9 | # Cases that should NOT trigger the violation
40+
|
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
source: crates/ruff_linter/src/rules/ruff/mod.rs
3+
---
4+
RUF063.py:4:1: RUF063 Use `annotationlib.get_annotations` instead of `__dict__` access
5+
|
6+
2 | # Cases that should trigger the violation
7+
3 |
8+
4 | foo.__dict__.get("__annotations__") # RUF063
9+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF063
10+
5 | foo.__dict__.get("__annotations__", None) # RUF063
11+
6 | foo.__dict__.get("__annotations__", {}) # RUF063
12+
|
13+
14+
RUF063.py:5:1: RUF063 Use `annotationlib.get_annotations` instead of `__dict__` access
15+
|
16+
4 | foo.__dict__.get("__annotations__") # RUF063
17+
5 | foo.__dict__.get("__annotations__", None) # RUF063
18+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF063
19+
6 | foo.__dict__.get("__annotations__", {}) # RUF063
20+
7 | foo.__dict__["__annotations__"] # RUF063
21+
|
22+
23+
RUF063.py:6:1: RUF063 Use `annotationlib.get_annotations` instead of `__dict__` access
24+
|
25+
4 | foo.__dict__.get("__annotations__") # RUF063
26+
5 | foo.__dict__.get("__annotations__", None) # RUF063
27+
6 | foo.__dict__.get("__annotations__", {}) # RUF063
28+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF063
29+
7 | foo.__dict__["__annotations__"] # RUF063
30+
|
31+
32+
RUF063.py:7:1: RUF063 Use `annotationlib.get_annotations` instead of `__dict__` access
33+
|
34+
5 | foo.__dict__.get("__annotations__", None) # RUF063
35+
6 | foo.__dict__.get("__annotations__", {}) # RUF063
36+
7 | foo.__dict__["__annotations__"] # RUF063
37+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF063
38+
8 |
39+
9 | # Cases that should NOT trigger the violation
40+
|
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
source: crates/ruff_linter/src/rules/ruff/mod.rs
3+
---
4+

0 commit comments

Comments
 (0)