[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>
This commit is contained in:
Deric Crago 2025-06-20 09:32:40 -04:00 committed by GitHub
parent f544026b81
commit e66f182045
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 391 additions and 0 deletions

View file

@ -0,0 +1,18 @@
# RUF063
# Cases that should trigger the violation
foo.__dict__.get("__annotations__") # RUF063
foo.__dict__.get("__annotations__", None) # RUF063
foo.__dict__.get("__annotations__", {}) # RUF063
foo.__dict__["__annotations__"] # RUF063
# Cases that should NOT trigger the violation
foo.__dict__.get("not__annotations__")
foo.__dict__.get("not__annotations__", None)
foo.__dict__.get("not__annotations__", {})
foo.__dict__["not__annotations__"]
foo.__annotations__
foo.get("__annotations__")
foo.get("__annotations__", None)
foo.get("__annotations__", {})

View file

@ -179,6 +179,9 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) {
if checker.enabled(Rule::MissingMaxsplitArg) {
pylint::rules::missing_maxsplit_arg(checker, value, slice, expr);
}
if checker.enabled(Rule::AccessAnnotationsFromClassDict) {
ruff::rules::access_annotations_from_class_dict_by_key(checker, subscript);
}
pandas_vet::rules::subscript(checker, value, expr);
}
Expr::Tuple(ast::ExprTuple {
@ -1196,6 +1199,9 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) {
if checker.enabled(Rule::StarmapZip) {
ruff::rules::starmap_zip(checker, call);
}
if checker.enabled(Rule::AccessAnnotationsFromClassDict) {
ruff::rules::access_annotations_from_class_dict_with_get(checker, call);
}
if checker.enabled(Rule::LogExceptionOutsideExceptHandler) {
flake8_logging::rules::log_exception_outside_except_handler(checker, call);
}

View file

@ -1028,6 +1028,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
(Ruff, "059") => (RuleGroup::Preview, rules::ruff::rules::UnusedUnpackedVariable),
(Ruff, "060") => (RuleGroup::Preview, rules::ruff::rules::InEmptyCollection),
(Ruff, "061") => (RuleGroup::Preview, rules::ruff::rules::LegacyFormPytestRaises),
(Ruff, "063") => (RuleGroup::Preview, rules::ruff::rules::AccessAnnotationsFromClassDict),
(Ruff, "064") => (RuleGroup::Preview, rules::ruff::rules::NonOctalPermissions),
(Ruff, "100") => (RuleGroup::Stable, rules::ruff::rules::UnusedNOQA),
(Ruff, "101") => (RuleGroup::Stable, rules::ruff::rules::RedirectedNOQA),

View file

@ -171,6 +171,60 @@ mod tests {
Ok(())
}
#[test]
fn access_annotations_from_class_dict_py39_no_typing_extensions() -> Result<()> {
let diagnostics = test_path(
Path::new("ruff/RUF063.py"),
&LinterSettings {
typing_extensions: false,
unresolved_target_version: PythonVersion::PY39.into(),
..LinterSettings::for_rule(Rule::AccessAnnotationsFromClassDict)
},
)?;
assert_diagnostics!(diagnostics);
Ok(())
}
#[test]
fn access_annotations_from_class_dict_py39_with_typing_extensions() -> Result<()> {
let diagnostics = test_path(
Path::new("ruff/RUF063.py"),
&LinterSettings {
typing_extensions: true,
unresolved_target_version: PythonVersion::PY39.into(),
..LinterSettings::for_rule(Rule::AccessAnnotationsFromClassDict)
},
)?;
assert_diagnostics!(diagnostics);
Ok(())
}
#[test]
fn access_annotations_from_class_dict_py310() -> Result<()> {
let diagnostics = test_path(
Path::new("ruff/RUF063.py"),
&LinterSettings {
unresolved_target_version: PythonVersion::PY310.into(),
..LinterSettings::for_rule(Rule::AccessAnnotationsFromClassDict)
},
)?;
assert_diagnostics!(diagnostics);
Ok(())
}
#[test]
fn access_annotations_from_class_dict_py314() -> Result<()> {
let diagnostics = test_path(
Path::new("ruff/RUF063.py"),
&LinterSettings {
unresolved_target_version: PythonVersion::PY314.into(),
..LinterSettings::for_rule(Rule::AccessAnnotationsFromClassDict)
},
)?;
assert_diagnostics!(diagnostics);
Ok(())
}
#[test]
fn confusables() -> Result<()> {
let diagnostics = test_path(

View file

@ -0,0 +1,185 @@
use crate::checkers::ast::Checker;
use crate::{FixAvailability, Violation};
use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast::{Expr, ExprCall, ExprSubscript, PythonVersion};
use ruff_text_size::Ranged;
/// ## What it does
/// Checks for uses of `foo.__dict__.get("__annotations__")` or
/// `foo.__dict__["__annotations__"]` on Python 3.10+ and Python < 3.10 when
/// [typing-extensions](https://docs.astral.sh/ruff/settings/#lint_typing-extensions)
/// is enabled.
///
/// ## Why is this bad?
/// Starting with Python 3.14, directly accessing `__annotations__` via
/// `foo.__dict__.get("__annotations__")` or `foo.__dict__["__annotations__"]`
/// will only return annotations if the class is defined under
/// `from __future__ import annotations`.
///
/// Therefore, it is better to use dedicated library functions like
/// `annotationlib.get_annotations` (Python 3.14+), `inspect.get_annotations`
/// (Python 3.10+), or `typing_extensions.get_annotations` (for Python < 3.10 if
/// [typing-extensions](https://pypi.org/project/typing-extensions/) is
/// available).
///
/// The benefits of using these functions include:
/// 1. **Avoiding Undocumented Internals:** They provide a stable, public API,
/// unlike direct `__dict__` access which relies on implementation details.
/// 2. **Forward-Compatibility:** They are designed to handle changes in
/// Python's annotation system across versions, ensuring your code remains
/// robust (e.g., correctly handling the Python 3.14 behavior mentioned
/// above).
///
/// See [Python Annotations Best Practices](https://docs.python.org/3.14/howto/annotations.html)
/// for alternatives.
///
/// ## Example
///
/// ```python
/// foo.__dict__.get("__annotations__", {})
/// # or
/// foo.__dict__["__annotations__"]
/// ```
///
/// On Python 3.14+, use instead:
/// ```python
/// import annotationlib
///
/// annotationlib.get_annotations(foo)
/// ```
///
/// On Python 3.10+, use instead:
/// ```python
/// import inspect
///
/// inspect.get_annotations(foo)
/// ```
///
/// On Python < 3.10 with [typing-extensions](https://pypi.org/project/typing-extensions/)
/// installed, use instead:
/// ```python
/// import typing_extensions
///
/// typing_extensions.get_annotations(foo)
/// ```
///
/// ## Fix safety
///
/// No autofix is currently provided for this rule.
///
/// ## Fix availability
///
/// No autofix is currently provided for this rule.
///
/// ## References
/// - [Python Annotations Best Practices](https://docs.python.org/3.14/howto/annotations.html)
#[derive(ViolationMetadata)]
pub(crate) struct AccessAnnotationsFromClassDict {
python_version: PythonVersion,
}
impl Violation for AccessAnnotationsFromClassDict {
const FIX_AVAILABILITY: FixAvailability = FixAvailability::None;
#[derive_message_formats]
fn message(&self) -> String {
let suggestion = if self.python_version >= PythonVersion::PY314 {
"annotationlib.get_annotations"
} else if self.python_version >= PythonVersion::PY310 {
"inspect.get_annotations"
} else {
"typing_extensions.get_annotations"
};
format!("Use `{suggestion}` instead of `__dict__` access")
}
}
/// RUF063
pub(crate) fn access_annotations_from_class_dict_with_get(checker: &Checker, call: &ExprCall) {
let python_version = checker.target_version();
let typing_extensions = checker.settings.typing_extensions;
// Only apply this rule for Python 3.10 and newer unless `typing-extensions` is enabled.
if python_version < PythonVersion::PY310 && !typing_extensions {
return;
}
// Expected pattern: foo.__dict__.get("__annotations__" [, <default>])
// Here, `call` is the `.get(...)` part.
// 1. Check that the `call.func` is `get`
let get_attribute = match call.func.as_ref() {
Expr::Attribute(attr) if attr.attr.as_str() == "get" => attr,
_ => return,
};
// 2. Check that the `get_attribute.value` is `__dict__`
match get_attribute.value.as_ref() {
Expr::Attribute(attr) if attr.attr.as_str() == "__dict__" => {}
_ => return,
}
// At this point, we have `foo.__dict__.get`.
// 3. Check arguments to `.get()`:
// - No keyword arguments.
// - One or two positional arguments.
// - First positional argument must be the string literal "__annotations__".
// - The value of the second positional argument (if present) does not affect the match.
if !call.arguments.keywords.is_empty() || call.arguments.len() > 2 {
return;
}
let Some(first_arg) = &call.arguments.find_positional(0) else {
return;
};
let is_first_arg_correct = first_arg
.as_string_literal_expr()
.is_some_and(|s| s.value.to_str() == "__annotations__");
if is_first_arg_correct {
checker.report_diagnostic(
AccessAnnotationsFromClassDict { python_version },
call.range(),
);
}
}
/// RUF063
pub(crate) fn access_annotations_from_class_dict_by_key(
checker: &Checker,
subscript: &ExprSubscript,
) {
let python_version = checker.target_version();
let typing_extensions = checker.settings.typing_extensions;
// Only apply this rule for Python 3.10 and newer unless `typing-extensions` is enabled.
if python_version < PythonVersion::PY310 && !typing_extensions {
return;
}
// Expected pattern: foo.__dict__["__annotations__"]
// 1. Check that the slice is a string literal "__annotations__"
if subscript
.slice
.as_string_literal_expr()
.is_none_or(|s| s.value.to_str() != "__annotations__")
{
return;
}
// 2. Check that the `subscript.value` is `__dict__`
let is_value_correct = subscript
.value
.as_attribute_expr()
.is_some_and(|attr| attr.attr.as_str() == "__dict__");
if is_value_correct {
checker.report_diagnostic(
AccessAnnotationsFromClassDict { python_version },
subscript.range(),
);
}
}

View file

@ -1,3 +1,4 @@
pub(crate) use access_annotations_from_class_dict::*;
pub(crate) use ambiguous_unicode_character::*;
pub(crate) use assert_with_print_message::*;
pub(crate) use assignment_in_assert::*;
@ -59,6 +60,7 @@ pub(crate) use used_dummy_variable::*;
pub(crate) use useless_if_else::*;
pub(crate) use zip_instead_of_pairwise::*;
mod access_annotations_from_class_dict;
mod ambiguous_unicode_character;
mod assert_with_print_message;
mod assignment_in_assert;

View file

@ -0,0 +1,40 @@
---
source: crates/ruff_linter/src/rules/ruff/mod.rs
---
RUF063.py:4:1: RUF063 Use `inspect.get_annotations` instead of `__dict__` access
|
2 | # Cases that should trigger the violation
3 |
4 | foo.__dict__.get("__annotations__") # RUF063
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF063
5 | foo.__dict__.get("__annotations__", None) # RUF063
6 | foo.__dict__.get("__annotations__", {}) # RUF063
|
RUF063.py:5:1: RUF063 Use `inspect.get_annotations` instead of `__dict__` access
|
4 | foo.__dict__.get("__annotations__") # RUF063
5 | foo.__dict__.get("__annotations__", None) # RUF063
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF063
6 | foo.__dict__.get("__annotations__", {}) # RUF063
7 | foo.__dict__["__annotations__"] # RUF063
|
RUF063.py:6:1: RUF063 Use `inspect.get_annotations` instead of `__dict__` access
|
4 | foo.__dict__.get("__annotations__") # RUF063
5 | foo.__dict__.get("__annotations__", None) # RUF063
6 | foo.__dict__.get("__annotations__", {}) # RUF063
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF063
7 | foo.__dict__["__annotations__"] # RUF063
|
RUF063.py:7:1: RUF063 Use `inspect.get_annotations` instead of `__dict__` access
|
5 | foo.__dict__.get("__annotations__", None) # RUF063
6 | foo.__dict__.get("__annotations__", {}) # RUF063
7 | foo.__dict__["__annotations__"] # RUF063
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF063
8 |
9 | # Cases that should NOT trigger the violation
|

View file

@ -0,0 +1,40 @@
---
source: crates/ruff_linter/src/rules/ruff/mod.rs
---
RUF063.py:4:1: RUF063 Use `annotationlib.get_annotations` instead of `__dict__` access
|
2 | # Cases that should trigger the violation
3 |
4 | foo.__dict__.get("__annotations__") # RUF063
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF063
5 | foo.__dict__.get("__annotations__", None) # RUF063
6 | foo.__dict__.get("__annotations__", {}) # RUF063
|
RUF063.py:5:1: RUF063 Use `annotationlib.get_annotations` instead of `__dict__` access
|
4 | foo.__dict__.get("__annotations__") # RUF063
5 | foo.__dict__.get("__annotations__", None) # RUF063
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF063
6 | foo.__dict__.get("__annotations__", {}) # RUF063
7 | foo.__dict__["__annotations__"] # RUF063
|
RUF063.py:6:1: RUF063 Use `annotationlib.get_annotations` instead of `__dict__` access
|
4 | foo.__dict__.get("__annotations__") # RUF063
5 | foo.__dict__.get("__annotations__", None) # RUF063
6 | foo.__dict__.get("__annotations__", {}) # RUF063
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF063
7 | foo.__dict__["__annotations__"] # RUF063
|
RUF063.py:7:1: RUF063 Use `annotationlib.get_annotations` instead of `__dict__` access
|
5 | foo.__dict__.get("__annotations__", None) # RUF063
6 | foo.__dict__.get("__annotations__", {}) # RUF063
7 | foo.__dict__["__annotations__"] # RUF063
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF063
8 |
9 | # Cases that should NOT trigger the violation
|

View file

@ -0,0 +1,4 @@
---
source: crates/ruff_linter/src/rules/ruff/mod.rs
---

View file

@ -0,0 +1,40 @@
---
source: crates/ruff_linter/src/rules/ruff/mod.rs
---
RUF063.py:4:1: RUF063 Use `typing_extensions.get_annotations` instead of `__dict__` access
|
2 | # Cases that should trigger the violation
3 |
4 | foo.__dict__.get("__annotations__") # RUF063
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF063
5 | foo.__dict__.get("__annotations__", None) # RUF063
6 | foo.__dict__.get("__annotations__", {}) # RUF063
|
RUF063.py:5:1: RUF063 Use `typing_extensions.get_annotations` instead of `__dict__` access
|
4 | foo.__dict__.get("__annotations__") # RUF063
5 | foo.__dict__.get("__annotations__", None) # RUF063
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF063
6 | foo.__dict__.get("__annotations__", {}) # RUF063
7 | foo.__dict__["__annotations__"] # RUF063
|
RUF063.py:6:1: RUF063 Use `typing_extensions.get_annotations` instead of `__dict__` access
|
4 | foo.__dict__.get("__annotations__") # RUF063
5 | foo.__dict__.get("__annotations__", None) # RUF063
6 | foo.__dict__.get("__annotations__", {}) # RUF063
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF063
7 | foo.__dict__["__annotations__"] # RUF063
|
RUF063.py:7:1: RUF063 Use `typing_extensions.get_annotations` instead of `__dict__` access
|
5 | foo.__dict__.get("__annotations__", None) # RUF063
6 | foo.__dict__.get("__annotations__", {}) # RUF063
7 | foo.__dict__["__annotations__"] # RUF063
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF063
8 |
9 | # Cases that should NOT trigger the violation
|