Skip to content

Commit e51e132

Browse files
loganrosenclaudeCopilotntBre
authored
[flake8-async] Implement yield-in-context-manager-in-async-generator (ASYNC119) (#24644)
## Summary Implement ASYNC119 from flake8-async, which detects `yield` inside a context manager (`with` or `async with`) in an async generator function. This pattern is unsafe because the cleanup of the context manager may be delayed until the generator is closed, at which point `await` is no longer allowed. This can lead to resource leaks, `RuntimeError`s from structured concurrency violations, or other bugs. See [PEP 533](https://peps.python.org/pep-0533/) for details. The rule suppresses warnings for functions decorated with: - `@contextlib.asynccontextmanager` - `@pytest.fixture` - `@pytest_asyncio.fixture` These decorators are known to handle async generator cleanup safely. Part of #8451 ## Test plan - [x] Added test fixture with error and OK cases - [x] Snapshot tests pass - [x] `cargo dev generate-all` run - [x] `uvx prek run -a` passes - [x] Tested against a real 500k+ LOC codebase — 56 true positive findings, no false positives --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Brent Westbrook <brentrwestbrook@gmail.com>
1 parent 7c6dcd9 commit e51e132

8 files changed

Lines changed: 325 additions & 0 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import contextlib
2+
from contextlib import asynccontextmanager
3+
4+
import pytest
5+
import pytest_asyncio
6+
7+
8+
# Errors
9+
10+
async def unsafe_yield():
11+
with open(""):
12+
yield # ASYNC119
13+
14+
15+
async def async_with():
16+
async with open(""):
17+
yield # ASYNC119
18+
19+
20+
async def warn_on_each_yield():
21+
with open(""):
22+
yield # ASYNC119
23+
yield # ASYNC119
24+
with open(""):
25+
yield # ASYNC119
26+
yield # ASYNC119
27+
28+
29+
async def yield_in_nested_with():
30+
with open(""):
31+
with open(""):
32+
yield # ASYNC119
33+
34+
35+
# OK
36+
37+
async def yield_not_in_context_manager():
38+
yield
39+
with open(""):
40+
...
41+
yield
42+
43+
44+
async def yield_in_nested_sync_function():
45+
with open(""):
46+
def foo():
47+
yield
48+
49+
50+
async def yield_in_nested_async_function():
51+
with open(""):
52+
async def foo():
53+
yield
54+
55+
56+
async def yield_after_nested_function():
57+
with open(""):
58+
async def foo():
59+
yield
60+
yield # ASYNC119
61+
62+
63+
@asynccontextmanager
64+
async def safe_with_decorator():
65+
with open(""):
66+
yield
67+
68+
69+
@contextlib.asynccontextmanager
70+
async def safe_with_qualified_decorator():
71+
with open(""):
72+
yield
73+
74+
75+
def sync_generator():
76+
with open(""):
77+
yield
78+
79+
80+
@pytest.fixture
81+
async def safe_pytest_fixture():
82+
with open(""):
83+
yield
84+
85+
86+
@pytest_asyncio.fixture
87+
async def safe_pytest_asyncio_fixture():
88+
with open(""):
89+
yield

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1405,6 +1405,9 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) {
14051405
if checker.is_rule_enabled(Rule::YieldInInit) {
14061406
pylint::rules::yield_in_init(checker, expr);
14071407
}
1408+
if checker.is_rule_enabled(Rule::YieldInContextManagerInAsyncGenerator) {
1409+
flake8_async::rules::yield_in_context_manager_in_async_generator(checker, expr);
1410+
}
14081411
}
14091412
Expr::YieldFrom(_) => {
14101413
if checker.is_rule_enabled(Rule::YieldInInit) {

crates/ruff_linter/src/codes.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
340340
(Flake8Async, "110") => rules::flake8_async::rules::AsyncBusyWait,
341341
(Flake8Async, "115") => rules::flake8_async::rules::AsyncZeroSleep,
342342
(Flake8Async, "116") => rules::flake8_async::rules::LongSleepNotForever,
343+
(Flake8Async, "119") => rules::flake8_async::rules::YieldInContextManagerInAsyncGenerator,
343344
(Flake8Async, "210") => rules::flake8_async::rules::BlockingHttpCallInAsyncFunction,
344345
(Flake8Async, "212") => rules::flake8_async::rules::BlockingHttpCallHttpxInAsyncFunction,
345346
(Flake8Async, "220") => rules::flake8_async::rules::CreateSubprocessInAsyncFunction,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ mod tests {
1919
#[test_case(Rule::TrioSyncCall, Path::new("ASYNC105.py"))]
2020
#[test_case(Rule::AsyncFunctionWithTimeout, Path::new("ASYNC109_0.py"))]
2121
#[test_case(Rule::AsyncFunctionWithTimeout, Path::new("ASYNC109_1.py"))]
22+
#[test_case(Rule::YieldInContextManagerInAsyncGenerator, Path::new("ASYNC119.py"))]
2223
#[test_case(Rule::AsyncBusyWait, Path::new("ASYNC110.py"))]
2324
#[test_case(Rule::AsyncZeroSleep, Path::new("ASYNC115.py"))]
2425
#[test_case(Rule::LongSleepNotForever, Path::new("ASYNC116.py"))]

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ pub(crate) use blocking_sleep::*;
1111
pub(crate) use cancel_scope_no_checkpoint::*;
1212
pub(crate) use long_sleep_not_forever::*;
1313
pub(crate) use sync_call::*;
14+
pub(crate) use yield_in_context_manager_in_async_generator::*;
1415

1516
mod async_busy_wait;
1617
mod async_function_with_timeout;
@@ -25,3 +26,4 @@ mod blocking_sleep;
2526
mod cancel_scope_no_checkpoint;
2627
mod long_sleep_not_forever;
2728
mod sync_call;
29+
mod yield_in_context_manager_in_async_generator;
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
use ruff_macros::{ViolationMetadata, derive_message_formats};
2+
use ruff_python_ast::helpers::map_callable;
3+
use ruff_python_ast::{self as ast, Expr, Stmt};
4+
use ruff_python_semantic::ScopeKind;
5+
use ruff_text_size::Ranged;
6+
7+
use crate::Violation;
8+
use crate::checkers::ast::Checker;
9+
10+
/// ## What it does
11+
/// Checks for `yield` inside a context manager in an async generator.
12+
///
13+
/// ## Why is this bad?
14+
/// Yielding inside a context manager in an async generator is unsafe because
15+
/// the cleanup of the context manager may be delayed until the generator is
16+
/// closed, at which point `await` is no longer allowed. This can lead to
17+
/// resource leaks or other bugs.
18+
///
19+
/// For more information, see [PEP 533](https://peps.python.org/pep-0533/).
20+
///
21+
/// If the function is intended to yield only once and act as a context
22+
/// manager, use `@asynccontextmanager`. If it's a true async generator
23+
/// that yields multiple values, consumers should use `contextlib.aclosing` to
24+
/// ensure timely cleanup, or the generator should be refactored to avoid
25+
/// holding context managers open across yields.
26+
///
27+
/// The rule also suppresses diagnostics for functions decorated with
28+
/// `@pytest.fixture` or `@pytest_asyncio.fixture`, as the pytest runner
29+
/// handles generator cleanup automatically.
30+
///
31+
/// ## Example
32+
///
33+
/// The following function yields once inside a context manager, but without
34+
/// `@asynccontextmanager`, cleanup of the connection may be delayed:
35+
/// ```python
36+
/// async def open_connection():
37+
/// async with connect() as conn:
38+
/// yield conn
39+
/// ```
40+
///
41+
/// If the function is intended to yield exactly once (i.e., it's a context
42+
/// manager), add `@asynccontextmanager`:
43+
/// ```python
44+
/// from contextlib import asynccontextmanager
45+
///
46+
///
47+
/// @asynccontextmanager
48+
/// async def open_connection():
49+
/// async with connect() as conn:
50+
/// yield conn
51+
/// ```
52+
///
53+
/// For async generators that yield multiple values, `@asynccontextmanager`
54+
/// is not appropriate. Instead, refactor to avoid holding context managers
55+
/// open across yields, or ensure consumers use `contextlib.aclosing` for timely
56+
/// cleanup.
57+
///
58+
/// ## Known problems
59+
/// Using `contextlib.aclosing` around all call sites of an async generator is a
60+
/// valid way to guarantee timely cleanup, but this rule cannot verify that
61+
/// all callers use `aclosing`. As a result, it may flag generators that
62+
/// are always consumed safely.
63+
///
64+
/// ## References
65+
/// - [PEP 533 – Deterministic cleanup for iterators](https://peps.python.org/pep-0533/)
66+
/// - [`contextlib.aclosing`](https://docs.python.org/3/library/contextlib.html#contextlib.aclosing)
67+
/// - [trio.as_safe_channel](https://trio.readthedocs.io/en/latest/reference-core.html#trio.as_safe_channel)
68+
#[derive(ViolationMetadata)]
69+
#[violation_metadata(preview_since = "NEXT_RUFF_VERSION")]
70+
pub(crate) struct YieldInContextManagerInAsyncGenerator;
71+
72+
impl Violation for YieldInContextManagerInAsyncGenerator {
73+
#[derive_message_formats]
74+
fn message(&self) -> String {
75+
"Yield in context manager in async generator may not trigger cleanup".to_string()
76+
}
77+
78+
fn fix_title(&self) -> Option<String> {
79+
Some("Use `@asynccontextmanager` if appropriate, or refactor".to_string())
80+
}
81+
}
82+
83+
/// ASYNC119
84+
pub(crate) fn yield_in_context_manager_in_async_generator(checker: &Checker, expr: &Expr) {
85+
// Check that the enclosing scope is an async function.
86+
let Some(function_def) = enclosing_async_function(checker) else {
87+
return;
88+
};
89+
90+
// If the function is decorated with `@asynccontextmanager` or `@pytest.fixture`,
91+
// the yield is safe — these decorators handle async generator cleanup properly.
92+
if has_safe_decorator(checker, function_def) {
93+
return;
94+
}
95+
96+
// Walk up the statement hierarchy to check if this yield is inside a `with` block.
97+
// Stop at function/class boundaries, since nested definitions create a new scope.
98+
for stmt in checker.semantic().current_statements() {
99+
match stmt {
100+
Stmt::With(_) => {
101+
checker.report_diagnostic(YieldInContextManagerInAsyncGenerator, expr.range());
102+
return;
103+
}
104+
Stmt::FunctionDef(_) | Stmt::ClassDef(_) => return,
105+
_ => {}
106+
}
107+
}
108+
}
109+
110+
/// Returns the enclosing `async` function definition, if any.
111+
fn enclosing_async_function<'a>(checker: &Checker<'a>) -> Option<&'a ast::StmtFunctionDef> {
112+
for scope in checker.semantic().current_scopes() {
113+
match scope.kind {
114+
ScopeKind::Function(function_def) if function_def.is_async => {
115+
return Some(function_def);
116+
}
117+
// Nested functions, lambdas, and classes break the chain.
118+
ScopeKind::Function(_) | ScopeKind::Lambda(_) | ScopeKind::Class(_) => return None,
119+
_ => {}
120+
}
121+
}
122+
None
123+
}
124+
125+
/// Returns `true` if the function is decorated with `@asynccontextmanager`
126+
/// or `@pytest.fixture`, which are known to handle async generator cleanup
127+
/// safely.
128+
fn has_safe_decorator(checker: &Checker, function_def: &ast::StmtFunctionDef) -> bool {
129+
function_def.decorator_list.iter().any(|decorator| {
130+
checker
131+
.semantic()
132+
.resolve_qualified_name(map_callable(&decorator.expression))
133+
.is_some_and(|qualified_name| {
134+
matches!(
135+
qualified_name.segments(),
136+
["contextlib", "asynccontextmanager"]
137+
| ["pytest" | "pytest_asyncio", "fixture"]
138+
)
139+
})
140+
})
141+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
---
2+
source: crates/ruff_linter/src/rules/flake8_async/mod.rs
3+
---
4+
ASYNC119 Yield in context manager in async generator may not trigger cleanup
5+
--> ASYNC119.py:12:9
6+
|
7+
10 | async def unsafe_yield():
8+
11 | with open(""):
9+
12 | yield # ASYNC119
10+
| ^^^^^
11+
|
12+
help: Use `@asynccontextmanager` if appropriate, or refactor
13+
14+
ASYNC119 Yield in context manager in async generator may not trigger cleanup
15+
--> ASYNC119.py:17:9
16+
|
17+
15 | async def async_with():
18+
16 | async with open(""):
19+
17 | yield # ASYNC119
20+
| ^^^^^
21+
|
22+
help: Use `@asynccontextmanager` if appropriate, or refactor
23+
24+
ASYNC119 Yield in context manager in async generator may not trigger cleanup
25+
--> ASYNC119.py:22:9
26+
|
27+
20 | async def warn_on_each_yield():
28+
21 | with open(""):
29+
22 | yield # ASYNC119
30+
| ^^^^^
31+
23 | yield # ASYNC119
32+
24 | with open(""):
33+
|
34+
help: Use `@asynccontextmanager` if appropriate, or refactor
35+
36+
ASYNC119 Yield in context manager in async generator may not trigger cleanup
37+
--> ASYNC119.py:23:9
38+
|
39+
21 | with open(""):
40+
22 | yield # ASYNC119
41+
23 | yield # ASYNC119
42+
| ^^^^^
43+
24 | with open(""):
44+
25 | yield # ASYNC119
45+
|
46+
help: Use `@asynccontextmanager` if appropriate, or refactor
47+
48+
ASYNC119 Yield in context manager in async generator may not trigger cleanup
49+
--> ASYNC119.py:25:9
50+
|
51+
23 | yield # ASYNC119
52+
24 | with open(""):
53+
25 | yield # ASYNC119
54+
| ^^^^^
55+
26 | yield # ASYNC119
56+
|
57+
help: Use `@asynccontextmanager` if appropriate, or refactor
58+
59+
ASYNC119 Yield in context manager in async generator may not trigger cleanup
60+
--> ASYNC119.py:26:9
61+
|
62+
24 | with open(""):
63+
25 | yield # ASYNC119
64+
26 | yield # ASYNC119
65+
| ^^^^^
66+
|
67+
help: Use `@asynccontextmanager` if appropriate, or refactor
68+
69+
ASYNC119 Yield in context manager in async generator may not trigger cleanup
70+
--> ASYNC119.py:32:13
71+
|
72+
30 | with open(""):
73+
31 | with open(""):
74+
32 | yield # ASYNC119
75+
| ^^^^^
76+
|
77+
help: Use `@asynccontextmanager` if appropriate, or refactor
78+
79+
ASYNC119 Yield in context manager in async generator may not trigger cleanup
80+
--> ASYNC119.py:60:9
81+
|
82+
58 | async def foo():
83+
59 | yield
84+
60 | yield # ASYNC119
85+
| ^^^^^
86+
|
87+
help: Use `@asynccontextmanager` if appropriate, or refactor

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)