Skip to content

Commit 8669fd7

Browse files
committed
Added cls.__dict__.get('__annotations__') check for Python 3.10+ and
Python < 3.10 with `typing_extensions` enabled.
1 parent d098118 commit 8669fd7

10 files changed

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

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1185,6 +1185,9 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) {
11851185
if checker.enabled(Rule::StarmapZip) {
11861186
ruff::rules::starmap_zip(checker, call);
11871187
}
1188+
if checker.enabled(Rule::ClassDictAnnotations) {
1189+
ruff::rules::class_dict_annotations(checker, call);
1190+
}
11881191
if checker.enabled(Rule::LogExceptionOutsideExceptHandler) {
11891192
flake8_logging::rules::log_exception_outside_except_handler(checker, call);
11901193
}

crates/ruff_linter/src/codes.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1024,6 +1024,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
10241024
(Ruff, "058") => (RuleGroup::Preview, rules::ruff::rules::StarmapZip),
10251025
(Ruff, "059") => (RuleGroup::Preview, rules::ruff::rules::UnusedUnpackedVariable),
10261026
(Ruff, "060") => (RuleGroup::Preview, rules::ruff::rules::InEmptyCollection),
1027+
(Ruff, "061") => (RuleGroup::Preview, rules::ruff::rules::ClassDictAnnotations),
10271028
(Ruff, "100") => (RuleGroup::Stable, rules::ruff::rules::UnusedNOQA),
10281029
(Ruff, "101") => (RuleGroup::Stable, rules::ruff::rules::RedirectedNOQA),
10291030
(Ruff, "102") => (RuleGroup::Preview, rules::ruff::rules::InvalidRuleCode),

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,47 @@ mod tests {
161161
Ok(())
162162
}
163163

164+
#[test]
165+
fn class_dict_annotations_py39_no_typing_extensions() -> Result<()> {
166+
let diagnostics = test_path(
167+
Path::new("ruff/RUF061.py"),
168+
&LinterSettings {
169+
typing_extensions: false,
170+
unresolved_target_version: PythonVersion::PY39.into(),
171+
..LinterSettings::for_rule(Rule::ClassDictAnnotations)
172+
},
173+
)?;
174+
assert_messages!(diagnostics);
175+
Ok(())
176+
}
177+
178+
#[test]
179+
fn class_dict_annotations_py39_with_typing_extensions() -> Result<()> {
180+
let diagnostics = test_path(
181+
Path::new("ruff/RUF061.py"),
182+
&LinterSettings {
183+
typing_extensions: true,
184+
unresolved_target_version: PythonVersion::PY39.into(),
185+
..LinterSettings::for_rule(Rule::ClassDictAnnotations)
186+
},
187+
)?;
188+
assert_messages!(diagnostics);
189+
Ok(())
190+
}
191+
192+
#[test]
193+
fn class_dict_annotations_py310() -> Result<()> {
194+
let diagnostics = test_path(
195+
Path::new("ruff/RUF061.py"),
196+
&LinterSettings {
197+
unresolved_target_version: PythonVersion::PY310.into(),
198+
..LinterSettings::for_rule(Rule::ClassDictAnnotations)
199+
},
200+
)?;
201+
assert_messages!(diagnostics);
202+
Ok(())
203+
}
204+
164205
#[test]
165206
fn confusables() -> Result<()> {
166207
let diagnostics = test_path(
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
use crate::checkers::ast::Checker;
2+
use ruff_diagnostics::{Diagnostic, FixAvailability, Violation};
3+
use ruff_macros::{ViolationMetadata, derive_message_formats};
4+
use ruff_python_ast::{Expr, ExprCall, PythonVersion};
5+
use ruff_text_size::Ranged;
6+
7+
/// ## What it does
8+
/// Checks for uses of `<identifier>.__dict__.get("__annotations__" [, <default>])`
9+
/// on Python 3.10+ and Python < 3.10 with `typing_extensions` enabled.
10+
///
11+
/// ## Why is this bad?
12+
/// On Python 3.10 and newer, `<identifier>.__dict__.get("__annotations__" [, <default>])`
13+
/// can be unreliable, especially with the introduction of stringized annotations
14+
/// and features like `from __future__ import annotations`. For Python 3.14+, this
15+
/// approach is more strongly discouraged.
16+
///
17+
/// See [Python Annotations Best Practices](https://docs.python.org/3.14/howto/annotations.html)
18+
/// for alternatives.
19+
///
20+
/// TLDR:
21+
///
22+
/// If you use a class with a custom metaclass and access `__annotations__`
23+
/// on the class, you may observe unexpected behavior; see
24+
/// [PEP 749](https://peps.python.org/pep-0749/#pep749-metaclasses) for some
25+
/// examples.
26+
///
27+
/// You can avoid these quirks by using `annotationlib.get_annotations()` on
28+
/// Python 3.14+, `inspect.get_annotations()` on Python 3.10+, or
29+
/// `typing_extensions.get_annotations(cls)` on Python < 3.10 with
30+
/// `typing_extensions` enabled.
31+
///
32+
/// ## Example
33+
///
34+
/// ```python
35+
/// cls.__dict__.get("__annotations__", {})
36+
/// ```
37+
///
38+
/// On Python 3.14+, use instead:
39+
/// ```python
40+
/// import annotationlib
41+
///
42+
/// annotationlib.get_annotations(cls)
43+
/// ```
44+
///
45+
/// On Python 3.10+, use instead:
46+
/// ```python
47+
/// import inspect
48+
///
49+
/// inspect.get_annotations(cls)
50+
/// ```
51+
///
52+
/// On Python < 3.10 with `typing_extensions` enabled, use instead:
53+
/// ```python
54+
/// import typing_extensions
55+
///
56+
/// typing_extensions.get_annotations(cls)
57+
/// ```
58+
///
59+
/// ## Fix safety
60+
///
61+
/// No autofix is currently provided for this rule.
62+
///
63+
/// ## Fix availability
64+
///
65+
/// No autofix is currently provided for this rule.
66+
///
67+
/// ## References
68+
/// - [Python Annotations Best Practices](https://docs.python.org/3.14/howto/annotations.html)
69+
#[derive(ViolationMetadata)]
70+
pub(crate) struct ClassDictAnnotations;
71+
72+
impl Violation for ClassDictAnnotations {
73+
const FIX_AVAILABILITY: FixAvailability = FixAvailability::None;
74+
75+
#[derive_message_formats]
76+
fn message(&self) -> String {
77+
"Use `typing_extensions.get_annotations` (Python < 3.10 with `typing_extensions` enabled), `inspect.get_annotations` (Python 3.10+), or `annotationlib.get_annotations` (Python 3.14+) instead of `__dict__.get('__annotations__')`".to_string()
78+
}
79+
}
80+
81+
/// RUF061
82+
pub(crate) fn class_dict_annotations(checker: &Checker, call: &ExprCall) {
83+
// Only apply this rule for Python 3.10 and newer unless `typing_extensions` is enabled.
84+
if checker.target_version() < PythonVersion::PY310 && !checker.settings.typing_extensions {
85+
return;
86+
}
87+
88+
// Expected pattern: <identifier>.__dict__.get("__annotations__" [, <default>])
89+
// Here, `call` is the `.get(...)` part.
90+
91+
// 1. Check that the `call.func` is `get`
92+
let get_attribute = match call.func.as_ref() {
93+
Expr::Attribute(attr) if attr.attr.as_str() == "get" => attr,
94+
_ => return, // Not a call to an attribute named "get"
95+
};
96+
97+
// 2. Check that the `get_attribute.value` is `__dict__`
98+
let dict_attribute = match get_attribute.value.as_ref() {
99+
Expr::Attribute(attr) if attr.attr.as_str() == "__dict__" => attr,
100+
_ => return, // The object of ".get" is not an attribute named "__dict__"
101+
};
102+
103+
// 3. Check that the `dict_attribute.value` is an Expr::Name (e.g., `cls`, `obj`)
104+
let Expr::Name(_object_name_expr) = dict_attribute.value.as_ref() else {
105+
return; // Not <identifier>.__dict__.get
106+
};
107+
// `_object_name_expr` is now an &ruff_python_ast::ExprName
108+
109+
// At this point, we have `<identifier>.__dict__.get`. Now check arguments.
110+
111+
// 4. Check arguments to `.get()`:
112+
// - No keyword arguments.
113+
// - One or two positional arguments.
114+
// - First positional argument must be the string literal "__annotations__".
115+
// - The value of the second positional argument (if present) does not affect the match.
116+
if call.arguments.keywords.is_empty()
117+
&& (call.arguments.args.len() == 1 || call.arguments.args.len() == 2)
118+
{
119+
let first_arg = &call.arguments.args[0];
120+
let is_first_arg_correct = match first_arg.as_string_literal_expr() {
121+
Some(str_literal) => str_literal.value.to_str() == "__annotations__",
122+
None => false,
123+
};
124+
125+
if is_first_arg_correct {
126+
// Pattern successfully matched! Report a diagnostic.
127+
let diagnostic = Diagnostic::new(ClassDictAnnotations, call.range());
128+
checker.report_diagnostic(diagnostic);
129+
}
130+
}
131+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ pub(crate) use ambiguous_unicode_character::*;
22
pub(crate) use assert_with_print_message::*;
33
pub(crate) use assignment_in_assert::*;
44
pub(crate) use asyncio_dangling_task::*;
5+
pub(crate) use class_dict_annotations::*;
56
pub(crate) use class_with_mixed_type_vars::*;
67
pub(crate) use collection_literal_concatenation::*;
78
pub(crate) use dataclass_enum::*;
@@ -61,6 +62,7 @@ mod ambiguous_unicode_character;
6162
mod assert_with_print_message;
6263
mod assignment_in_assert;
6364
mod asyncio_dangling_task;
65+
mod class_dict_annotations;
6466
mod class_with_mixed_type_vars;
6567
mod collection_literal_concatenation;
6668
mod confusables;
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
source: crates/ruff_linter/src/rules/ruff/mod.rs
3+
---
4+
RUF061.py:4:1: RUF061 Use `typing_extensions.get_annotations` (Python < 3.10 with `typing_extensions` enabled), `inspect.get_annotations` (Python 3.10+), or `annotationlib.get_annotations` (Python 3.14+) instead of `__dict__.get('__annotations__')`
5+
|
6+
2 | # Cases that should trigger the violation
7+
3 |
8+
4 | foo.__dict__.get("__annotations__") # RUF061
9+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF061
10+
5 | foo.__dict__.get("__annotations__", None) # RUF061
11+
6 | foo.__dict__.get("__annotations__", {}) # RUF061
12+
|
13+
14+
RUF061.py:5:1: RUF061 Use `typing_extensions.get_annotations` (Python < 3.10 with `typing_extensions` enabled), `inspect.get_annotations` (Python 3.10+), or `annotationlib.get_annotations` (Python 3.14+) instead of `__dict__.get('__annotations__')`
15+
|
16+
4 | foo.__dict__.get("__annotations__") # RUF061
17+
5 | foo.__dict__.get("__annotations__", None) # RUF061
18+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF061
19+
6 | foo.__dict__.get("__annotations__", {}) # RUF061
20+
|
21+
22+
RUF061.py:6:1: RUF061 Use `typing_extensions.get_annotations` (Python < 3.10 with `typing_extensions` enabled), `inspect.get_annotations` (Python 3.10+), or `annotationlib.get_annotations` (Python 3.14+) instead of `__dict__.get('__annotations__')`
23+
|
24+
4 | foo.__dict__.get("__annotations__") # RUF061
25+
5 | foo.__dict__.get("__annotations__", None) # RUF061
26+
6 | foo.__dict__.get("__annotations__", {}) # RUF061
27+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF061
28+
7 |
29+
8 | # Cases that should NOT trigger the violation
30+
|
Lines changed: 4 additions & 0 deletions
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+
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
source: crates/ruff_linter/src/rules/ruff/mod.rs
3+
---
4+
RUF061.py:4:1: RUF061 Use `typing_extensions.get_annotations` (Python < 3.10 with `typing_extensions` enabled), `inspect.get_annotations` (Python 3.10+), or `annotationlib.get_annotations` (Python 3.14+) instead of `__dict__.get('__annotations__')`
5+
|
6+
2 | # Cases that should trigger the violation
7+
3 |
8+
4 | foo.__dict__.get("__annotations__") # RUF061
9+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF061
10+
5 | foo.__dict__.get("__annotations__", None) # RUF061
11+
6 | foo.__dict__.get("__annotations__", {}) # RUF061
12+
|
13+
14+
RUF061.py:5:1: RUF061 Use `typing_extensions.get_annotations` (Python < 3.10 with `typing_extensions` enabled), `inspect.get_annotations` (Python 3.10+), or `annotationlib.get_annotations` (Python 3.14+) instead of `__dict__.get('__annotations__')`
15+
|
16+
4 | foo.__dict__.get("__annotations__") # RUF061
17+
5 | foo.__dict__.get("__annotations__", None) # RUF061
18+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF061
19+
6 | foo.__dict__.get("__annotations__", {}) # RUF061
20+
|
21+
22+
RUF061.py:6:1: RUF061 Use `typing_extensions.get_annotations` (Python < 3.10 with `typing_extensions` enabled), `inspect.get_annotations` (Python 3.10+), or `annotationlib.get_annotations` (Python 3.14+) instead of `__dict__.get('__annotations__')`
23+
|
24+
4 | foo.__dict__.get("__annotations__") # RUF061
25+
5 | foo.__dict__.get("__annotations__", None) # RUF061
26+
6 | foo.__dict__.get("__annotations__", {}) # RUF061
27+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF061
28+
7 |
29+
8 | # Cases that should NOT trigger the violation
30+
|

ruff.schema.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)