Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": null,
"id": "6a70904e-dbfe-441c-99ec-12e6cf57f8ba",
"metadata": {},
"outputs": [],
"source": [
"async def elements(n): yield n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "5412fc2f-76eb-42c0-8db1-b5af6fdc46aa",
"metadata": {},
"outputs": [],
"source": [
"[x async for x in elements(5)] # okay, async at top level"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "dc3c94a7-2e64-42de-9351-260b3f41c3fd",
"metadata": {
"scrolled": true
},
"outputs": [],
"source": [
"[[x async for x in elements(5)] for i in range(5)] # error on 3.10, okay after"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.10.16"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
42 changes: 34 additions & 8 deletions crates/ruff_linter/src/checkers/ast/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ use std::path::Path;
use itertools::Itertools;
use log::debug;
use ruff_python_parser::semantic_errors::{
SemanticSyntaxChecker, SemanticSyntaxContext, SemanticSyntaxError, SemanticSyntaxErrorKind,
Checkpoint, SemanticSyntaxChecker, SemanticSyntaxContext, SemanticSyntaxError,
SemanticSyntaxErrorKind,
};
use rustc_hash::{FxHashMap, FxHashSet};

Expand Down Expand Up @@ -282,7 +283,7 @@ impl<'a> Checker<'a> {
last_stmt_end: TextSize::default(),
docstring_state: DocstringState::default(),
target_version,
semantic_checker: SemanticSyntaxChecker::new(),
semantic_checker: SemanticSyntaxChecker::new(source_type),
semantic_errors: RefCell::default(),
}
}
Expand Down Expand Up @@ -525,10 +526,14 @@ impl<'a> Checker<'a> {
self.target_version
}

fn with_semantic_checker(&mut self, f: impl FnOnce(&mut SemanticSyntaxChecker, &Checker)) {
fn with_semantic_checker(
&mut self,
f: impl FnOnce(&mut SemanticSyntaxChecker, &Checker) -> Checkpoint,
) -> Checkpoint {
let mut checker = std::mem::take(&mut self.semantic_checker);
f(&mut checker, self);
let checkpoint = f(&mut checker, self);
self.semantic_checker = checker;
checkpoint
}
}

Expand Down Expand Up @@ -576,7 +581,8 @@ impl SemanticSyntaxContext for Checker<'_> {
| SemanticSyntaxErrorKind::InvalidExpression(..)
| SemanticSyntaxErrorKind::DuplicateMatchKey(_)
| SemanticSyntaxErrorKind::DuplicateMatchClassAttribute(_)
| SemanticSyntaxErrorKind::InvalidStarExpression => {
| SemanticSyntaxErrorKind::InvalidStarExpression
| SemanticSyntaxErrorKind::AsyncComprehensionOutsideAsyncFunction(_) => {
if self.settings.preview.is_enabled() {
self.semantic_errors.borrow_mut().push(error);
}
Expand All @@ -591,7 +597,13 @@ 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 checkpoint = if stmt.is_function_def_stmt() {
None
} else {
Some(self.with_semantic_checker(|semantic, context| semantic.enter_stmt(stmt, context)))
};
Comment on lines +600 to +606
Copy link
Member

Choose a reason for hiding this comment

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

I don't understand the reasoning for skipping function definitions here. Won't this result in stale in_async_context flags because we don't update the state until after we visited the entire body?

If there's a need for deferred visiting, then I'd prefer to have a enter_deferred_stmt or, better, move the necessary checks into exit_stmt and also pass the statement and context because exit is exactly the hook called after visiting the node's children

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 think I might be missing something here, but my reasoning for this was that the ast::Checker doesn't actually visit the body of the function until visit_deferred_function:

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

Before I added this logic, the function body was being visited twice by the SemanticSyntaxChecker but only once by the ast::Checker and this integration test was failing even though the inline version passed:

async def test(): return [[x async for x in elements(n)] async for n in range(3)]

which pointed to an issue in the visit order because the test visitor is much simpler:

    fn visit_stmt(&mut self, stmt: &'_ Stmt) {
        let checkpoint = self.checker.enter_stmt(stmt, &self.context);
        ruff_python_ast::visitor::walk_stmt(self, stmt);
        self.checker.exit_stmt(checkpoint);
    }

Again I might be misunderstanding, but I don't think an enter_deferred_stmt helps here because we still need this logic in visit_stmt itself to avoid duplicating the visit. Unless you mean hiding this logic inside of SemanticSyntaxChecker::enter_stmt. I guess that could work if I also update the test visitor to defer function bodies like the real ast::Checker.

Copy link
Member

Choose a reason for hiding this comment

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

Oh I see. This seems fragile. I wouldn't be aware of this out-of-order visiting when working on the SemanticSyntaxChecker. We should at least document the constraints in which the enter methods are called. I assumed it would be in semantic visiting order but it seems its in semantic visiting order except for functions which may be deferred (or not, depending on the caller)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, very fragile. I was so confused when the integration test was failing but the inline test was fine, until I realized the function bodies were deferred. I'll work on expanding the docs for this.


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

if let Some(checkpoint) = checkpoint {
self.semantic_checker.exit_stmt(checkpoint);
}
}

fn visit_annotation(&mut self, expr: &'a Expr) {
Expand All @@ -1204,7 +1220,8 @@ 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));
let checkpoint =
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 @@ -1743,6 +1760,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(checkpoint);
}

fn visit_except_handler(&mut self, except_handler: &'a ExceptHandler) {
Expand Down Expand Up @@ -2578,17 +2597,24 @@ 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")
};

let checkpoint = 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(checkpoint);
}
}
self.semantic.restore(snapshot);
Expand Down
128 changes: 127 additions & 1 deletion crates/ruff_linter/src/linter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -777,14 +777,22 @@ mod tests {
use std::path::Path;

use anyhow::Result;
use ruff_python_ast::{PySourceType, PythonVersion};
use ruff_python_codegen::Stylist;
use ruff_python_index::Indexer;
use ruff_python_parser::ParseOptions;
use ruff_python_trivia::textwrap::dedent;
use ruff_text_size::Ranged;
use test_case::test_case;

use ruff_notebook::{Notebook, NotebookError};

use crate::linter::check_path;
use crate::message::Message;
use crate::registry::Rule;
use crate::source_kind::SourceKind;
use crate::test::{assert_notebook_path, test_contents, TestedNotebook};
use crate::{assert_messages, settings};
use crate::{assert_messages, directives, settings, Locator};

/// Construct a path to a Jupyter notebook in the `resources/test/fixtures/jupyter` directory.
fn notebook_path(path: impl AsRef<Path>) -> std::path::PathBuf {
Expand Down Expand Up @@ -934,4 +942,122 @@ mod tests {
}
Ok(())
}

/// Wrapper around `test_contents_syntax_errors` for testing a snippet of code instead of a
/// file.
fn test_snippet_syntax_errors(
contents: &str,
settings: &settings::LinterSettings,
) -> Vec<Message> {
let contents = dedent(contents);
test_contents_syntax_errors(
&SourceKind::Python(contents.to_string()),
Path::new("<filename>"),
settings,
)
}

/// A custom test runner that prints syntax errors in addition to other diagnostics. Adapted
/// from `flakes` in pyflakes/mod.rs.
fn test_contents_syntax_errors(
source_kind: &SourceKind,
path: &Path,
settings: &settings::LinterSettings,
) -> Vec<Message> {
let source_type = PySourceType::from(path);
let options =
ParseOptions::from(source_type).with_target_version(settings.unresolved_target_version);
let parsed = ruff_python_parser::parse_unchecked(source_kind.source_code(), options)
.try_into_module()
.expect("PySourceType always parses into a module");
let locator = Locator::new(source_kind.source_code());
let stylist = Stylist::from_tokens(parsed.tokens(), locator.contents());
let indexer = Indexer::from_tokens(parsed.tokens(), locator.contents());
let directives = directives::extract_directives(
parsed.tokens(),
directives::Flags::from_settings(settings),
&locator,
&indexer,
);
let mut messages = check_path(
path,
None,
&locator,
&stylist,
&indexer,
&directives,
settings,
settings::flags::Noqa::Enabled,
source_kind,
source_type,
&parsed,
settings.unresolved_target_version,
);
messages.sort_by_key(Ranged::start);
messages
}

#[test_case(
"error_on_310",
"async def f(): return [[x async for x in foo(n)] for n in range(3)]",
PythonVersion::PY310
)]
#[test_case(
"okay_on_311",
"async def f(): return [[x async for x in foo(n)] for n in range(3)]",
PythonVersion::PY311
)]
#[test_case(
"okay_on_310",
"async def test(): return [[x async for x in elements(n)] async for n in range(3)]",
PythonVersion::PY310
)]
#[test_case(
"deferred_function_body",
"
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()]
",
PythonVersion::PY310
)]
fn test_async_comprehension_in_sync_comprehension(
name: &str,
contents: &str,
python_version: PythonVersion,
) {
let snapshot = format!("async_comprehension_in_sync_comprehension_{name}_{python_version}");
let messages = test_snippet_syntax_errors(
contents,
&settings::LinterSettings {
rules: settings::rule_table::RuleTable::empty(),
unresolved_target_version: python_version,
preview: settings::types::PreviewMode::Enabled,
..Default::default()
},
);
assert_messages!(snapshot, messages);
}

#[test_case(PythonVersion::PY310)]
#[test_case(PythonVersion::PY311)]
fn test_async_comprehension_notebook(python_version: PythonVersion) -> Result<()> {
let snapshot =
format!("async_comprehension_in_sync_comprehension_notebook_{python_version}");
let path = Path::new("resources/test/fixtures/syntax_errors/async_comprehension.ipynb");
let messages = test_contents_syntax_errors(
&SourceKind::IpyNotebook(Notebook::from_path(path)?),
path,
&settings::LinterSettings {
unresolved_target_version: python_version,
rules: settings::rule_table::RuleTable::empty(),
preview: settings::types::PreviewMode::Enabled,
..Default::default()
},
);
assert_messages!(snapshot, messages);

Ok(())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
source: crates/ruff_linter/src/linter.rs
---

Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
source: crates/ruff_linter/src/linter.rs
---
<filename>:1:27: SyntaxError: cannot use an asynchronous comprehension outside of an asynchronous function on Python 3.10 (syntax was added in 3.11)
|
1 | async def f(): return [[x async for x in foo(n)] for n in range(3)]
| ^^^^^^^^^^^^^^^^^^^^^
|
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
source: crates/ruff_linter/src/linter.rs
---
resources/test/fixtures/syntax_errors/async_comprehension.ipynb:3:5: SyntaxError: cannot use an asynchronous comprehension outside of an asynchronous function on Python 3.10 (syntax was added in 3.11)
|
1 | async def elements(n): yield n
2 | [x async for x in elements(5)] # okay, async at top level
3 | [[x async for x in elements(5)] for i in range(5)] # error on 3.10, okay after
| ^^^^^^^^^^^^^^^^^^^^^^^^^^
|
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
source: crates/ruff_linter/src/linter.rs
---

Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
source: crates/ruff_linter/src/linter.rs
---

Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
source: crates/ruff_linter/src/linter.rs
---

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
Loading