Skip to content

[syntax-errors] Async comprehension in sync comprehension #17177

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 30 commits into from
Apr 8, 2025
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
b15f6bb
[syntax-errors] Async comprehension in sync comprehension
ntBre Apr 2, 2025
336e6a3
in-line `TestContext` into SemanticSyntaxCheckerVisitor
ntBre Apr 2, 2025
0835fa2
refactor some `Default` impls to make clippy happier
ntBre Apr 2, 2025
3785e4b
add test_ok all-async case on 3.10 and track in_async_context
ntBre Apr 2, 2025
76dd5d5
add failing linter test, this should be an error
ntBre Apr 2, 2025
30a56a8
track async status for generator scopes and pass test
ntBre Apr 2, 2025
7462cd6
add 3.11 test, don't reuse command, can't pass target-version twice
ntBre Apr 2, 2025
04c57f0
track async context on SemanticSyntaxChecker, with checkpoint
ntBre Apr 2, 2025
35f9c9c
remove apparently unnecessary checkpointing code
ntBre Apr 2, 2025
50e0bc8
revert other unused context changes
ntBre Apr 2, 2025
f811c1a
revert unnecessary linter tests
ntBre Apr 2, 2025
ffcd250
tidy up and add docs
ntBre Apr 2, 2025
d2127e8
add more test cases, finally find a test_ok that needs checkpoints
ntBre Apr 2, 2025
5b440de
restore checkpoints, convert to stack to pass test
ntBre Apr 2, 2025
a96ae02
restore linter tests
ntBre Apr 2, 2025
d49c50c
add a test case for exit_stmt and some commentary
ntBre Apr 2, 2025
624f671
wire up exit methods and add test, but earlier test is failing
ntBre Apr 2, 2025
a21be7e
isolate the inline test that fails in the linter
ntBre Apr 2, 2025
bf69ef9
defer visits to function bodies
ntBre Apr 3, 2025
cc42a38
rename visit_{stmt,expr} to enter_{stmt,expr} and add docs
ntBre Apr 3, 2025
d1e1cc4
fix doc links and mention `exit_*` methods
ntBre Apr 3, 2025
7f99f5f
Merge branch 'main' into brent/syn-async-comprehensions
ntBre Apr 3, 2025
0f95773
return early on recent python version
ntBre Apr 3, 2025
48f1e52
use the call stack instead of Vec<Checkpoint> stack
ntBre Apr 3, 2025
970fe3d
Merge branch 'main' into brent/syn-async-comprehensions
ntBre Apr 3, 2025
7d3b4f6
add `must_use` and some docs
ntBre Apr 7, 2025
2dd610f
pass source_type to SemanticSyntaxChecker
ntBre Apr 7, 2025
6db55c6
add notebook CLI test
ntBre Apr 7, 2025
f730e85
move non-notebook tests to ruff_linter
ntBre Apr 8, 2025
83d3a70
refactor check_syntax_errors to handle notebooks, move notebook test
ntBre Apr 8, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions crates/ruff/tests/lint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5564,3 +5564,90 @@ fn semantic_syntax_errors() -> Result<()> {

Ok(())
}

#[test]
fn async_comprehension_in_sync_comprehension() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not quiet clear to me why we need those cli tests. Could some of those tests be parser tests instead? The parser tests already support # parse_options: {"target-version": "3.7"}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably don't need all of these, but the third and fourth helped to detect bugs in the enter/exit pairs and the deferred function body traversal. The first two are probably less valuable, if you want me to trim it down a bit.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be great if we could narrow them down and maybe document why it's important that they're CLI tests.

An alternative would be to implement them as custom tests in ruff_linter (that instantiate checker). Maybe there's something you can reuse from the linter test infra?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I adapted the pyflakes test runner (as suggested on Discord) and moved all of the CLI tests to ruff_linter. They're just in the top-level linter::tests module for now, but I'm happy to move them around if there's a better place. I also considered nesting them under rules/syntax_errors, for example.

Copy link
Member

@MichaReiser MichaReiser Apr 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think having them in linter::tests is fine. They are integration tests after all

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good, thanks! I'll merge then

let contents = "async def f(): return [[x async for x in foo(n)] for n in range(3)]";

// error on 3.10
assert_cmd_snapshot!(
Command::new(get_cargo_bin(BIN_NAME))
.args(STDIN_BASE_OPTIONS)
.args(["--config", "lint.select = []"])
.args(["--target-version", "py310"])
.arg("--preview")
.arg("-")
.pass_stdin(contents),
@r"
success: false
exit_code: 1
----- stdout -----
-:1:27: SyntaxError: cannot use an asynchronous comprehension outside of an asynchronous function on Python 3.10 (syntax was added in 3.11)
Found 1 error.

----- stderr -----
");

// okay on 3.11
assert_cmd_snapshot!(
Command::new(get_cargo_bin(BIN_NAME))
.args(STDIN_BASE_OPTIONS)
.args(["--config", "lint.select = []"])
.args(["--target-version", "py311"])
.arg("--preview")
.arg("-")
.pass_stdin(contents),
@r"
success: true
exit_code: 0
----- stdout -----
All checks passed!

----- stderr -----
");

let contents =
"async def test(): return [[x async for x in elements(n)] async for n in range(3)]";

// okay on 3.10 because everything is async
assert_cmd_snapshot!(
Command::new(get_cargo_bin(BIN_NAME))
.args(STDIN_BASE_OPTIONS)
.args(["--config", "lint.select = []"])
.args(["--target-version", "py310"])
.arg("--preview")
.arg("-")
.pass_stdin(contents),
@r"
success: true
exit_code: 0
----- stdout -----
All checks passed!

----- stderr -----
");

// this one will only pass if `SemanticSyntaxChecker::exit_expr` and `exit_stmt` have been wired
// up correctly
assert_cmd_snapshot!(
Command::new(get_cargo_bin(BIN_NAME))
.args(STDIN_BASE_OPTIONS)
.args(["--config", "lint.select = []"])
.args(["--target-version", "py310"])
.arg("--preview")
.arg("-")
.pass_stdin("
async def f(): [x for x in foo()] and [x async for x in foo()]
async def f():
def g(): ...
[x async for x in foo()]
"),
@r"
success: true
exit_code: 0
----- stdout -----
All checks passed!

----- stderr -----
");
}
26 changes: 22 additions & 4 deletions crates/ruff_linter/src/checkers/ast/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -573,7 +573,8 @@ impl SemanticSyntaxContext for Checker<'_> {
| SemanticSyntaxErrorKind::IrrefutableCasePattern(_)
| SemanticSyntaxErrorKind::SingleStarredAssignment
| SemanticSyntaxErrorKind::WriteToDebug(_)
| SemanticSyntaxErrorKind::InvalidStarExpression => {
| SemanticSyntaxErrorKind::InvalidStarExpression
| SemanticSyntaxErrorKind::AsyncComprehensionOutsideAsyncFunction(_) => {
if self.settings.preview.is_enabled() {
self.semantic_errors.borrow_mut().push(error);
}
Expand All @@ -584,7 +585,12 @@ impl SemanticSyntaxContext for Checker<'_> {

impl<'a> Visitor<'a> for Checker<'a> {
fn visit_stmt(&mut self, stmt: &'a Stmt) {
self.with_semantic_checker(|semantic, context| semantic.visit_stmt(stmt, context));
// For functions, defer semantic syntax error checks until the body of the function is
// visited
let is_function_def_stmt = stmt.is_function_def_stmt();
if !is_function_def_stmt {
self.with_semantic_checker(|semantic, context| semantic.enter_stmt(stmt, context));
}

// Step 0: Pre-processing
self.semantic.push_node(stmt);
Expand Down Expand Up @@ -1187,6 +1193,10 @@ impl<'a> Visitor<'a> for Checker<'a> {
self.semantic.flags = flags_snapshot;
self.semantic.pop_node();
self.last_stmt_end = stmt.end();

if !is_function_def_stmt {
self.semantic_checker.exit_stmt();
}
}

fn visit_annotation(&mut self, expr: &'a Expr) {
Expand All @@ -1197,7 +1207,7 @@ impl<'a> Visitor<'a> for Checker<'a> {
}

fn visit_expr(&mut self, expr: &'a Expr) {
self.with_semantic_checker(|semantic, context| semantic.visit_expr(expr, context));
self.with_semantic_checker(|semantic, context| semantic.enter_expr(expr, context));

// Step 0: Pre-processing
if self.source_type.is_stub()
Expand Down Expand Up @@ -1736,6 +1746,8 @@ impl<'a> Visitor<'a> for Checker<'a> {
self.semantic.flags = flags_snapshot;
analyze::expression(expr, self);
self.semantic.pop_node();

self.semantic_checker.exit_expr();
}

fn visit_except_handler(&mut self, except_handler: &'a ExceptHandler) {
Expand Down Expand Up @@ -2571,17 +2583,23 @@ impl<'a> Checker<'a> {
for snapshot in deferred_functions {
self.semantic.restore(snapshot);

let stmt = self.semantic.current_statement();

let Stmt::FunctionDef(ast::StmtFunctionDef {
body, parameters, ..
}) = self.semantic.current_statement()
}) = stmt
else {
unreachable!("Expected Stmt::FunctionDef")
};

self.with_semantic_checker(|semantic, context| semantic.enter_stmt(stmt, context));

self.visit_parameters(parameters);
// Set the docstring state before visiting the function body.
self.docstring_state = DocstringState::Expected(ExpectedDocstringKind::Function);
self.visit_body(body);

self.semantic_checker.exit_stmt();
}
}
self.semantic.restore(snapshot);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# parse_options: {"target-version": "3.10"}
async def f(): return [[x async for x in foo(n)] for n in range(3)] # list
async def g(): return [{x: 1 async for x in foo(n)} for n in range(3)] # dict
async def h(): return [{x async for x in foo(n)} for n in range(3)] # set
async def i(): return [([y async for y in range(1)], [z for z in range(2)]) for x in range(5)]
async def j(): return [([y for y in range(1)], [z async for z in range(2)]) for x in range(5)]
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# parse_options: {"target-version": "3.10"}
async def test(): return [[x async for x in elements(n)] async for n in range(3)]
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# parse_options: {"target-version": "3.10"}
# this case fails if exit_expr doesn't run
async def f():
[_ for n in range(3)]
[_ async for n in range(3)]
# and this fails without exit_stmt
async def f():
def g(): ...
[_ async for n in range(3)]
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# parse_options: {"target-version": "3.11"}
async def f(): return [[x async for x in foo(n)] for n in range(3)] # list
async def g(): return [{x: 1 async for x in foo(n)} for n in range(3)] # dict
async def h(): return [{x async for x in foo(n)} for n in range(3)] # set
Loading