From 3db7a15a44eca27e28ec67a48a690c1d70b7600d Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Mon, 11 May 2026 08:09:53 -0700 Subject: [PATCH 1/7] Add full PEP 798 support Fixes #25098. This adds full support for PEP 798 through the parser, linter, formatter, and type checker. There are a lot of changes but they are mostly straightforward. --- .../fixtures/flake8_bugbear/B035_py315.py | 4 + .../src/checkers/ast/analyze/expression.rs | 4 +- crates/ruff_linter/src/checkers/ast/mod.rs | 4 +- .../rules/runtime_value_in_dag_or_task.rs | 4 +- .../src/rules/flake8_bugbear/mod.rs | 5 + .../rules/reuse_of_groupby_generator.rs | 4 +- .../rules/static_key_dict_comprehension.rs | 10 +- ...bear__tests__B035_py315_B035_py315.py.snap | 4 + ...cessary_dict_comprehension_for_iterable.rs | 6 +- .../ruff/rules/mutable_fromkeys_value.rs | 2 +- crates/ruff_python_ast/ast.toml | 2 +- crates/ruff_python_ast/src/comparable.rs | 4 +- crates/ruff_python_ast/src/generated.rs | 8 +- crates/ruff_python_ast/src/helpers.rs | 3 +- crates/ruff_python_ast/src/visitor.rs | 4 +- .../src/visitor/transformer.rs | 4 +- crates/ruff_python_codegen/src/generator.rs | 8 +- .../ruff/expression/list_comp_py315.py | 19 + .../src/expression/expr_dict_comp.rs | 17 +- ...format@expression__list_comp_py315.py.snap | 35 ++ .../pep_798_unpacking_comprehensions_py314.py | 6 + .../pep_798_unpacking_comprehensions_py315.py | 10 + .../expressions/parenthesized/generator.py | 3 +- .../invalid/expressions/set/comprehension.py | 3 - crates/ruff_python_parser/src/error.rs | 30 ++ .../src/parser/expression.rs | 55 +- .../ruff_python_parser/src/semantic_errors.rs | 4 +- crates/ruff_python_parser/tests/fixtures.rs | 4 +- ...ax@expressions__arguments__starred.py.snap | 8 +- ...x@expressions__dict__comprehension.py.snap | 192 ++++--- ...ressions__parenthesized__generator.py.snap | 86 +--- ...ax@expressions__set__comprehension.py.snap | 366 ++++++------- ...x@nested_async_comprehension_py310.py.snap | 16 +- ...798_unpacking_comprehensions_py314.py.snap | 334 ++++++++++++ ...tax@rebound_comprehension_variable.py.snap | 62 +-- ...ements__invalid_assignment_targets.py.snap | 16 +- ...nvalid_augmented_assignment_target.py.snap | 16 +- ...s__with__ambiguous_lpar_with_items.py.snap | 22 +- ...lid_syntax@expressions__dictionary.py.snap | 58 ++- ...ressions__dictionary_comprehension.py.snap | 224 ++++---- ...x@nested_async_comprehension_py311.py.snap | 16 +- ...798_unpacking_comprehensions_py315.py.snap | 487 ++++++++++++++++++ crates/ty_python_core/src/builder.rs | 4 +- .../resources/mdtest/comprehensions/basic.md | 28 +- .../collections/generator_expressions.md | 17 + .../src/types/infer/builder.rs | 41 +- 46 files changed, 1636 insertions(+), 623 deletions(-) create mode 100644 crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B035_py315.py create mode 100644 crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B035_py315_B035_py315.py.snap create mode 100644 crates/ruff_python_parser/resources/inline/err/pep_798_unpacking_comprehensions_py314.py create mode 100644 crates/ruff_python_parser/resources/inline/ok/pep_798_unpacking_comprehensions_py315.py create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@pep_798_unpacking_comprehensions_py314.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/valid_syntax@pep_798_unpacking_comprehensions_py315.py.snap diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B035_py315.py b/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B035_py315.py new file mode 100644 index 00000000000000..a551aa25f59d40 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B035_py315.py @@ -0,0 +1,4 @@ +dicts = [{"key": "value"}] + +# OK +{**dictionary for dictionary in dicts} diff --git a/crates/ruff_linter/src/checkers/ast/analyze/expression.rs b/crates/ruff_linter/src/checkers/ast/analyze/expression.rs index 20519f6ac2eb23..ea9397cc6663df 100644 --- a/crates/ruff_linter/src/checkers/ast/analyze/expression.rs +++ b/crates/ruff_linter/src/checkers/ast/analyze/expression.rs @@ -1863,7 +1863,9 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) { pylint::rules::unnecessary_dict_index_lookup_comprehension(checker, expr); } - if checker.is_rule_enabled(Rule::UnnecessaryComprehension) { + if checker.is_rule_enabled(Rule::UnnecessaryComprehension) + && let Some(key) = key + { flake8_comprehensions::rules::unnecessary_dict_comprehension( checker, expr, key, value, generators, ); diff --git a/crates/ruff_linter/src/checkers/ast/mod.rs b/crates/ruff_linter/src/checkers/ast/mod.rs index 3c091306ba7ba0..1f5f0371399311 100644 --- a/crates/ruff_linter/src/checkers/ast/mod.rs +++ b/crates/ruff_linter/src/checkers/ast/mod.rs @@ -1769,7 +1769,9 @@ impl<'a> Visitor<'a> for Checker<'a> { node_index: _, }) => { self.visit_generators(GeneratorKind::DictComprehension, generators); - self.visit_expr(key); + if let Some(key) = key { + self.visit_expr(key); + } self.visit_expr(value); } Expr::Lambda( diff --git a/crates/ruff_linter/src/rules/airflow/rules/runtime_value_in_dag_or_task.rs b/crates/ruff_linter/src/rules/airflow/rules/runtime_value_in_dag_or_task.rs index 56e0f2a96c33b9..d99a1d09f3b730 100644 --- a/crates/ruff_linter/src/rules/airflow/rules/runtime_value_in_dag_or_task.rs +++ b/crates/ruff_linter/src/rules/airflow/rules/runtime_value_in_dag_or_task.rs @@ -204,7 +204,9 @@ fn find_runtime_varying_call<'a>( value, generators, .. - }) => find_runtime_varying_call(key, semantic) + }) => key + .as_deref() + .and_then(|key| find_runtime_varying_call(key, semantic)) .or_else(|| find_runtime_varying_call(value, semantic)) .or_else(|| { generators.iter().find_map(|generator| { diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/mod.rs b/crates/ruff_linter/src/rules/flake8_bugbear/mod.rs index 146a98ea13b511..b2d1d8fb593941 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/mod.rs @@ -125,6 +125,11 @@ mod tests { Path::new("B912.py"), PythonVersion::PY313 )] + #[test_case( + Rule::StaticKeyDictComprehension, + Path::new("B035_py315.py"), + PythonVersion::PY315 + )] fn rules_with_target_version( rule_code: Rule, path: &Path, diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/reuse_of_groupby_generator.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/reuse_of_groupby_generator.rs index 6e88ccca40da78..710785b509959f 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/reuse_of_groupby_generator.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/reuse_of_groupby_generator.rs @@ -286,7 +286,9 @@ impl<'a> Visitor<'a> for GroupNameFinder<'a> { } if !self.overridden { self.nested = true; - visitor::walk_expr(self, key); + if let Some(key) = key { + visitor::walk_expr(self, key); + } visitor::walk_expr(self, value); self.nested = false; } diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/static_key_dict_comprehension.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/static_key_dict_comprehension.rs index 784782052595b1..3e762874dad062 100644 --- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/static_key_dict_comprehension.rs +++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/static_key_dict_comprehension.rs @@ -49,6 +49,10 @@ impl Violation for StaticKeyDictComprehension { /// B035, RUF011 pub(crate) fn static_key_dict_comprehension(checker: &Checker, dict_comp: &ast::ExprDictComp) { + let Some(key) = dict_comp.key.as_deref() else { + return; + }; + // Collect the bound names in the comprehension's generators. let names = { let mut visitor = StoredNameFinder::default(); @@ -58,12 +62,12 @@ pub(crate) fn static_key_dict_comprehension(checker: &Checker, dict_comp: &ast:: visitor.names }; - if is_constant(&dict_comp.key, &names) { + if is_constant(key, &names) { checker.report_diagnostic( StaticKeyDictComprehension { - key: SourceCodeSnippet::from_str(checker.locator().slice(dict_comp.key.as_ref())), + key: SourceCodeSnippet::from_str(checker.locator().slice(key)), }, - dict_comp.key.range(), + key.range(), ); } } diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B035_py315_B035_py315.py.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B035_py315_B035_py315.py.snap new file mode 100644 index 00000000000000..967e60a4f9e9af --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__B035_py315_B035_py315.py.snap @@ -0,0 +1,4 @@ +--- +source: crates/ruff_linter/src/rules/flake8_bugbear/mod.rs +--- + diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_dict_comprehension_for_iterable.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_dict_comprehension_for_iterable.rs index 2e8448adfee369..67b93818cc6983 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_dict_comprehension_for_iterable.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_dict_comprehension_for_iterable.rs @@ -92,8 +92,12 @@ pub(crate) fn unnecessary_dict_comprehension_for_iterable( return; } + let Some(key) = dict_comp.key.as_deref() else { + return; + }; + // Don't suggest `dict.keys` if the target is not the same as the key. - if ComparableExpr::from(&generator.target) != ComparableExpr::from(dict_comp.key.as_ref()) { + if ComparableExpr::from(&generator.target) != ComparableExpr::from(key) { return; } diff --git a/crates/ruff_linter/src/rules/ruff/rules/mutable_fromkeys_value.rs b/crates/ruff_linter/src/rules/ruff/rules/mutable_fromkeys_value.rs index c6c24a67c8b999..28a285edf43acb 100644 --- a/crates/ruff_linter/src/rules/ruff/rules/mutable_fromkeys_value.rs +++ b/crates/ruff_linter/src/rules/ruff/rules/mutable_fromkeys_value.rs @@ -121,7 +121,7 @@ fn generate_dict_comprehension( }; // Construct the dict comprehension. let dict_comp = ast::ExprDictComp { - key: Box::new(key.into()), + key: Some(Box::new(key.into())), value: Box::new(value.clone()), generators: vec![comp], range: TextRange::default(), diff --git a/crates/ruff_python_ast/ast.toml b/crates/ruff_python_ast/ast.toml index b046d3731ae9e0..44a948da466152 100644 --- a/crates/ruff_python_ast/ast.toml +++ b/crates/ruff_python_ast/ast.toml @@ -383,7 +383,7 @@ fields = [ [Expr.nodes.ExprDictComp] doc = "See also [DictComp](https://docs.python.org/3/library/ast.html#ast.DictComp)" fields = [ - { name = "key", type = "Expr" }, + { name = "key", type = "Expr?" }, { name = "value", type = "Expr" }, { name = "generators", type = "Comprehension*" }, ] diff --git a/crates/ruff_python_ast/src/comparable.rs b/crates/ruff_python_ast/src/comparable.rs index 5fe7d74e3f050d..ced91485fce114 100644 --- a/crates/ruff_python_ast/src/comparable.rs +++ b/crates/ruff_python_ast/src/comparable.rs @@ -913,7 +913,7 @@ pub struct ExprSetComp<'a> { #[derive(Debug, PartialEq, Eq, Hash)] pub struct ExprDictComp<'a> { - key: Box>, + key: Option>>, value: Box>, generators: Vec>, } @@ -1186,7 +1186,7 @@ impl<'a> From<&'a ast::Expr> for ComparableExpr<'a> { range: _, node_index: _, }) => Self::DictComp(ExprDictComp { - key: key.into(), + key: key.as_ref().map(Into::into), value: value.into(), generators: generators.iter().map(Into::into).collect(), }), diff --git a/crates/ruff_python_ast/src/generated.rs b/crates/ruff_python_ast/src/generated.rs index 8af08401889065..e3bf11ca13fd4c 100644 --- a/crates/ruff_python_ast/src/generated.rs +++ b/crates/ruff_python_ast/src/generated.rs @@ -9399,7 +9399,7 @@ pub struct ExprSetComp { pub struct ExprDictComp { pub node_index: crate::AtomicNodeIndex, pub range: ruff_text_size::TextRange, - pub key: Box, + pub key: Option>, pub value: Box, pub generators: Vec, } @@ -10416,7 +10416,11 @@ impl ExprDictComp { range: _, node_index: _, } = self; - visitor.visit_expr(key); + + if let Some(key) = key { + visitor.visit_expr(key); + } + visitor.visit_expr(value); for elm in generators { diff --git a/crates/ruff_python_ast/src/helpers.rs b/crates/ruff_python_ast/src/helpers.rs index 62fcbbaedb898b..19955d1506d238 100644 --- a/crates/ruff_python_ast/src/helpers.rs +++ b/crates/ruff_python_ast/src/helpers.rs @@ -358,7 +358,8 @@ where range: _, node_index: _, }) => { - any_over_expr(key, &mut *func) + key.as_deref() + .is_some_and(|key| any_over_expr(key, &mut *func)) || any_over_expr(value, &mut *func) || generators.iter().any(|generator| { any_over_expr(&generator.target, &mut *func) diff --git a/crates/ruff_python_ast/src/visitor.rs b/crates/ruff_python_ast/src/visitor.rs index 80d025385c70b7..9e90f6be8bce20 100644 --- a/crates/ruff_python_ast/src/visitor.rs +++ b/crates/ruff_python_ast/src/visitor.rs @@ -479,7 +479,9 @@ pub fn walk_expr<'a, V: Visitor<'a> + ?Sized>(visitor: &mut V, expr: &'a Expr) { for comprehension in generators { visitor.visit_comprehension(comprehension); } - visitor.visit_expr(key); + if let Some(key) = key { + visitor.visit_expr(key); + } visitor.visit_expr(value); } Expr::Generator(ast::ExprGenerator { diff --git a/crates/ruff_python_ast/src/visitor/transformer.rs b/crates/ruff_python_ast/src/visitor/transformer.rs index b999ab18d521f5..7a0246403d7a1f 100644 --- a/crates/ruff_python_ast/src/visitor/transformer.rs +++ b/crates/ruff_python_ast/src/visitor/transformer.rs @@ -463,7 +463,9 @@ pub fn walk_expr(visitor: &V, expr: &mut Expr) { for comprehension in generators { visitor.visit_comprehension(comprehension); } - visitor.visit_expr(key); + if let Some(key) = key { + visitor.visit_expr(key); + } visitor.visit_expr(value); } Expr::Generator(ast::ExprGenerator { diff --git a/crates/ruff_python_codegen/src/generator.rs b/crates/ruff_python_codegen/src/generator.rs index ee1ffbd4147a7c..f76aed156e3835 100644 --- a/crates/ruff_python_codegen/src/generator.rs +++ b/crates/ruff_python_codegen/src/generator.rs @@ -1111,8 +1111,12 @@ impl<'a> Generator<'a> { node_index: _, }) => { self.p("{"); - self.unparse_expr(key, precedence::COMPREHENSION_ELEMENT); - self.p(": "); + if let Some(key) = key { + self.unparse_expr(key, precedence::COMPREHENSION_ELEMENT); + self.p(": "); + } else { + self.p("**"); + } self.unparse_expr(value, precedence::COMPREHENSION_ELEMENT); self.unparse_comp(generators); self.p("}"); diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/list_comp_py315.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/list_comp_py315.py index e47f7bd45dd2ef..b9dd977d309121 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/list_comp_py315.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/list_comp_py315.py @@ -10,3 +10,22 @@ *values for values in some_really_long_collection_name_that_should_force_wrapping ] + +{*x for x in y} + +{ + * # comment between * and x + x + for x in y +} + +{**d for d in dicts} + +{ + **d + for d in dicts +} + +(*x for x in y) + +f(*x for x in y) diff --git a/crates/ruff_python_formatter/src/expression/expr_dict_comp.rs b/crates/ruff_python_formatter/src/expression/expr_dict_comp.rs index 91eb3183633abf..69f49469c992ee 100644 --- a/crates/ruff_python_formatter/src/expression/expr_dict_comp.rs +++ b/crates/ruff_python_formatter/src/expression/expr_dict_comp.rs @@ -32,20 +32,25 @@ impl FormatNodeRule for FormatExprDictComp { // for (x, y) in z // } // ``` - let (open_parenthesis_comments, key_value_comments) = - dangling.split_at(dangling.partition_point(|comment| comment.end() < key.start())); + let first_expression = key.as_deref().unwrap_or(value); + let (open_parenthesis_comments, key_value_comments) = dangling + .split_at(dangling.partition_point(|comment| comment.end() < first_expression.start())); write!( f, [parenthesized( "{", &group(&format_with(|f| { - write!(f, [group(&key.format()), token(":")])?; + if let Some(key) = key { + write!(f, [group(&key.format()), token(":")])?; - if key_value_comments.is_empty() { - space().fmt(f)?; + if key_value_comments.is_empty() { + space().fmt(f)?; + } else { + dangling_comments(key_value_comments).fmt(f)?; + } } else { - dangling_comments(key_value_comments).fmt(f)?; + write!(f, [token("**")])?; } write!(f, [value.format(), soft_line_break_or_space()])?; diff --git a/crates/ruff_python_formatter/tests/snapshots/format@expression__list_comp_py315.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@expression__list_comp_py315.py.snap index dfc59585aaeb02..def41860b48c3e 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@expression__list_comp_py315.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@expression__list_comp_py315.py.snap @@ -16,6 +16,25 @@ input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/expression *values for values in some_really_long_collection_name_that_should_force_wrapping ] + +{*x for x in y} + +{ + * # comment between * and x + x + for x in y +} + +{**d for d in dicts} + +{ + **d + for d in dicts +} + +(*x for x in y) + +f(*x for x in y) ``` ## Outputs @@ -45,4 +64,20 @@ nested-string-quote-style = alternating ] [*values for values in some_really_long_collection_name_that_should_force_wrapping] + +{*x for x in y} + +{ + # comment between * and x + *x + for x in y +} + +{**d for d in dicts} + +{**d for d in dicts} + +(*x for x in y) + +f(*x for x in y) ``` diff --git a/crates/ruff_python_parser/resources/inline/err/pep_798_unpacking_comprehensions_py314.py b/crates/ruff_python_parser/resources/inline/err/pep_798_unpacking_comprehensions_py314.py new file mode 100644 index 00000000000000..67d2a8ded3563b --- /dev/null +++ b/crates/ruff_python_parser/resources/inline/err/pep_798_unpacking_comprehensions_py314.py @@ -0,0 +1,6 @@ +# parse_options: {"target-version": "3.14"} +[*x for x in y] +{*x for x in y} +{**x for x in y} +(*x for x in y) +f(*x for x in y) diff --git a/crates/ruff_python_parser/resources/inline/ok/pep_798_unpacking_comprehensions_py315.py b/crates/ruff_python_parser/resources/inline/ok/pep_798_unpacking_comprehensions_py315.py new file mode 100644 index 00000000000000..5195ab8050848d --- /dev/null +++ b/crates/ruff_python_parser/resources/inline/ok/pep_798_unpacking_comprehensions_py315.py @@ -0,0 +1,10 @@ +# parse_options: {"target-version": "3.15"} +[*x for x in y] +{*x for x in y} +{**x for x in y} +(*x for x in y) +f(*x for x in y) +[*x async for x in y] +{*x async for x in y} +{**x async for x in y} +(*x async for x in y) diff --git a/crates/ruff_python_parser/resources/invalid/expressions/parenthesized/generator.py b/crates/ruff_python_parser/resources/invalid/expressions/parenthesized/generator.py index 3664e5069c1a52..f280c7a224d4c6 100644 --- a/crates/ruff_python_parser/resources/invalid/expressions/parenthesized/generator.py +++ b/crates/ruff_python_parser/resources/invalid/expressions/parenthesized/generator.py @@ -1,2 +1 @@ -(*x for x in y) -(x := 1, for x in y) \ No newline at end of file +(x := 1, for x in y) diff --git a/crates/ruff_python_parser/resources/invalid/expressions/set/comprehension.py b/crates/ruff_python_parser/resources/invalid/expressions/set/comprehension.py index 3ee17a02ada94e..a3a53b758c6f98 100644 --- a/crates/ruff_python_parser/resources/invalid/expressions/set/comprehension.py +++ b/crates/ruff_python_parser/resources/invalid/expressions/set/comprehension.py @@ -1,6 +1,3 @@ -# Iterable unpacking not allowed -{*x for x in y} - # Invalid target {x for 1 in y} {x for 'a' in y} diff --git a/crates/ruff_python_parser/src/error.rs b/crates/ruff_python_parser/src/error.rs index e2353816aa0180..1ff1236d634a17 100644 --- a/crates/ruff_python_parser/src/error.rs +++ b/crates/ruff_python_parser/src/error.rs @@ -865,6 +865,18 @@ pub enum UnsupportedSyntaxErrorKind { /// [PEP 798]: https://peps.python.org/pep-0798/ IterableUnpackingInListComprehension, + /// Represents the use of iterable unpacking inside a set comprehension + /// before Python 3.15. + IterableUnpackingInSetComprehension, + + /// Represents the use of iterable unpacking inside a generator expression + /// before Python 3.15. + IterableUnpackingInGeneratorExpression, + + /// Represents the use of dictionary unpacking inside a dict comprehension + /// before Python 3.15. + DictUnpackingInDictComprehension, + /// Represents the use of tuple unpacking in a `for` statement iterator clause before Python /// 3.9. /// @@ -1013,6 +1025,15 @@ impl Display for UnsupportedSyntaxError { UnsupportedSyntaxErrorKind::IterableUnpackingInListComprehension => { "Cannot use iterable unpacking in a list comprehension" } + UnsupportedSyntaxErrorKind::IterableUnpackingInSetComprehension => { + "Cannot use iterable unpacking in a set comprehension" + } + UnsupportedSyntaxErrorKind::IterableUnpackingInGeneratorExpression => { + "Cannot use iterable unpacking in a generator expression" + } + UnsupportedSyntaxErrorKind::DictUnpackingInDictComprehension => { + "Cannot use dictionary unpacking in a dict comprehension" + } UnsupportedSyntaxErrorKind::UnparenthesizedUnpackInFor => { "Cannot use iterable unpacking in `for` statements" } @@ -1088,6 +1109,15 @@ impl UnsupportedSyntaxErrorKind { UnsupportedSyntaxErrorKind::IterableUnpackingInListComprehension => { Change::Added(PythonVersion::PY315) } + UnsupportedSyntaxErrorKind::IterableUnpackingInSetComprehension => { + Change::Added(PythonVersion::PY315) + } + UnsupportedSyntaxErrorKind::IterableUnpackingInGeneratorExpression => { + Change::Added(PythonVersion::PY315) + } + UnsupportedSyntaxErrorKind::DictUnpackingInDictComprehension => { + Change::Added(PythonVersion::PY315) + } UnsupportedSyntaxErrorKind::UnparenthesizedUnpackInFor => { Change::Added(PythonVersion::PY39) } diff --git a/crates/ruff_python_parser/src/parser/expression.rs b/crates/ruff_python_parser/src/parser/expression.rs index 3e1a7ccab4a13b..c8238b9edd9d53 100644 --- a/crates/ruff_python_parser/src/parser/expression.rs +++ b/crates/ruff_python_parser/src/parser/expression.rs @@ -730,9 +730,9 @@ impl<'src> Parser<'src> { match parser.current_token_kind() { TokenKind::Async | TokenKind::For => { if parsed_expr.is_unparenthesized_starred_expr() { - parser.add_error( - ParseErrorType::IterableUnpackingInComprehension, - &parsed_expr, + parser.add_unsupported_syntax_error( + UnsupportedSyntaxErrorKind::IterableUnpackingInGeneratorExpression, + parsed_expr.range(), ); } @@ -2058,6 +2058,26 @@ impl<'src> Parser<'src> { /// - /// - fn parse_set_or_dict_like_expression(&mut self) -> Expr { + // test_ok pep_798_unpacking_comprehensions_py315 + // # parse_options: {"target-version": "3.15"} + // [*x for x in y] + // {*x for x in y} + // {**x for x in y} + // (*x for x in y) + // f(*x for x in y) + // [*x async for x in y] + // {*x async for x in y} + // {**x async for x in y} + // (*x async for x in y) + + // test_err pep_798_unpacking_comprehensions_py314 + // # parse_options: {"target-version": "3.14"} + // [*x for x in y] + // {*x for x in y} + // {**x for x in y} + // (*x for x in y) + // f(*x for x in y) + let start = self.node_start(); self.bump(TokenKind::Lbrace); @@ -2083,6 +2103,17 @@ impl<'src> Parser<'src> { // which requires limiting the expression. let value = self.parse_expression_with_bitwise_or_precedence(); + if matches!(self.current_token_kind(), TokenKind::Async | TokenKind::For) { + self.add_unsupported_syntax_error( + UnsupportedSyntaxErrorKind::DictUnpackingInDictComprehension, + TextRange::new(start, value.range().end()), + ); + + return Expr::DictComp( + self.parse_dictionary_comprehension_expression(None, value.expr, start), + ); + } + return Expr::Dict(self.parse_dictionary_expression(None, value.expr, start)); } @@ -2095,9 +2126,9 @@ impl<'src> Parser<'src> { match self.current_token_kind() { TokenKind::Async | TokenKind::For => { if key_or_element.is_unparenthesized_starred_expr() { - self.add_error( - ParseErrorType::IterableUnpackingInComprehension, - &key_or_element, + self.add_unsupported_syntax_error( + UnsupportedSyntaxErrorKind::IterableUnpackingInSetComprehension, + key_or_element.range(), ); } else if key_or_element.is_unparenthesized_named_expr() { // test_ok parenthesized_named_expr_py38 @@ -2145,7 +2176,7 @@ impl<'src> Parser<'src> { if matches!(self.current_token_kind(), TokenKind::Async | TokenKind::For) { Expr::DictComp(self.parse_dictionary_comprehension_expression( - key_or_element.expr, + Some(key_or_element.expr), value.expr, start, )) @@ -2212,9 +2243,9 @@ impl<'src> Parser<'src> { TokenKind::Async | TokenKind::For => { // grammar: `genexp` if parsed_expr.is_unparenthesized_starred_expr() { - self.add_error( - ParseErrorType::IterableUnpackingInComprehension, - &parsed_expr, + self.add_unsupported_syntax_error( + UnsupportedSyntaxErrorKind::IterableUnpackingInGeneratorExpression, + parsed_expr.range(), ); } @@ -2523,7 +2554,7 @@ impl<'src> Parser<'src> { /// See: fn parse_dictionary_comprehension_expression( &mut self, - key: Expr, + key: Option, value: Expr, start: TextSize, ) -> ast::ExprDictComp { @@ -2532,7 +2563,7 @@ impl<'src> Parser<'src> { self.expect(TokenKind::Rbrace); ast::ExprDictComp { - key: Box::new(key), + key: key.map(Box::new), value: Box::new(value), generators, range: self.node_range(start), diff --git a/crates/ruff_python_parser/src/semantic_errors.rs b/crates/ruff_python_parser/src/semantic_errors.rs index 533b64b56d39ac..446ad540b29a6d 100644 --- a/crates/ruff_python_parser/src/semantic_errors.rs +++ b/crates/ruff_python_parser/src/semantic_errors.rs @@ -883,7 +883,9 @@ impl SemanticSyntaxChecker { generators, .. }) => { - Self::check_generator_expr(key, generators, ctx); + if let Some(key) = key { + Self::check_generator_expr(key, generators, ctx); + } Self::check_generator_expr(value, generators, ctx); Self::async_comprehension_in_sync_comprehension(ctx, generators); for generator in generators.iter().filter(|g| g.is_async) { diff --git a/crates/ruff_python_parser/tests/fixtures.rs b/crates/ruff_python_parser/tests/fixtures.rs index 0cf8e0b5069051..0e9352645b0507 100644 --- a/crates/ruff_python_parser/tests/fixtures.rs +++ b/crates/ruff_python_parser/tests/fixtures.rs @@ -747,7 +747,9 @@ impl Visitor<'_> for SemanticSyntaxCheckerVisitor<'_> { self.scopes.push(Scope::Comprehension { is_async: generators.iter().any(|generator| generator.is_async), }); - self.visit_expr(key); + if let Some(key) = key { + self.visit_expr(key); + } self.visit_expr(value); self.scopes.pop().unwrap(); } diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__arguments__starred.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__arguments__starred.py.snap index d4e877372689df..7703db14a26ffa 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__arguments__starred.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__arguments__starred.py.snap @@ -189,8 +189,8 @@ Module( | 1 | call(*data for data in iter) - | ^^^^^ Syntax Error: Iterable unpacking cannot be used in a comprehension 2 | call(*yield x) + | ^^^^^^^ Syntax Error: Yield expression cannot be used here 3 | call(*yield from x) | @@ -198,14 +198,16 @@ Module( | 1 | call(*data for data in iter) 2 | call(*yield x) - | ^^^^^^^ Syntax Error: Yield expression cannot be used here 3 | call(*yield from x) + | ^^^^^^^^^^^^ Syntax Error: Yield expression cannot be used here | +## Unsupported Syntax Errors + | 1 | call(*data for data in iter) + | ^^^^^ Syntax Error: Cannot use iterable unpacking in a generator expression on Python 3.14 (syntax was added in Python 3.15) 2 | call(*yield x) 3 | call(*yield from x) - | ^^^^^^^^^^^^ Syntax Error: Yield expression cannot be used here | diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__dict__comprehension.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__dict__comprehension.py.snap index 6b8ee79f05d205..a0f372cea4987c 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__dict__comprehension.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__dict__comprehension.py.snap @@ -18,13 +18,15 @@ Module( ExprDictComp { node_index: NodeIndex(None), range: 17..34, - key: Name( - ExprName { - node_index: NodeIndex(None), - range: 18..19, - id: Name("x"), - ctx: Load, - }, + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 18..19, + id: Name("x"), + ctx: Load, + }, + ), ), value: Name( ExprName { @@ -71,13 +73,15 @@ Module( ExprDictComp { node_index: NodeIndex(None), range: 35..54, - key: Name( - ExprName { - node_index: NodeIndex(None), - range: 36..37, - id: Name("x"), - ctx: Load, - }, + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 36..37, + id: Name("x"), + ctx: Load, + }, + ), ), value: Name( ExprName { @@ -136,13 +140,15 @@ Module( ExprDictComp { node_index: NodeIndex(None), range: 55..77, - key: Name( - ExprName { - node_index: NodeIndex(None), - range: 56..57, - id: Name("x"), - ctx: Load, - }, + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 56..57, + id: Name("x"), + ctx: Load, + }, + ), ), value: Name( ExprName { @@ -200,13 +206,15 @@ Module( ExprDictComp { node_index: NodeIndex(None), range: 78..100, - key: Name( - ExprName { - node_index: NodeIndex(None), - range: 79..80, - id: Name("x"), - ctx: Load, - }, + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 79..80, + id: Name("x"), + ctx: Load, + }, + ), ), value: Name( ExprName { @@ -268,13 +276,15 @@ Module( ExprDictComp { node_index: NodeIndex(None), range: 117..135, - key: Name( - ExprName { - node_index: NodeIndex(None), - range: 118..119, - id: Name("x"), - ctx: Load, - }, + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 118..119, + id: Name("x"), + ctx: Load, + }, + ), ), value: Name( ExprName { @@ -327,13 +337,15 @@ Module( ExprDictComp { node_index: NodeIndex(None), range: 136..159, - key: Name( - ExprName { - node_index: NodeIndex(None), - range: 137..138, - id: Name("x"), - ctx: Load, - }, + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 137..138, + id: Name("x"), + ctx: Load, + }, + ), ), value: Name( ExprName { @@ -387,13 +399,15 @@ Module( ExprDictComp { node_index: NodeIndex(None), range: 160..188, - key: Name( - ExprName { - node_index: NodeIndex(None), - range: 161..162, - id: Name("x"), - ctx: Load, - }, + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 161..162, + id: Name("x"), + ctx: Load, + }, + ), ), value: Name( ExprName { @@ -445,13 +459,15 @@ Module( ExprDictComp { node_index: NodeIndex(None), range: 189..216, - key: Name( - ExprName { - node_index: NodeIndex(None), - range: 190..191, - id: Name("x"), - ctx: Load, - }, + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 190..191, + id: Name("x"), + ctx: Load, + }, + ), ), value: Name( ExprName { @@ -530,13 +546,15 @@ Module( ExprDictComp { node_index: NodeIndex(None), range: 231..257, - key: Name( - ExprName { - node_index: NodeIndex(None), - range: 232..233, - id: Name("x"), - ctx: Load, - }, + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 232..233, + id: Name("x"), + ctx: Load, + }, + ), ), value: Name( ExprName { @@ -598,13 +616,15 @@ Module( ExprDictComp { node_index: NodeIndex(None), range: 258..289, - key: Name( - ExprName { - node_index: NodeIndex(None), - range: 259..260, - id: Name("x"), - ctx: Load, - }, + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 259..260, + id: Name("x"), + ctx: Load, + }, + ), ), value: Name( ExprName { @@ -667,13 +687,15 @@ Module( ExprDictComp { node_index: NodeIndex(None), range: 290..326, - key: Name( - ExprName { - node_index: NodeIndex(None), - range: 291..292, - id: Name("x"), - ctx: Load, - }, + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 291..292, + id: Name("x"), + ctx: Load, + }, + ), ), value: Name( ExprName { @@ -734,13 +756,15 @@ Module( ExprDictComp { node_index: NodeIndex(None), range: 327..362, - key: Name( - ExprName { - node_index: NodeIndex(None), - range: 328..329, - id: Name("x"), - ctx: Load, - }, + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 328..329, + id: Name("x"), + ctx: Load, + }, + ), ), value: Name( ExprName { diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__parenthesized__generator.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__parenthesized__generator.py.snap index 7ebe57ede27f4e..2c06c02bf47582 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__parenthesized__generator.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__parenthesized__generator.py.snap @@ -8,77 +8,25 @@ input_file: crates/ruff_python_parser/resources/invalid/expressions/parenthesize Module( ModModule { node_index: NodeIndex(None), - range: 0..36, + range: 0..21, body: [ Expr( StmtExpr { node_index: NodeIndex(None), - range: 0..15, - value: Generator( - ExprGenerator { - node_index: NodeIndex(None), - range: 0..15, - elt: Starred( - ExprStarred { - node_index: NodeIndex(None), - range: 1..3, - value: Name( - ExprName { - node_index: NodeIndex(None), - range: 2..3, - id: Name("x"), - ctx: Load, - }, - ), - ctx: Load, - }, - ), - generators: [ - Comprehension { - range: 4..14, - node_index: NodeIndex(None), - target: Name( - ExprName { - node_index: NodeIndex(None), - range: 8..9, - id: Name("x"), - ctx: Store, - }, - ), - iter: Name( - ExprName { - node_index: NodeIndex(None), - range: 13..14, - id: Name("y"), - ctx: Load, - }, - ), - ifs: [], - is_async: false, - }, - ], - parenthesized: true, - }, - ), - }, - ), - Expr( - StmtExpr { - node_index: NodeIndex(None), - range: 16..24, + range: 0..8, value: Tuple( ExprTuple { node_index: NodeIndex(None), - range: 16..24, + range: 0..8, elts: [ Named( ExprNamed { node_index: NodeIndex(None), - range: 17..23, + range: 1..7, target: Name( ExprName { node_index: NodeIndex(None), - range: 17..18, + range: 1..2, id: Name("x"), ctx: Store, }, @@ -86,7 +34,7 @@ Module( value: NumberLiteral( ExprNumberLiteral { node_index: NodeIndex(None), - range: 22..23, + range: 6..7, value: Int( 1, ), @@ -104,12 +52,12 @@ Module( For( StmtFor { node_index: NodeIndex(None), - range: 25..35, + range: 9..19, is_async: false, target: Name( ExprName { node_index: NodeIndex(None), - range: 29..30, + range: 13..14, id: Name("x"), ctx: Store, }, @@ -117,7 +65,7 @@ Module( iter: Name( ExprName { node_index: NodeIndex(None), - range: 34..35, + range: 18..19, id: Name("y"), ctx: Load, }, @@ -133,28 +81,18 @@ Module( ## Errors | -1 | (*x for x in y) - | ^^ Syntax Error: Iterable unpacking cannot be used in a comprehension -2 | (x := 1, for x in y) - | - - - | -1 | (*x for x in y) -2 | (x := 1, for x in y) +1 | (x := 1, for x in y) | ^^^ Syntax Error: Expected `)`, found `for` | | -1 | (*x for x in y) -2 | (x := 1, for x in y) +1 | (x := 1, for x in y) | ^ Syntax Error: Expected `:`, found `)` | | -1 | (*x for x in y) -2 | (x := 1, for x in y) +1 | (x := 1, for x in y) | ^ Syntax Error: Expected a statement | diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__set__comprehension.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__set__comprehension.py.snap index 1ccec2050bec32..22132cd30124a1 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__set__comprehension.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__set__comprehension.py.snap @@ -8,83 +8,32 @@ input_file: crates/ruff_python_parser/resources/invalid/expressions/set/comprehe Module( ModModule { node_index: NodeIndex(None), - range: 0..377, + range: 0..327, body: [ Expr( StmtExpr { node_index: NodeIndex(None), - range: 33..48, + range: 17..31, value: SetComp( ExprSetComp { node_index: NodeIndex(None), - range: 33..48, - elt: Starred( - ExprStarred { - node_index: NodeIndex(None), - range: 34..36, - value: Name( - ExprName { - node_index: NodeIndex(None), - range: 35..36, - id: Name("x"), - ctx: Load, - }, - ), - ctx: Load, - }, - ), - generators: [ - Comprehension { - range: 37..47, - node_index: NodeIndex(None), - target: Name( - ExprName { - node_index: NodeIndex(None), - range: 41..42, - id: Name("x"), - ctx: Store, - }, - ), - iter: Name( - ExprName { - node_index: NodeIndex(None), - range: 46..47, - id: Name("y"), - ctx: Load, - }, - ), - ifs: [], - is_async: false, - }, - ], - }, - ), - }, - ), - Expr( - StmtExpr { - node_index: NodeIndex(None), - range: 67..81, - value: SetComp( - ExprSetComp { - node_index: NodeIndex(None), - range: 67..81, + range: 17..31, elt: Name( ExprName { node_index: NodeIndex(None), - range: 68..69, + range: 18..19, id: Name("x"), ctx: Load, }, ), generators: [ Comprehension { - range: 70..80, + range: 20..30, node_index: NodeIndex(None), target: NumberLiteral( ExprNumberLiteral { node_index: NodeIndex(None), - range: 74..75, + range: 24..25, value: Int( 1, ), @@ -93,7 +42,7 @@ Module( iter: Name( ExprName { node_index: NodeIndex(None), - range: 79..80, + range: 29..30, id: Name("y"), ctx: Load, }, @@ -109,31 +58,31 @@ Module( Expr( StmtExpr { node_index: NodeIndex(None), - range: 82..98, + range: 32..48, value: SetComp( ExprSetComp { node_index: NodeIndex(None), - range: 82..98, + range: 32..48, elt: Name( ExprName { node_index: NodeIndex(None), - range: 83..84, + range: 33..34, id: Name("x"), ctx: Load, }, ), generators: [ Comprehension { - range: 85..97, + range: 35..47, node_index: NodeIndex(None), target: StringLiteral( ExprStringLiteral { node_index: NodeIndex(None), - range: 89..92, + range: 39..42, value: StringLiteralValue { inner: Single( StringLiteral { - range: 89..92, + range: 39..42, node_index: NodeIndex(None), value: "a", flags: StringLiteralFlags { @@ -150,7 +99,7 @@ Module( iter: Name( ExprName { node_index: NodeIndex(None), - range: 96..97, + range: 46..47, id: Name("y"), ctx: Load, }, @@ -166,37 +115,37 @@ Module( Expr( StmtExpr { node_index: NodeIndex(None), - range: 99..118, + range: 49..68, value: SetComp( ExprSetComp { node_index: NodeIndex(None), - range: 99..118, + range: 49..68, elt: Name( ExprName { node_index: NodeIndex(None), - range: 100..101, + range: 50..51, id: Name("x"), ctx: Load, }, ), generators: [ Comprehension { - range: 102..117, + range: 52..67, node_index: NodeIndex(None), target: Call( ExprCall { node_index: NodeIndex(None), - range: 106..112, + range: 56..62, func: Name( ExprName { node_index: NodeIndex(None), - range: 106..110, + range: 56..60, id: Name("call"), ctx: Load, }, ), arguments: Arguments { - range: 110..112, + range: 60..62, node_index: NodeIndex(None), args: [], keywords: [], @@ -206,7 +155,7 @@ Module( iter: Name( ExprName { node_index: NodeIndex(None), - range: 116..117, + range: 66..67, id: Name("y"), ctx: Load, }, @@ -222,32 +171,32 @@ Module( Expr( StmtExpr { node_index: NodeIndex(None), - range: 119..138, + range: 69..88, value: SetComp( ExprSetComp { node_index: NodeIndex(None), - range: 119..138, + range: 69..88, elt: Name( ExprName { node_index: NodeIndex(None), - range: 120..121, + range: 70..71, id: Name("x"), ctx: Load, }, ), generators: [ Comprehension { - range: 122..137, + range: 72..87, node_index: NodeIndex(None), target: Set( ExprSet { node_index: NodeIndex(None), - range: 126..132, + range: 76..82, elts: [ Name( ExprName { node_index: NodeIndex(None), - range: 127..128, + range: 77..78, id: Name("a"), ctx: Load, }, @@ -255,7 +204,7 @@ Module( Name( ExprName { node_index: NodeIndex(None), - range: 130..131, + range: 80..81, id: Name("b"), ctx: Load, }, @@ -266,7 +215,7 @@ Module( iter: Name( ExprName { node_index: NodeIndex(None), - range: 136..137, + range: 86..87, id: Name("y"), ctx: Load, }, @@ -282,27 +231,27 @@ Module( Expr( StmtExpr { node_index: NodeIndex(None), - range: 155..170, + range: 105..120, value: SetComp( ExprSetComp { node_index: NodeIndex(None), - range: 155..170, + range: 105..120, elt: Name( ExprName { node_index: NodeIndex(None), - range: 156..157, + range: 106..107, id: Name("x"), ctx: Load, }, ), generators: [ Comprehension { - range: 158..169, + range: 108..119, node_index: NodeIndex(None), target: Name( ExprName { node_index: NodeIndex(None), - range: 162..163, + range: 112..113, id: Name("x"), ctx: Store, }, @@ -310,11 +259,11 @@ Module( iter: Starred( ExprStarred { node_index: NodeIndex(None), - range: 167..169, + range: 117..119, value: Name( ExprName { node_index: NodeIndex(None), - range: 168..169, + range: 118..119, id: Name("y"), ctx: Load, }, @@ -333,27 +282,27 @@ Module( Expr( StmtExpr { node_index: NodeIndex(None), - range: 171..191, + range: 121..141, value: SetComp( ExprSetComp { node_index: NodeIndex(None), - range: 171..191, + range: 121..141, elt: Name( ExprName { node_index: NodeIndex(None), - range: 172..173, + range: 122..123, id: Name("x"), ctx: Load, }, ), generators: [ Comprehension { - range: 174..190, + range: 124..140, node_index: NodeIndex(None), target: Name( ExprName { node_index: NodeIndex(None), - range: 178..179, + range: 128..129, id: Name("x"), ctx: Store, }, @@ -361,12 +310,12 @@ Module( iter: Yield( ExprYield { node_index: NodeIndex(None), - range: 183..190, + range: 133..140, value: Some( Name( ExprName { node_index: NodeIndex(None), - range: 189..190, + range: 139..140, id: Name("y"), ctx: Load, }, @@ -385,27 +334,27 @@ Module( Expr( StmtExpr { node_index: NodeIndex(None), - range: 192..217, + range: 142..167, value: SetComp( ExprSetComp { node_index: NodeIndex(None), - range: 192..217, + range: 142..167, elt: Name( ExprName { node_index: NodeIndex(None), - range: 193..194, + range: 143..144, id: Name("x"), ctx: Load, }, ), generators: [ Comprehension { - range: 195..216, + range: 145..166, node_index: NodeIndex(None), target: Name( ExprName { node_index: NodeIndex(None), - range: 199..200, + range: 149..150, id: Name("x"), ctx: Store, }, @@ -413,11 +362,11 @@ Module( iter: YieldFrom( ExprYieldFrom { node_index: NodeIndex(None), - range: 204..216, + range: 154..166, value: Name( ExprName { node_index: NodeIndex(None), - range: 215..216, + range: 165..166, id: Name("y"), ctx: Load, }, @@ -435,27 +384,27 @@ Module( Expr( StmtExpr { node_index: NodeIndex(None), - range: 218..242, + range: 168..192, value: SetComp( ExprSetComp { node_index: NodeIndex(None), - range: 218..242, + range: 168..192, elt: Name( ExprName { node_index: NodeIndex(None), - range: 219..220, + range: 169..170, id: Name("x"), ctx: Load, }, ), generators: [ Comprehension { - range: 221..241, + range: 171..191, node_index: NodeIndex(None), target: Name( ExprName { node_index: NodeIndex(None), - range: 225..226, + range: 175..176, id: Name("x"), ctx: Store, }, @@ -463,22 +412,22 @@ Module( iter: Lambda( ExprLambda { node_index: NodeIndex(None), - range: 230..241, + range: 180..191, parameters: Some( Parameters { - range: 237..238, + range: 187..188, node_index: NodeIndex(None), posonlyargs: [], args: [ ParameterWithDefault { - range: 237..238, + range: 187..188, node_index: NodeIndex(None), parameter: Parameter { - range: 237..238, + range: 187..188, node_index: NodeIndex(None), name: Identifier { id: Name("y"), - range: 237..238, + range: 187..188, node_index: NodeIndex(None), }, annotation: None, @@ -494,7 +443,7 @@ Module( body: Name( ExprName { node_index: NodeIndex(None), - range: 240..241, + range: 190..191, id: Name("y"), ctx: Load, }, @@ -512,27 +461,27 @@ Module( Expr( StmtExpr { node_index: NodeIndex(None), - range: 257..280, + range: 207..230, value: SetComp( ExprSetComp { node_index: NodeIndex(None), - range: 257..280, + range: 207..230, elt: Name( ExprName { node_index: NodeIndex(None), - range: 258..259, + range: 208..209, id: Name("x"), ctx: Load, }, ), generators: [ Comprehension { - range: 260..279, + range: 210..229, node_index: NodeIndex(None), target: Name( ExprName { node_index: NodeIndex(None), - range: 264..265, + range: 214..215, id: Name("x"), ctx: Store, }, @@ -540,7 +489,7 @@ Module( iter: Name( ExprName { node_index: NodeIndex(None), - range: 269..273, + range: 219..223, id: Name("data"), ctx: Load, }, @@ -549,11 +498,11 @@ Module( Starred( ExprStarred { node_index: NodeIndex(None), - range: 277..279, + range: 227..229, value: Name( ExprName { node_index: NodeIndex(None), - range: 278..279, + range: 228..229, id: Name("y"), ctx: Load, }, @@ -572,27 +521,27 @@ Module( Expr( StmtExpr { node_index: NodeIndex(None), - range: 281..309, + range: 231..259, value: SetComp( ExprSetComp { node_index: NodeIndex(None), - range: 281..309, + range: 231..259, elt: Name( ExprName { node_index: NodeIndex(None), - range: 282..283, + range: 232..233, id: Name("x"), ctx: Load, }, ), generators: [ Comprehension { - range: 284..308, + range: 234..258, node_index: NodeIndex(None), target: Name( ExprName { node_index: NodeIndex(None), - range: 288..289, + range: 238..239, id: Name("x"), ctx: Store, }, @@ -600,7 +549,7 @@ Module( iter: Name( ExprName { node_index: NodeIndex(None), - range: 293..297, + range: 243..247, id: Name("data"), ctx: Load, }, @@ -609,12 +558,12 @@ Module( Yield( ExprYield { node_index: NodeIndex(None), - range: 301..308, + range: 251..258, value: Some( Name( ExprName { node_index: NodeIndex(None), - range: 307..308, + range: 257..258, id: Name("y"), ctx: Load, }, @@ -633,27 +582,27 @@ Module( Expr( StmtExpr { node_index: NodeIndex(None), - range: 310..343, + range: 260..293, value: SetComp( ExprSetComp { node_index: NodeIndex(None), - range: 310..343, + range: 260..293, elt: Name( ExprName { node_index: NodeIndex(None), - range: 311..312, + range: 261..262, id: Name("x"), ctx: Load, }, ), generators: [ Comprehension { - range: 313..342, + range: 263..292, node_index: NodeIndex(None), target: Name( ExprName { node_index: NodeIndex(None), - range: 317..318, + range: 267..268, id: Name("x"), ctx: Store, }, @@ -661,7 +610,7 @@ Module( iter: Name( ExprName { node_index: NodeIndex(None), - range: 322..326, + range: 272..276, id: Name("data"), ctx: Load, }, @@ -670,11 +619,11 @@ Module( YieldFrom( ExprYieldFrom { node_index: NodeIndex(None), - range: 330..342, + range: 280..292, value: Name( ExprName { node_index: NodeIndex(None), - range: 341..342, + range: 291..292, id: Name("y"), ctx: Load, }, @@ -692,27 +641,27 @@ Module( Expr( StmtExpr { node_index: NodeIndex(None), - range: 344..376, + range: 294..326, value: SetComp( ExprSetComp { node_index: NodeIndex(None), - range: 344..376, + range: 294..326, elt: Name( ExprName { node_index: NodeIndex(None), - range: 345..346, + range: 295..296, id: Name("x"), ctx: Load, }, ), generators: [ Comprehension { - range: 347..375, + range: 297..325, node_index: NodeIndex(None), target: Name( ExprName { node_index: NodeIndex(None), - range: 351..352, + range: 301..302, id: Name("x"), ctx: Store, }, @@ -720,7 +669,7 @@ Module( iter: Name( ExprName { node_index: NodeIndex(None), - range: 356..360, + range: 306..310, id: Name("data"), ctx: Load, }, @@ -729,22 +678,22 @@ Module( Lambda( ExprLambda { node_index: NodeIndex(None), - range: 364..375, + range: 314..325, parameters: Some( Parameters { - range: 371..372, + range: 321..322, node_index: NodeIndex(None), posonlyargs: [], args: [ ParameterWithDefault { - range: 371..372, + range: 321..322, node_index: NodeIndex(None), parameter: Parameter { - range: 371..372, + range: 321..322, node_index: NodeIndex(None), name: Identifier { id: Name("y"), - range: 371..372, + range: 321..322, node_index: NodeIndex(None), }, annotation: None, @@ -760,7 +709,7 @@ Module( body: Name( ExprName { node_index: NodeIndex(None), - range: 374..375, + range: 324..325, id: Name("y"), ctx: Load, }, @@ -782,121 +731,112 @@ Module( ## Errors | -1 | # Iterable unpacking not allowed -2 | {*x for x in y} - | ^^ Syntax Error: Iterable unpacking cannot be used in a comprehension -3 | -4 | # Invalid target - | - - - | -4 | # Invalid target -5 | {x for 1 in y} +1 | # Invalid target +2 | {x for 1 in y} | ^ Syntax Error: Invalid assignment target -6 | {x for 'a' in y} -7 | {x for call() in y} +3 | {x for 'a' in y} +4 | {x for call() in y} | | -4 | # Invalid target -5 | {x for 1 in y} -6 | {x for 'a' in y} +1 | # Invalid target +2 | {x for 1 in y} +3 | {x for 'a' in y} | ^^^ Syntax Error: Invalid assignment target -7 | {x for call() in y} -8 | {x for {a, b} in y} +4 | {x for call() in y} +5 | {x for {a, b} in y} | | -5 | {x for 1 in y} -6 | {x for 'a' in y} -7 | {x for call() in y} +2 | {x for 1 in y} +3 | {x for 'a' in y} +4 | {x for call() in y} | ^^^^^^ Syntax Error: Invalid assignment target -8 | {x for {a, b} in y} +5 | {x for {a, b} in y} | - | - 6 | {x for 'a' in y} - 7 | {x for call() in y} - 8 | {x for {a, b} in y} - | ^^^^^^ Syntax Error: Invalid assignment target - 9 | -10 | # Invalid iter - | + | +3 | {x for 'a' in y} +4 | {x for call() in y} +5 | {x for {a, b} in y} + | ^^^^^^ Syntax Error: Invalid assignment target +6 | +7 | # Invalid iter + | | -10 | # Invalid iter -11 | {x for x in *y} + 7 | # Invalid iter + 8 | {x for x in *y} | ^^ Syntax Error: Starred expression cannot be used here -12 | {x for x in yield y} -13 | {x for x in yield from y} + 9 | {x for x in yield y} +10 | {x for x in yield from y} | | -10 | # Invalid iter -11 | {x for x in *y} -12 | {x for x in yield y} + 7 | # Invalid iter + 8 | {x for x in *y} + 9 | {x for x in yield y} | ^^^^^^^ Syntax Error: Yield expression cannot be used here -13 | {x for x in yield from y} -14 | {x for x in lambda y: y} +10 | {x for x in yield from y} +11 | {x for x in lambda y: y} | | -11 | {x for x in *y} -12 | {x for x in yield y} -13 | {x for x in yield from y} + 8 | {x for x in *y} + 9 | {x for x in yield y} +10 | {x for x in yield from y} | ^^^^^^^^^^^^ Syntax Error: Yield expression cannot be used here -14 | {x for x in lambda y: y} +11 | {x for x in lambda y: y} | | -12 | {x for x in yield y} -13 | {x for x in yield from y} -14 | {x for x in lambda y: y} + 9 | {x for x in yield y} +10 | {x for x in yield from y} +11 | {x for x in lambda y: y} | ^^^^^^^^^^^ Syntax Error: Lambda expression cannot be used here -15 | -16 | # Invalid if +12 | +13 | # Invalid if | | -16 | # Invalid if -17 | {x for x in data if *y} +13 | # Invalid if +14 | {x for x in data if *y} | ^^ Syntax Error: Starred expression cannot be used here -18 | {x for x in data if yield y} -19 | {x for x in data if yield from y} +15 | {x for x in data if yield y} +16 | {x for x in data if yield from y} | | -16 | # Invalid if -17 | {x for x in data if *y} -18 | {x for x in data if yield y} +13 | # Invalid if +14 | {x for x in data if *y} +15 | {x for x in data if yield y} | ^^^^^^^ Syntax Error: Yield expression cannot be used here -19 | {x for x in data if yield from y} -20 | {x for x in data if lambda y: y} +16 | {x for x in data if yield from y} +17 | {x for x in data if lambda y: y} | | -17 | {x for x in data if *y} -18 | {x for x in data if yield y} -19 | {x for x in data if yield from y} +14 | {x for x in data if *y} +15 | {x for x in data if yield y} +16 | {x for x in data if yield from y} | ^^^^^^^^^^^^ Syntax Error: Yield expression cannot be used here -20 | {x for x in data if lambda y: y} +17 | {x for x in data if lambda y: y} | | -18 | {x for x in data if yield y} -19 | {x for x in data if yield from y} -20 | {x for x in data if lambda y: y} +15 | {x for x in data if yield y} +16 | {x for x in data if yield from y} +17 | {x for x in data if lambda y: y} | ^^^^^^^^^^^ Syntax Error: Lambda expression cannot be used here | diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@nested_async_comprehension_py310.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@nested_async_comprehension_py310.py.snap index 310c7d5336f6b3..94435420da820e 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@nested_async_comprehension_py310.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@nested_async_comprehension_py310.py.snap @@ -191,13 +191,15 @@ Module( ExprDictComp { node_index: NodeIndex(None), range: 145..173, - key: Name( - ExprName { - node_index: NodeIndex(None), - range: 146..147, - id: Name("x"), - ctx: Load, - }, + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 146..147, + id: Name("x"), + ctx: Load, + }, + ), ), value: NumberLiteral( ExprNumberLiteral { diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@pep_798_unpacking_comprehensions_py314.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@pep_798_unpacking_comprehensions_py314.py.snap new file mode 100644 index 00000000000000..36a931dfca90d1 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@pep_798_unpacking_comprehensions_py314.py.snap @@ -0,0 +1,334 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/inline/err/pep_798_unpacking_comprehensions_py314.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..126, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 44..59, + value: ListComp( + ExprListComp { + node_index: NodeIndex(None), + range: 44..59, + elt: Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 45..47, + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 46..47, + id: Name("x"), + ctx: Load, + }, + ), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 48..58, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 52..53, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 57..58, + id: Name("y"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + }, + ], + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 60..75, + value: SetComp( + ExprSetComp { + node_index: NodeIndex(None), + range: 60..75, + elt: Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 61..63, + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 62..63, + id: Name("x"), + ctx: Load, + }, + ), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 64..74, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 68..69, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 73..74, + id: Name("y"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + }, + ], + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 76..92, + value: DictComp( + ExprDictComp { + node_index: NodeIndex(None), + range: 76..92, + key: None, + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 79..80, + id: Name("x"), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 81..91, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 85..86, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 90..91, + id: Name("y"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + }, + ], + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 93..108, + value: Generator( + ExprGenerator { + node_index: NodeIndex(None), + range: 93..108, + elt: Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 94..96, + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 95..96, + id: Name("x"), + ctx: Load, + }, + ), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 97..107, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 101..102, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 106..107, + id: Name("y"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + }, + ], + parenthesized: true, + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 109..125, + value: Call( + ExprCall { + node_index: NodeIndex(None), + range: 109..125, + func: Name( + ExprName { + node_index: NodeIndex(None), + range: 109..110, + id: Name("f"), + ctx: Load, + }, + ), + arguments: Arguments { + range: 110..125, + node_index: NodeIndex(None), + args: [ + Generator( + ExprGenerator { + node_index: NodeIndex(None), + range: 111..124, + elt: Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 111..113, + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 112..113, + id: Name("x"), + ctx: Load, + }, + ), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 114..124, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 118..119, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 123..124, + id: Name("y"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + }, + ], + parenthesized: false, + }, + ), + ], + keywords: [], + }, + }, + ), + }, + ), + ], + }, +) +``` +## Unsupported Syntax Errors + + | +1 | # parse_options: {"target-version": "3.14"} +2 | [*x for x in y] + | ^^ Syntax Error: Cannot use iterable unpacking in a list comprehension on Python 3.14 (syntax was added in Python 3.15) +3 | {*x for x in y} +4 | {**x for x in y} + | + + + | +1 | # parse_options: {"target-version": "3.14"} +2 | [*x for x in y] +3 | {*x for x in y} + | ^^ Syntax Error: Cannot use iterable unpacking in a set comprehension on Python 3.14 (syntax was added in Python 3.15) +4 | {**x for x in y} +5 | (*x for x in y) + | + + + | +2 | [*x for x in y] +3 | {*x for x in y} +4 | {**x for x in y} + | ^^^^ Syntax Error: Cannot use dictionary unpacking in a dict comprehension on Python 3.14 (syntax was added in Python 3.15) +5 | (*x for x in y) +6 | f(*x for x in y) + | + + + | +3 | {*x for x in y} +4 | {**x for x in y} +5 | (*x for x in y) + | ^^ Syntax Error: Cannot use iterable unpacking in a generator expression on Python 3.14 (syntax was added in Python 3.15) +6 | f(*x for x in y) + | + + + | +4 | {**x for x in y} +5 | (*x for x in y) +6 | f(*x for x in y) + | ^^ Syntax Error: Cannot use iterable unpacking in a generator expression on Python 3.14 (syntax was added in Python 3.15) + | diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@rebound_comprehension_variable.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@rebound_comprehension_variable.py.snap index 589ef950bff7bf..51966592d8a630 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@rebound_comprehension_variable.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@rebound_comprehension_variable.py.snap @@ -180,28 +180,30 @@ Module( ExprDictComp { node_index: NodeIndex(None), range: 58..91, - key: Named( - ExprNamed { - node_index: NodeIndex(None), - range: 60..66, - target: Name( - ExprName { - node_index: NodeIndex(None), - range: 60..61, - id: Name("a"), - ctx: Store, - }, - ), - value: NumberLiteral( - ExprNumberLiteral { - node_index: NodeIndex(None), - range: 65..66, - value: Int( - 0, - ), - }, - ), - }, + key: Some( + Named( + ExprNamed { + node_index: NodeIndex(None), + range: 60..66, + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 60..61, + id: Name("a"), + ctx: Store, + }, + ), + value: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 65..66, + value: Int( + 0, + ), + }, + ), + }, + ), ), value: Name( ExprName { @@ -269,13 +271,15 @@ Module( ExprDictComp { node_index: NodeIndex(None), range: 92..125, - key: Name( - ExprName { - node_index: NodeIndex(None), - range: 93..96, - id: Name("key"), - ctx: Load, - }, + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 93..96, + id: Name("key"), + ctx: Load, + }, + ), ), value: Named( ExprNamed { diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__invalid_assignment_targets.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__invalid_assignment_targets.py.snap index cdf778510128a7..7744b48010a38c 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__invalid_assignment_targets.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__invalid_assignment_targets.py.snap @@ -567,13 +567,15 @@ Module( ExprDictComp { node_index: NodeIndex(None), range: 451..473, - key: Name( - ExprName { - node_index: NodeIndex(None), - range: 452..453, - id: Name("x"), - ctx: Load, - }, + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 452..453, + id: Name("x"), + ctx: Load, + }, + ), ), value: BinOp( ExprBinOp { diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__invalid_augmented_assignment_target.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__invalid_augmented_assignment_target.py.snap index 66bb515f10b05b..4c67e9427830c4 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__invalid_augmented_assignment_target.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__invalid_augmented_assignment_target.py.snap @@ -470,13 +470,15 @@ Module( ExprDictComp { node_index: NodeIndex(None), range: 255..277, - key: Name( - ExprName { - node_index: NodeIndex(None), - range: 256..257, - id: Name("x"), - ctx: Load, - }, + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 256..257, + id: Name("x"), + ctx: Load, + }, + ), ), value: BinOp( ExprBinOp { diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__with__ambiguous_lpar_with_items.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__with__ambiguous_lpar_with_items.py.snap index 61ff19b25d1c62..b0cefbde739d3d 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__with__ambiguous_lpar_with_items.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__with__ambiguous_lpar_with_items.py.snap @@ -1693,16 +1693,6 @@ Module( | - | -15 | with ((*item)): ... -16 | -17 | with (*x for x in iter, item): ... - | ^^ Syntax Error: Iterable unpacking cannot be used in a comprehension -18 | with (item1, *x for x in iter, item2): ... -19 | with (x as f, *y): ... - | - - | 15 | with ((*item)): ... 16 | @@ -1880,3 +1870,15 @@ Module( 32 | with (item1 as f, item2 := 0): ... | ^^^^^^^^^^ Syntax Error: Unparenthesized named expression cannot be used here | + + +## Unsupported Syntax Errors + + | +15 | with ((*item)): ... +16 | +17 | with (*x for x in iter, item): ... + | ^^ Syntax Error: Cannot use iterable unpacking in a generator expression on Python 3.14 (syntax was added in Python 3.15) +18 | with (item1, *x for x in iter, item2): ... +19 | with (x as f, *y): ... + | diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__dictionary.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__dictionary.py.snap index 1eef98686d5a83..34a2ba29b61fa4 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__dictionary.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__dictionary.py.snap @@ -1112,34 +1112,36 @@ Module( ExprDictComp { node_index: NodeIndex(None), range: 516..575, - key: If( - ExprIf { - node_index: NodeIndex(None), - range: 517..533, - test: BooleanLiteral( - ExprBooleanLiteral { - node_index: NodeIndex(None), - range: 522..526, - value: true, - }, - ), - body: Name( - ExprName { - node_index: NodeIndex(None), - range: 517..518, - id: Name("x"), - ctx: Load, - }, - ), - orelse: Name( - ExprName { - node_index: NodeIndex(None), - range: 532..533, - id: Name("y"), - ctx: Load, - }, - ), - }, + key: Some( + If( + ExprIf { + node_index: NodeIndex(None), + range: 517..533, + test: BooleanLiteral( + ExprBooleanLiteral { + node_index: NodeIndex(None), + range: 522..526, + value: true, + }, + ), + body: Name( + ExprName { + node_index: NodeIndex(None), + range: 517..518, + id: Name("x"), + ctx: Load, + }, + ), + orelse: Name( + ExprName { + node_index: NodeIndex(None), + range: 532..533, + id: Name("y"), + ctx: Load, + }, + ), + }, + ), ), value: Name( ExprName { diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__dictionary_comprehension.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__dictionary_comprehension.py.snap index 5046336486ae62..aca02dde86b393 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__dictionary_comprehension.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__dictionary_comprehension.py.snap @@ -91,13 +91,15 @@ Module( ExprDictComp { node_index: NodeIndex(None), range: 23..42, - key: Name( - ExprName { - node_index: NodeIndex(None), - range: 24..26, - id: Name("x1"), - ctx: Load, - }, + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 24..26, + id: Name("x1"), + ctx: Load, + }, + ), ), value: Name( ExprName { @@ -143,29 +145,31 @@ Module( ExprDictComp { node_index: NodeIndex(None), range: 43..73, - key: BinOp( - ExprBinOp { - node_index: NodeIndex(None), - range: 44..49, - left: Name( - ExprName { - node_index: NodeIndex(None), - range: 44..45, - id: Name("x"), - ctx: Load, - }, - ), - op: Add, - right: NumberLiteral( - ExprNumberLiteral { - node_index: NodeIndex(None), - range: 48..49, - value: Int( - 1, - ), - }, - ), - }, + key: Some( + BinOp( + ExprBinOp { + node_index: NodeIndex(None), + range: 44..49, + left: Name( + ExprName { + node_index: NodeIndex(None), + range: 44..45, + id: Name("x"), + ctx: Load, + }, + ), + op: Add, + right: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 48..49, + value: Int( + 1, + ), + }, + ), + }, + ), ), value: StringLiteral( ExprStringLiteral { @@ -246,13 +250,15 @@ Module( ExprDictComp { node_index: NodeIndex(None), range: 74..122, - key: Name( - ExprName { - node_index: NodeIndex(None), - range: 75..76, - id: Name("b"), - ctx: Load, - }, + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 75..76, + id: Name("b"), + ctx: Load, + }, + ), ), value: BinOp( ExprBinOp { @@ -375,13 +381,15 @@ Module( ExprDictComp { node_index: NodeIndex(None), range: 123..176, - key: Name( - ExprName { - node_index: NodeIndex(None), - range: 124..125, - id: Name("a"), - ctx: Load, - }, + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 124..125, + id: Name("a"), + ctx: Load, + }, + ), ), value: BinOp( ExprBinOp { @@ -519,13 +527,15 @@ Module( ExprDictComp { node_index: NodeIndex(None), range: 177..231, - key: Name( - ExprName { - node_index: NodeIndex(None), - range: 178..179, - id: Name("a"), - ctx: Load, - }, + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 178..179, + id: Name("a"), + ctx: Load, + }, + ), ), value: Name( ExprName { @@ -647,13 +657,15 @@ Module( ExprDictComp { node_index: NodeIndex(None), range: 232..252, - key: Name( - ExprName { - node_index: NodeIndex(None), - range: 233..234, - id: Name("a"), - ctx: Load, - }, + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 233..234, + id: Name("a"), + ctx: Load, + }, + ), ), value: Name( ExprName { @@ -717,13 +729,15 @@ Module( ExprDictComp { node_index: NodeIndex(None), range: 391..416, - key: Name( - ExprName { - node_index: NodeIndex(None), - range: 392..393, - id: Name("x"), - ctx: Load, - }, + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 392..393, + id: Name("x"), + ctx: Load, + }, + ), ), value: Name( ExprName { @@ -777,13 +791,15 @@ Module( ExprDictComp { node_index: NodeIndex(None), range: 417..447, - key: Name( - ExprName { - node_index: NodeIndex(None), - range: 418..419, - id: Name("x"), - ctx: Load, - }, + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 418..419, + id: Name("x"), + ctx: Load, + }, + ), ), value: Name( ExprName { @@ -835,13 +851,15 @@ Module( ExprDictComp { node_index: NodeIndex(None), range: 448..477, - key: Name( - ExprName { - node_index: NodeIndex(None), - range: 449..450, - id: Name("x"), - ctx: Load, - }, + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 449..450, + id: Name("x"), + ctx: Load, + }, + ), ), value: Name( ExprName { @@ -920,13 +938,15 @@ Module( ExprDictComp { node_index: NodeIndex(None), range: 478..511, - key: Name( - ExprName { - node_index: NodeIndex(None), - range: 479..480, - id: Name("x"), - ctx: Load, - }, + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 479..480, + id: Name("x"), + ctx: Load, + }, + ), ), value: Name( ExprName { @@ -989,13 +1009,15 @@ Module( ExprDictComp { node_index: NodeIndex(None), range: 512..550, - key: Name( - ExprName { - node_index: NodeIndex(None), - range: 513..514, - id: Name("x"), - ctx: Load, - }, + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 513..514, + id: Name("x"), + ctx: Load, + }, + ), ), value: Name( ExprName { @@ -1056,13 +1078,15 @@ Module( ExprDictComp { node_index: NodeIndex(None), range: 551..588, - key: Name( - ExprName { - node_index: NodeIndex(None), - range: 552..553, - id: Name("x"), - ctx: Load, - }, + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 552..553, + id: Name("x"), + ctx: Load, + }, + ), ), value: Name( ExprName { diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@nested_async_comprehension_py311.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@nested_async_comprehension_py311.py.snap index 3754f5f1bad5d6..e1b30a4094c89c 100644 --- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@nested_async_comprehension_py311.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@nested_async_comprehension_py311.py.snap @@ -191,13 +191,15 @@ Module( ExprDictComp { node_index: NodeIndex(None), range: 145..173, - key: Name( - ExprName { - node_index: NodeIndex(None), - range: 146..147, - id: Name("x"), - ctx: Load, - }, + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 146..147, + id: Name("x"), + ctx: Load, + }, + ), ), value: NumberLiteral( ExprNumberLiteral { diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@pep_798_unpacking_comprehensions_py315.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@pep_798_unpacking_comprehensions_py315.py.snap new file mode 100644 index 00000000000000..7ed54cd39f48a5 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@pep_798_unpacking_comprehensions_py315.py.snap @@ -0,0 +1,487 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/inline/ok/pep_798_unpacking_comprehensions_py315.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..215, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 44..59, + value: ListComp( + ExprListComp { + node_index: NodeIndex(None), + range: 44..59, + elt: Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 45..47, + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 46..47, + id: Name("x"), + ctx: Load, + }, + ), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 48..58, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 52..53, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 57..58, + id: Name("y"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + }, + ], + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 60..75, + value: SetComp( + ExprSetComp { + node_index: NodeIndex(None), + range: 60..75, + elt: Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 61..63, + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 62..63, + id: Name("x"), + ctx: Load, + }, + ), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 64..74, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 68..69, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 73..74, + id: Name("y"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + }, + ], + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 76..92, + value: DictComp( + ExprDictComp { + node_index: NodeIndex(None), + range: 76..92, + key: None, + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 79..80, + id: Name("x"), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 81..91, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 85..86, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 90..91, + id: Name("y"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + }, + ], + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 93..108, + value: Generator( + ExprGenerator { + node_index: NodeIndex(None), + range: 93..108, + elt: Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 94..96, + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 95..96, + id: Name("x"), + ctx: Load, + }, + ), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 97..107, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 101..102, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 106..107, + id: Name("y"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + }, + ], + parenthesized: true, + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 109..125, + value: Call( + ExprCall { + node_index: NodeIndex(None), + range: 109..125, + func: Name( + ExprName { + node_index: NodeIndex(None), + range: 109..110, + id: Name("f"), + ctx: Load, + }, + ), + arguments: Arguments { + range: 110..125, + node_index: NodeIndex(None), + args: [ + Generator( + ExprGenerator { + node_index: NodeIndex(None), + range: 111..124, + elt: Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 111..113, + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 112..113, + id: Name("x"), + ctx: Load, + }, + ), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 114..124, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 118..119, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 123..124, + id: Name("y"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + }, + ], + parenthesized: false, + }, + ), + ], + keywords: [], + }, + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 126..147, + value: ListComp( + ExprListComp { + node_index: NodeIndex(None), + range: 126..147, + elt: Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 127..129, + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 128..129, + id: Name("x"), + ctx: Load, + }, + ), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 130..146, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 140..141, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 145..146, + id: Name("y"), + ctx: Load, + }, + ), + ifs: [], + is_async: true, + }, + ], + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 148..169, + value: SetComp( + ExprSetComp { + node_index: NodeIndex(None), + range: 148..169, + elt: Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 149..151, + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 150..151, + id: Name("x"), + ctx: Load, + }, + ), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 152..168, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 162..163, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 167..168, + id: Name("y"), + ctx: Load, + }, + ), + ifs: [], + is_async: true, + }, + ], + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 170..192, + value: DictComp( + ExprDictComp { + node_index: NodeIndex(None), + range: 170..192, + key: None, + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 173..174, + id: Name("x"), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 175..191, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 185..186, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 190..191, + id: Name("y"), + ctx: Load, + }, + ), + ifs: [], + is_async: true, + }, + ], + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 193..214, + value: Generator( + ExprGenerator { + node_index: NodeIndex(None), + range: 193..214, + elt: Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 194..196, + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 195..196, + id: Name("x"), + ctx: Load, + }, + ), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 197..213, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 207..208, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 212..213, + id: Name("y"), + ctx: Load, + }, + ), + ifs: [], + is_async: true, + }, + ], + parenthesized: true, + }, + ), + }, + ), + ], + }, +) +``` diff --git a/crates/ty_python_core/src/builder.rs b/crates/ty_python_core/src/builder.rs index 8afa2091f60358..e4390e16aabf63 100644 --- a/crates/ty_python_core/src/builder.rs +++ b/crates/ty_python_core/src/builder.rs @@ -3708,7 +3708,9 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> { NodeWithScopeRef::DictComprehension(dict_comprehension), generators, |builder| { - builder.visit_expr(key); + if let Some(key) = key { + builder.visit_expr(key); + } builder.visit_expr(value); }, ); diff --git a/crates/ty_python_semantic/resources/mdtest/comprehensions/basic.md b/crates/ty_python_semantic/resources/mdtest/comprehensions/basic.md index 64d32b4d1faa25..461fa9cd241111 100644 --- a/crates/ty_python_semantic/resources/mdtest/comprehensions/basic.md +++ b/crates/ty_python_semantic/resources/mdtest/comprehensions/basic.md @@ -156,10 +156,36 @@ squares: list[int | None] = [x**2 for x in range(10)] reveal_type(squares) # revealed: list[int | None] ``` +## PEP 798 unpacking comprehensions + +```toml +[environment] +python-version = "3.15" +``` + +Unpacking comprehensions flatten the unpacked element type: + +```py +list_of_lists: list[list[int]] = [[1], [2, 3]] +sets: list[set[str]] = [{"a"}, {"b", "c"}] +dicts: list[dict[str, int]] = [{"a": 1}, {"b": 2}] +not_iterables: list[int] = [1, 2] + +reveal_type([*xs for xs in list_of_lists]) # revealed: list[int] +reveal_type({*xs for xs in sets}) # revealed: set[str] +reveal_type({**d for d in dicts}) # revealed: dict[str, int] + +[*value for value in not_iterables] # error: [not-iterable] "Object of type `int` is not iterable" +{*value for value in not_iterables} # error: [not-iterable] "Object of type `int` is not iterable" +{**value for value in not_iterables} # error: [invalid-argument-type] +``` + +## Inference for comprehensions takes context + Inference for comprehensions takes the type context into account: ```py -from typing import Sequence +from typing import Literal, Sequence, TypedDict # Without type context: reveal_type([x for x in [1, 2, 3]]) # revealed: list[int] diff --git a/crates/ty_python_semantic/resources/mdtest/literal/collections/generator_expressions.md b/crates/ty_python_semantic/resources/mdtest/literal/collections/generator_expressions.md index bd2ccf14b09848..249a1551c37f79 100644 --- a/crates/ty_python_semantic/resources/mdtest/literal/collections/generator_expressions.md +++ b/crates/ty_python_semantic/resources/mdtest/literal/collections/generator_expressions.md @@ -12,6 +12,23 @@ reveal_type(x for x in range(10)) reveal_type((x, str(y)) for x in range(3) for y in range(3)) ``` +## PEP 798 unpacking generator expressions + +```toml +[environment] +python-version = "3.15" +``` + +```py +list_of_lists: list[list[int]] = [[1], [2, 3]] +not_iterables: list[int] = [1, 2] + +# revealed: GeneratorType[int, None, None] +reveal_type(*xs for xs in list_of_lists) + +(*value for value in not_iterables) # error: [not-iterable] "Object of type `int` is not iterable" +``` + When used in a loop, the yielded type can be inferred: ```py diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index daf319160e846c..d1a0d6806e1669 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -6485,7 +6485,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let scope = scope_id.to_scope_id(self.db(), self.file()); let inference = infer_scope_types(self.db(), scope, yield_tcx); self.extend_scope(inference); - let yield_type = inference.expression_type(elt.as_ref()); + let yield_type = Self::comprehension_element_type(self.db(), elt, inference); if evaluation_mode.is_async() { KnownClass::AsyncGeneratorType @@ -6498,6 +6498,19 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } + fn comprehension_element_type( + db: &'db dyn Db, + element: &ast::Expr, + inference: &ScopeInference<'db>, + ) -> Type<'db> { + let element_type = inference.expression_type(element); + if element.is_starred_expr() { + element_type.iterate(db).homogeneous_element_type(db) + } else { + element_type + } + } + /// Return a specialization of the collection class (list, dict, set) based on the type context and the inferred /// element / key-value types from the comprehension expression. fn infer_comprehension_specialization( @@ -6600,7 +6613,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { self.infer_comprehension_specialization( KnownClass::Dict, - [Some(key), Some(value)], + [key.as_deref(), Some(value)], inference, tcx, ) @@ -6622,7 +6635,12 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { parenthesized: _, } = generator; - self.infer_expression(elt, tcx); + let elt_tcx = if elt.is_starred_expr() { + tcx.map(|yield_ty| KnownClass::Iterable.to_specialized_instance(self.db(), &[yield_ty])) + } else { + tcx + }; + self.infer_expression(elt, elt_tcx); self.infer_comprehensions(generators); } @@ -6681,11 +6699,18 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { generators, } = dictcomp; - // Infer the key and value types using the outer type context. - let elts = [[Some(key.as_ref()), Some(value.as_ref())]]; - let mut infer_elt_ty = - |builder: &mut Self, (_, elt, tcx)| builder.infer_expression(elt, tcx); - self.infer_collection_literal(KnownClass::Dict, &elts, &mut infer_elt_ty, tcx); + if key.is_some() { + // Infer the key and value types using the outer type context. + let elts = [[key.as_deref(), Some(value.as_ref())]]; + let mut infer_elt_ty = + |builder: &mut Self, (_, elt, tcx)| builder.infer_expression(elt, tcx); + self.infer_collection_literal(KnownClass::Dict, &elts, &mut infer_elt_ty, tcx); + } else { + // Dict-unpack comprehensions are typed by the outer expression inference. Inferring + // them through the collection-literal helper here would report the same invalid + // mapping diagnostic twice. + self.infer_expression(value, TypeContext::default()); + } self.infer_comprehensions(generators); } From 6ab7b404ccb58d35e3a8d738ba9f40d48141bbfb Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 12 May 2026 21:00:40 -0700 Subject: [PATCH 2/7] replies --- .../flake8_comprehensions/C400_py315.py | 3 ++ .../flake8_comprehensions/C401_py315.py | 3 ++ .../flake8_comprehensions/C411_py315.py | 3 ++ .../flake8_comprehensions/C418_py315.py | 3 ++ .../flake8_comprehensions/C419_py315.py | 4 ++ .../src/rules/flake8_comprehensions/mod.rs | 16 ++++++ .../rules/unnecessary_generator_list.rs | 15 ++++-- .../rules/unnecessary_generator_set.rs | 15 ++++-- .../rules/unnecessary_list_call.rs | 10 ++-- .../unnecessary_literal_within_dict_call.rs | 17 ++++-- ...rehensions__tests__C400_C400_py315.py.snap | 12 +++++ ...rehensions__tests__C401_C401_py315.py.snap | 12 +++++ ...rehensions__tests__C411_C411_py315.py.snap | 12 +++++ ...rehensions__tests__C418_C418_py315.py.snap | 12 +++++ ...rehensions__tests__C419_C419_py315.py.snap | 22 ++++++++ .../ruff/expression/list_comp_py315.py | 6 +++ .../src/comments/placement.rs | 8 ++- .../src/expression/expr_dict_comp.rs | 7 +++ ...format@expression__list_comp_py315.py.snap | 12 +++++ crates/ruff_python_parser/src/error.rs | 54 +++++++++++++++++++ .../src/types/infer/builder.rs | 8 +-- 21 files changed, 231 insertions(+), 23 deletions(-) create mode 100644 crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C400_py315.py create mode 100644 crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C401_py315.py create mode 100644 crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C411_py315.py create mode 100644 crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C418_py315.py create mode 100644 crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C419_py315.py create mode 100644 crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C400_C400_py315.py.snap create mode 100644 crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C401_C401_py315.py.snap create mode 100644 crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C411_C411_py315.py.snap create mode 100644 crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C418_C418_py315.py.snap create mode 100644 crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C419_C419_py315.py.snap diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C400_py315.py b/crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C400_py315.py new file mode 100644 index 00000000000000..eec926efb704a0 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C400_py315.py @@ -0,0 +1,3 @@ +xs = [[1], [2]] + +list(*x for x in xs) diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C401_py315.py b/crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C401_py315.py new file mode 100644 index 00000000000000..b0a1e090a42473 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C401_py315.py @@ -0,0 +1,3 @@ +xs = [[1], [2]] + +set(*x for x in xs) diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C411_py315.py b/crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C411_py315.py new file mode 100644 index 00000000000000..af81f8bb4a9e15 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C411_py315.py @@ -0,0 +1,3 @@ +xs = [[1], [2]] + +list([*x for x in xs]) diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C418_py315.py b/crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C418_py315.py new file mode 100644 index 00000000000000..f0cf9d1c8a4363 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C418_py315.py @@ -0,0 +1,3 @@ +dicts = [{"a": 1}, {"b": 2}] + +dict({**d for d in dicts}) diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C419_py315.py b/crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C419_py315.py new file mode 100644 index 00000000000000..b1d2395c8ee6fb --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C419_py315.py @@ -0,0 +1,4 @@ +xs = [[1], [2]] + +all([*x for x in xs]) +any({*x for x in xs}) diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/mod.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/mod.rs index 4385f630664acd..36dfc71f999bb4 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/mod.rs @@ -9,6 +9,7 @@ mod tests { use std::path::Path; use anyhow::Result; + use ruff_python_ast::PythonVersion; use test_case::test_case; use crate::assert_diagnostics; @@ -51,6 +52,21 @@ mod tests { Ok(()) } + #[test_case(Rule::UnnecessaryGeneratorList, Path::new("C400_py315.py"))] + #[test_case(Rule::UnnecessaryGeneratorSet, Path::new("C401_py315.py"))] + #[test_case(Rule::UnnecessaryListCall, Path::new("C411_py315.py"))] + #[test_case(Rule::UnnecessaryLiteralWithinDictCall, Path::new("C418_py315.py"))] + #[test_case(Rule::UnnecessaryComprehensionInCall, Path::new("C419_py315.py"))] + fn rules_py315(rule_code: Rule, path: &Path) -> Result<()> { + let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let diagnostics = test_path( + Path::new("flake8_comprehensions").join(path).as_path(), + &LinterSettings::for_rule(rule_code).with_target_version(PythonVersion::PY315), + )?; + assert_diagnostics!(snapshot, diagnostics); + Ok(()) + } + #[test_case(Rule::UnnecessaryLiteralWithinTupleCall, Path::new("C409.py"))] #[test_case(Rule::UnnecessaryComprehensionInCall, Path::new("C419_1.py"))] fn preview_rules(rule_code: Rule, path: &Path) -> Result<()> { diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_generator_list.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_generator_list.rs index d271a1379293a0..65c2274d52f167 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_generator_list.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_generator_list.rs @@ -7,7 +7,7 @@ use ruff_python_ast::token::parenthesized_range; use ruff_text_size::{Ranged, TextRange, TextSize}; use crate::checkers::ast::Checker; -use crate::{AlwaysFixableViolation, Edit, Fix}; +use crate::{Edit, Fix, FixAvailability, Violation}; use crate::rules::flake8_comprehensions::helpers; @@ -47,7 +47,9 @@ pub(crate) struct UnnecessaryGeneratorList { short_circuit: bool, } -impl AlwaysFixableViolation for UnnecessaryGeneratorList { +impl Violation for UnnecessaryGeneratorList { + const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes; + #[derive_message_formats] fn message(&self) -> String { if self.short_circuit { @@ -57,12 +59,12 @@ impl AlwaysFixableViolation for UnnecessaryGeneratorList { } } - fn fix_title(&self) -> String { - if self.short_circuit { + fn fix_title(&self) -> Option { + Some(if self.short_circuit { "Rewrite using `list()`".to_string() } else { "Rewrite as a list comprehension".to_string() - } + }) } } @@ -117,6 +119,9 @@ pub(crate) fn unnecessary_generator_list(checker: &Checker, call: &ast::ExprCall }, call.range(), ); + if elt.is_starred_expr() { + return; + } let fix = { // Replace `list(` with `[`. let call_start = Edit::replacement( diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_generator_set.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_generator_set.rs index 05a1c523cf1736..a5bc344302dd97 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_generator_set.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_generator_set.rs @@ -8,7 +8,7 @@ use ruff_text_size::{Ranged, TextRange, TextSize}; use crate::checkers::ast::Checker; use crate::rules::flake8_comprehensions::fixes::{pad_end, pad_start}; -use crate::{AlwaysFixableViolation, Edit, Fix}; +use crate::{Edit, Fix, FixAvailability, Violation}; use crate::rules::flake8_comprehensions::helpers; @@ -48,7 +48,9 @@ pub(crate) struct UnnecessaryGeneratorSet { short_circuit: bool, } -impl AlwaysFixableViolation for UnnecessaryGeneratorSet { +impl Violation for UnnecessaryGeneratorSet { + const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes; + #[derive_message_formats] fn message(&self) -> String { if self.short_circuit { @@ -58,12 +60,12 @@ impl AlwaysFixableViolation for UnnecessaryGeneratorSet { } } - fn fix_title(&self) -> String { - if self.short_circuit { + fn fix_title(&self) -> Option { + Some(if self.short_circuit { "Rewrite using `set()`".to_string() } else { "Rewrite as a set comprehension".to_string() - } + }) } } @@ -118,6 +120,9 @@ pub(crate) fn unnecessary_generator_set(checker: &Checker, call: &ast::ExprCall) }, call.range(), ); + if elt.is_starred_expr() { + return; + } let fix = { // Replace `set(` with `}`. let call_start = Edit::replacement( diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_list_call.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_list_call.rs index 00ed6915512bf0..b167fbf3cf305c 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_list_call.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_list_call.rs @@ -5,7 +5,7 @@ use ruff_text_size::Ranged; use crate::checkers::ast::Checker; use crate::rules::flake8_comprehensions::fixes; -use crate::{AlwaysFixableViolation, Fix}; +use crate::{Fix, FixAvailability, Violation}; use crate::rules::flake8_comprehensions::helpers; @@ -32,14 +32,16 @@ use crate::rules::flake8_comprehensions::helpers; #[violation_metadata(stable_since = "v0.0.73")] pub(crate) struct UnnecessaryListCall; -impl AlwaysFixableViolation for UnnecessaryListCall { +impl Violation for UnnecessaryListCall { + const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes; + #[derive_message_formats] fn message(&self) -> String { "Unnecessary `list()` call (remove the outer call to `list()`)".to_string() } - fn fix_title(&self) -> String { - "Remove outer `list()` call".to_string() + fn fix_title(&self) -> Option { + Some("Remove outer `list()` call".to_string()) } } diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_literal_within_dict_call.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_literal_within_dict_call.rs index e6f374f88dcae0..59a793f164c634 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_literal_within_dict_call.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_literal_within_dict_call.rs @@ -6,7 +6,7 @@ use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_text_size::Ranged; use crate::checkers::ast::Checker; -use crate::{AlwaysFixableViolation, Edit, Fix}; +use crate::{Edit, Fix, FixAvailability, Violation}; use crate::rules::flake8_comprehensions::helpers; @@ -40,15 +40,17 @@ pub(crate) struct UnnecessaryLiteralWithinDictCall { kind: DictKind, } -impl AlwaysFixableViolation for UnnecessaryLiteralWithinDictCall { +impl Violation for UnnecessaryLiteralWithinDictCall { + const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes; + #[derive_message_formats] fn message(&self) -> String { let UnnecessaryLiteralWithinDictCall { kind } = self; format!("Unnecessary dict {kind} passed to `dict()` (remove the outer call to `dict()`)") } - fn fix_title(&self) -> String { - "Remove outer `dict()` call".to_string() + fn fix_title(&self) -> Option { + Some("Remove outer `dict()` call".to_string()) } } @@ -79,6 +81,13 @@ pub(crate) fn unnecessary_literal_within_dict_call(checker: &Checker, call: &ast call.range(), ); + if matches!( + argument, + Expr::DictComp(ast::ExprDictComp { key: None, .. }) + ) { + return; + } + // Convert `dict({"a": 1})` to `{"a": 1}` diagnostic.set_fix({ // Delete from the start of the call to the start of the argument. diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C400_C400_py315.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C400_C400_py315.py.snap new file mode 100644 index 00000000000000..0131a66de448f1 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C400_C400_py315.py.snap @@ -0,0 +1,12 @@ +--- +source: crates/ruff_linter/src/rules/flake8_comprehensions/mod.rs +--- +C400 Unnecessary generator (rewrite as a list comprehension) + --> C400_py315.py:3:1 + | +1 | xs = [[1], [2]] +2 | +3 | list(*x for x in xs) + | ^^^^^^^^^^^^^^^^^^^^ + | +help: Rewrite as a list comprehension diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C401_C401_py315.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C401_C401_py315.py.snap new file mode 100644 index 00000000000000..a647b0a2c182f2 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C401_C401_py315.py.snap @@ -0,0 +1,12 @@ +--- +source: crates/ruff_linter/src/rules/flake8_comprehensions/mod.rs +--- +C401 Unnecessary generator (rewrite as a set comprehension) + --> C401_py315.py:3:1 + | +1 | xs = [[1], [2]] +2 | +3 | set(*x for x in xs) + | ^^^^^^^^^^^^^^^^^^^ + | +help: Rewrite as a set comprehension diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C411_C411_py315.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C411_C411_py315.py.snap new file mode 100644 index 00000000000000..375114392b31c0 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C411_C411_py315.py.snap @@ -0,0 +1,12 @@ +--- +source: crates/ruff_linter/src/rules/flake8_comprehensions/mod.rs +--- +C411 Unnecessary `list()` call (remove the outer call to `list()`) + --> C411_py315.py:3:1 + | +1 | xs = [[1], [2]] +2 | +3 | list([*x for x in xs]) + | ^^^^^^^^^^^^^^^^^^^^^^ + | +help: Remove outer `list()` call diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C418_C418_py315.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C418_C418_py315.py.snap new file mode 100644 index 00000000000000..1d63a5a8cd5a68 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C418_C418_py315.py.snap @@ -0,0 +1,12 @@ +--- +source: crates/ruff_linter/src/rules/flake8_comprehensions/mod.rs +--- +C418 Unnecessary dict comprehension passed to `dict()` (remove the outer call to `dict()`) + --> C418_py315.py:3:1 + | +1 | dicts = [{"a": 1}, {"b": 2}] +2 | +3 | dict({**d for d in dicts}) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +help: Remove outer `dict()` call diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C419_C419_py315.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C419_C419_py315.py.snap new file mode 100644 index 00000000000000..0af68931a95882 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C419_C419_py315.py.snap @@ -0,0 +1,22 @@ +--- +source: crates/ruff_linter/src/rules/flake8_comprehensions/mod.rs +--- +C419 Unnecessary list comprehension + --> C419_py315.py:3:5 + | +1 | xs = [[1], [2]] +2 | +3 | all([*x for x in xs]) + | ^^^^^^^^^^^^^^^^ +4 | any({*x for x in xs}) + | +help: Remove unnecessary comprehension + +C419 Unnecessary set comprehension + --> C419_py315.py:4:5 + | +3 | all([*x for x in xs]) +4 | any({*x for x in xs}) + | ^^^^^^^^^^^^^^^^ + | +help: Remove unnecessary comprehension diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/list_comp_py315.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/list_comp_py315.py index b9dd977d309121..dbb28f26bde4f3 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/list_comp_py315.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/list_comp_py315.py @@ -26,6 +26,12 @@ for d in dicts } +{ + ** # comment between ** and d + d + for d in dicts +} + (*x for x in y) f(*x for x in y) diff --git a/crates/ruff_python_formatter/src/comments/placement.rs b/crates/ruff_python_formatter/src/comments/placement.rs index ff38d7a100a692..f86f079283a767 100644 --- a/crates/ruff_python_formatter/src/comments/placement.rs +++ b/crates/ruff_python_formatter/src/comments/placement.rs @@ -235,7 +235,8 @@ fn handle_enclosed_comment<'a>( AnyNodeRef::ExprDict(_) => handle_dict_unpacking_comment(comment, source) .or_else(|comment| handle_bracketed_end_of_line_comment(comment, source)) .or_else(|comment| handle_key_value_comment(comment, source)), - AnyNodeRef::ExprDictComp(_) => handle_key_value_comment(comment, source) + AnyNodeRef::ExprDictComp(_) => handle_dict_unpacking_comment(comment, source) + .or_else(|comment| handle_key_value_comment(comment, source)) .or_else(|comment| handle_bracketed_end_of_line_comment(comment, source)), AnyNodeRef::ExprIf(expr_if) => handle_expr_if_comment(comment, expr_if, source), AnyNodeRef::ExprSlice(expr_slice) => { @@ -1323,7 +1324,10 @@ fn handle_dict_unpacking_comment<'a>( comment: DecoratedComment<'a>, source: &str, ) -> CommentPlacement<'a> { - debug_assert!(matches!(comment.enclosing_node(), AnyNodeRef::ExprDict(_))); + debug_assert!(matches!( + comment.enclosing_node(), + AnyNodeRef::ExprDict(_) | AnyNodeRef::ExprDictComp(_) + )); // no node after our comment so we can't be between `**` and the name (node) let Some(following) = comment.following_node() else { diff --git a/crates/ruff_python_formatter/src/expression/expr_dict_comp.rs b/crates/ruff_python_formatter/src/expression/expr_dict_comp.rs index 69f49469c992ee..32749566c66364 100644 --- a/crates/ruff_python_formatter/src/expression/expr_dict_comp.rs +++ b/crates/ruff_python_formatter/src/expression/expr_dict_comp.rs @@ -51,6 +51,13 @@ impl FormatNodeRule for FormatExprDictComp { } } else { write!(f, [token("**")])?; + if let Some(first) = comments.leading(value.as_ref()).first() { + if first.line_position().is_own_line() { + hard_line_break().fmt(f)?; + } else { + write!(f, [space(), space()])?; + } + } } write!(f, [value.format(), soft_line_break_or_space()])?; diff --git a/crates/ruff_python_formatter/tests/snapshots/format@expression__list_comp_py315.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@expression__list_comp_py315.py.snap index def41860b48c3e..ad9f3fd6b30689 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@expression__list_comp_py315.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@expression__list_comp_py315.py.snap @@ -32,6 +32,12 @@ input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/expression for d in dicts } +{ + ** # comment between ** and d + d + for d in dicts +} + (*x for x in y) f(*x for x in y) @@ -77,6 +83,12 @@ nested-string-quote-style = alternating {**d for d in dicts} +{ + ** # comment between ** and d + d + for d in dicts +} + (*x for x in y) f(*x for x in y) diff --git a/crates/ruff_python_parser/src/error.rs b/crates/ruff_python_parser/src/error.rs index 1ff1236d634a17..bf59851a3accab 100644 --- a/crates/ruff_python_parser/src/error.rs +++ b/crates/ruff_python_parser/src/error.rs @@ -867,14 +867,68 @@ pub enum UnsupportedSyntaxErrorKind { /// Represents the use of iterable unpacking inside a set comprehension /// before Python 3.15. + /// + /// ## Examples + /// + /// Before Python 3.15, set comprehensions could not use iterable + /// unpacking in their element expression: + /// + /// ```python + /// {*x for x in y} # SyntaxError + /// ``` + /// + /// Starting with Python 3.15, [PEP 798] allows iterable unpacking within + /// set comprehensions: + /// + /// ```python + /// {*x for x in y} + /// ``` + /// + /// [PEP 798]: https://peps.python.org/pep-0798/ IterableUnpackingInSetComprehension, /// Represents the use of iterable unpacking inside a generator expression /// before Python 3.15. + /// + /// ## Examples + /// + /// Before Python 3.15, generator expressions could not use iterable + /// unpacking in their element expression: + /// + /// ```python + /// (*x for x in y) # SyntaxError + /// ``` + /// + /// Starting with Python 3.15, [PEP 798] allows iterable unpacking within + /// generator expressions: + /// + /// ```python + /// (*x for x in y) + /// ``` + /// + /// [PEP 798]: https://peps.python.org/pep-0798/ IterableUnpackingInGeneratorExpression, /// Represents the use of dictionary unpacking inside a dict comprehension /// before Python 3.15. + /// + /// ## Examples + /// + /// Before Python 3.15, dict comprehensions could not use dictionary + /// unpacking in their element expression: + /// + /// ```python + /// {**d for d in dicts} # SyntaxError + /// ``` + /// + /// Starting with Python 3.15, [PEP 798] allows dictionary unpacking within + /// dict comprehensions: + /// + /// ```python + /// {**d for d in dicts} + /// ``` + /// + /// [PEP 798]: https://peps.python.org/pep-0798/ DictUnpackingInDictComprehension, /// Represents the use of tuple unpacking in a `for` statement iterator clause before Python diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index d1a0d6806e1669..6379a5e9cc1faa 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -6485,7 +6485,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let scope = scope_id.to_scope_id(self.db(), self.file()); let inference = infer_scope_types(self.db(), scope, yield_tcx); self.extend_scope(inference); - let yield_type = Self::comprehension_element_type(self.db(), elt, inference); + let yield_type = self.comprehension_element_type(elt, inference); if evaluation_mode.is_async() { KnownClass::AsyncGeneratorType @@ -6499,13 +6499,15 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } fn comprehension_element_type( - db: &'db dyn Db, + &self, element: &ast::Expr, inference: &ScopeInference<'db>, ) -> Type<'db> { let element_type = inference.expression_type(element); if element.is_starred_expr() { - element_type.iterate(db).homogeneous_element_type(db) + element_type + .iterate(self.db()) + .homogeneous_element_type(self.db()) } else { element_type } From 8c8ed02d603853edc6d781f8088194c8c13cb75a Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 12 May 2026 21:23:33 -0700 Subject: [PATCH 3/7] review --- .../unnecessary_comprehension_in_call.rs | 4 + .../rules/unnecessary_generator_list.rs | 15 +-- .../rules/unnecessary_generator_set.rs | 15 +-- .../rules/unnecessary_list_call.rs | 9 +- .../unnecessary_literal_within_dict_call.rs | 1 + ...rehensions__tests__C400_C400_py315.py.snap | 7 +- ...rehensions__tests__C401_C401_py315.py.snap | 7 +- crates/ruff_python_parser/src/error.rs | 124 +++++------------- .../src/parser/expression.rs | 24 +++- 9 files changed, 85 insertions(+), 121 deletions(-) diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_comprehension_in_call.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_comprehension_in_call.rs index 5be7f7f9742a07..906fdfcf1ec8b6 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_comprehension_in_call.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_comprehension_in_call.rs @@ -154,6 +154,10 @@ pub(crate) fn unnecessary_comprehension_in_call( } }; if args.len() == 1 { + if elt.is_starred_expr() { + // The LibCST-based fixer does not yet support PEP 798 unpacking comprehensions. + return; + } // If there's only one argument, remove the list or set brackets. diagnostic.try_set_fix(|| { fixes::fix_unnecessary_comprehension_in_call(expr, checker.locator(), checker.stylist()) diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_generator_list.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_generator_list.rs index 65c2274d52f167..d271a1379293a0 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_generator_list.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_generator_list.rs @@ -7,7 +7,7 @@ use ruff_python_ast::token::parenthesized_range; use ruff_text_size::{Ranged, TextRange, TextSize}; use crate::checkers::ast::Checker; -use crate::{Edit, Fix, FixAvailability, Violation}; +use crate::{AlwaysFixableViolation, Edit, Fix}; use crate::rules::flake8_comprehensions::helpers; @@ -47,9 +47,7 @@ pub(crate) struct UnnecessaryGeneratorList { short_circuit: bool, } -impl Violation for UnnecessaryGeneratorList { - const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes; - +impl AlwaysFixableViolation for UnnecessaryGeneratorList { #[derive_message_formats] fn message(&self) -> String { if self.short_circuit { @@ -59,12 +57,12 @@ impl Violation for UnnecessaryGeneratorList { } } - fn fix_title(&self) -> Option { - Some(if self.short_circuit { + fn fix_title(&self) -> String { + if self.short_circuit { "Rewrite using `list()`".to_string() } else { "Rewrite as a list comprehension".to_string() - }) + } } } @@ -119,9 +117,6 @@ pub(crate) fn unnecessary_generator_list(checker: &Checker, call: &ast::ExprCall }, call.range(), ); - if elt.is_starred_expr() { - return; - } let fix = { // Replace `list(` with `[`. let call_start = Edit::replacement( diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_generator_set.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_generator_set.rs index a5bc344302dd97..05a1c523cf1736 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_generator_set.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_generator_set.rs @@ -8,7 +8,7 @@ use ruff_text_size::{Ranged, TextRange, TextSize}; use crate::checkers::ast::Checker; use crate::rules::flake8_comprehensions::fixes::{pad_end, pad_start}; -use crate::{Edit, Fix, FixAvailability, Violation}; +use crate::{AlwaysFixableViolation, Edit, Fix}; use crate::rules::flake8_comprehensions::helpers; @@ -48,9 +48,7 @@ pub(crate) struct UnnecessaryGeneratorSet { short_circuit: bool, } -impl Violation for UnnecessaryGeneratorSet { - const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes; - +impl AlwaysFixableViolation for UnnecessaryGeneratorSet { #[derive_message_formats] fn message(&self) -> String { if self.short_circuit { @@ -60,12 +58,12 @@ impl Violation for UnnecessaryGeneratorSet { } } - fn fix_title(&self) -> Option { - Some(if self.short_circuit { + fn fix_title(&self) -> String { + if self.short_circuit { "Rewrite using `set()`".to_string() } else { "Rewrite as a set comprehension".to_string() - }) + } } } @@ -120,9 +118,6 @@ pub(crate) fn unnecessary_generator_set(checker: &Checker, call: &ast::ExprCall) }, call.range(), ); - if elt.is_starred_expr() { - return; - } let fix = { // Replace `set(` with `}`. let call_start = Edit::replacement( diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_list_call.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_list_call.rs index b167fbf3cf305c..412f34bcea5720 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_list_call.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_list_call.rs @@ -1,4 +1,4 @@ -use ruff_python_ast::{Arguments, Expr, ExprCall}; +use ruff_python_ast::{self as ast, Arguments, Expr, ExprCall}; use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_text_size::Ranged; @@ -79,6 +79,13 @@ pub(crate) fn unnecessary_list_call(checker: &Checker, expr: &Expr, call: &ExprC return; } let mut diagnostic = checker.report_diagnostic(UnnecessaryListCall, expr.range()); + if matches!( + argument, + Expr::ListComp(ast::ExprListComp { elt, .. }) if elt.is_starred_expr() + ) { + // The LibCST-based fixer does not yet support PEP 798 unpacking comprehensions. + return; + } diagnostic.try_set_fix(|| { fixes::fix_unnecessary_list_call(expr, checker.locator(), checker.stylist()) .map(Fix::unsafe_edit) diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_literal_within_dict_call.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_literal_within_dict_call.rs index 59a793f164c634..c53e31a958b806 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_literal_within_dict_call.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_literal_within_dict_call.rs @@ -85,6 +85,7 @@ pub(crate) fn unnecessary_literal_within_dict_call(checker: &Checker, call: &ast argument, Expr::DictComp(ast::ExprDictComp { key: None, .. }) ) { + // The LibCST-based fixer does not yet support PEP 798 unpacking comprehensions. return; } diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C400_C400_py315.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C400_C400_py315.py.snap index 0131a66de448f1..a7744db5ab7029 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C400_C400_py315.py.snap +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C400_C400_py315.py.snap @@ -1,7 +1,7 @@ --- source: crates/ruff_linter/src/rules/flake8_comprehensions/mod.rs --- -C400 Unnecessary generator (rewrite as a list comprehension) +C400 [*] Unnecessary generator (rewrite as a list comprehension) --> C400_py315.py:3:1 | 1 | xs = [[1], [2]] @@ -10,3 +10,8 @@ C400 Unnecessary generator (rewrite as a list comprehension) | ^^^^^^^^^^^^^^^^^^^^ | help: Rewrite as a list comprehension +1 | xs = [[1], [2]] +2 | + - list(*x for x in xs) +3 + [*x for x in xs] +note: This is an unsafe fix and may change runtime behavior diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C401_C401_py315.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C401_C401_py315.py.snap index a647b0a2c182f2..d1ebebb4afae50 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C401_C401_py315.py.snap +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C401_C401_py315.py.snap @@ -1,7 +1,7 @@ --- source: crates/ruff_linter/src/rules/flake8_comprehensions/mod.rs --- -C401 Unnecessary generator (rewrite as a set comprehension) +C401 [*] Unnecessary generator (rewrite as a set comprehension) --> C401_py315.py:3:1 | 1 | xs = [[1], [2]] @@ -10,3 +10,8 @@ C401 Unnecessary generator (rewrite as a set comprehension) | ^^^^^^^^^^^^^^^^^^^ | help: Rewrite as a set comprehension +1 | xs = [[1], [2]] +2 | + - set(*x for x in xs) +3 + {*x for x in xs} +note: This is an unsafe fix and may change runtime behavior diff --git a/crates/ruff_python_parser/src/error.rs b/crates/ruff_python_parser/src/error.rs index bf59851a3accab..9f3aede8322299 100644 --- a/crates/ruff_python_parser/src/error.rs +++ b/crates/ruff_python_parser/src/error.rs @@ -505,6 +505,16 @@ pub enum FStringKind { NestedQuote, } +/// The type of PEP 798 unpacking-comprehension error for +/// [`UnsupportedSyntaxErrorKind::UnpackingInComprehension`]. +#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy, get_size2::GetSize)] +pub enum ComprehensionUnpackingKind { + IterableInList, + IterableInSet, + IterableInGenerator, + DictInDict, +} + #[derive(Debug, PartialEq, Eq, Hash, Clone, Copy, get_size2::GetSize)] pub enum UnparenthesizedNamedExprKind { SequenceIndex, @@ -843,93 +853,32 @@ pub enum UnsupportedSyntaxErrorKind { /// [PEP 646]: https://peps.python.org/pep-0646/#change-2-args-as-a-typevartuple StarAnnotation, - /// Represents the use of iterable unpacking inside a list comprehension - /// before Python 3.15. + /// Represents the use of iterable or dictionary unpacking inside a comprehension before Python + /// 3.15. /// /// ## Examples /// - /// Before Python 3.15, list comprehensions could not use iterable - /// unpacking in their element expression: + /// Before Python 3.15, comprehensions could not use iterable or dictionary unpacking in their + /// element expression: /// /// ```python /// [*x for x in y] # SyntaxError - /// ``` - /// - /// Starting with Python 3.15, [PEP 798] allows iterable unpacking within - /// list comprehensions: - /// - /// ```python - /// [*x for x in y] - /// ``` - /// - /// [PEP 798]: https://peps.python.org/pep-0798/ - IterableUnpackingInListComprehension, - - /// Represents the use of iterable unpacking inside a set comprehension - /// before Python 3.15. - /// - /// ## Examples - /// - /// Before Python 3.15, set comprehensions could not use iterable - /// unpacking in their element expression: - /// - /// ```python /// {*x for x in y} # SyntaxError - /// ``` - /// - /// Starting with Python 3.15, [PEP 798] allows iterable unpacking within - /// set comprehensions: - /// - /// ```python - /// {*x for x in y} - /// ``` - /// - /// [PEP 798]: https://peps.python.org/pep-0798/ - IterableUnpackingInSetComprehension, - - /// Represents the use of iterable unpacking inside a generator expression - /// before Python 3.15. - /// - /// ## Examples - /// - /// Before Python 3.15, generator expressions could not use iterable - /// unpacking in their element expression: - /// - /// ```python /// (*x for x in y) # SyntaxError - /// ``` - /// - /// Starting with Python 3.15, [PEP 798] allows iterable unpacking within - /// generator expressions: - /// - /// ```python - /// (*x for x in y) - /// ``` - /// - /// [PEP 798]: https://peps.python.org/pep-0798/ - IterableUnpackingInGeneratorExpression, - - /// Represents the use of dictionary unpacking inside a dict comprehension - /// before Python 3.15. - /// - /// ## Examples - /// - /// Before Python 3.15, dict comprehensions could not use dictionary - /// unpacking in their element expression: - /// - /// ```python /// {**d for d in dicts} # SyntaxError /// ``` /// - /// Starting with Python 3.15, [PEP 798] allows dictionary unpacking within - /// dict comprehensions: + /// Starting with Python 3.15, [PEP 798] allows unpacking within comprehensions: /// /// ```python + /// [*x for x in y] + /// {*x for x in y} + /// (*x for x in y) /// {**d for d in dicts} /// ``` /// /// [PEP 798]: https://peps.python.org/pep-0798/ - DictUnpackingInDictComprehension, + UnpackingInComprehension(ComprehensionUnpackingKind), /// Represents the use of tuple unpacking in a `for` statement iterator clause before Python /// 3.9. @@ -1076,18 +1025,18 @@ impl Display for UnsupportedSyntaxError { "Cannot use star expression in index" } UnsupportedSyntaxErrorKind::StarAnnotation => "Cannot use star annotation", - UnsupportedSyntaxErrorKind::IterableUnpackingInListComprehension => { - "Cannot use iterable unpacking in a list comprehension" - } - UnsupportedSyntaxErrorKind::IterableUnpackingInSetComprehension => { - "Cannot use iterable unpacking in a set comprehension" - } - UnsupportedSyntaxErrorKind::IterableUnpackingInGeneratorExpression => { - "Cannot use iterable unpacking in a generator expression" - } - UnsupportedSyntaxErrorKind::DictUnpackingInDictComprehension => { - "Cannot use dictionary unpacking in a dict comprehension" - } + UnsupportedSyntaxErrorKind::UnpackingInComprehension( + ComprehensionUnpackingKind::IterableInList, + ) => "Cannot use iterable unpacking in a list comprehension", + UnsupportedSyntaxErrorKind::UnpackingInComprehension( + ComprehensionUnpackingKind::IterableInSet, + ) => "Cannot use iterable unpacking in a set comprehension", + UnsupportedSyntaxErrorKind::UnpackingInComprehension( + ComprehensionUnpackingKind::IterableInGenerator, + ) => "Cannot use iterable unpacking in a generator expression", + UnsupportedSyntaxErrorKind::UnpackingInComprehension( + ComprehensionUnpackingKind::DictInDict, + ) => "Cannot use dictionary unpacking in a dict comprehension", UnsupportedSyntaxErrorKind::UnparenthesizedUnpackInFor => { "Cannot use iterable unpacking in `for` statements" } @@ -1160,16 +1109,7 @@ impl UnsupportedSyntaxErrorKind { Change::Added(PythonVersion::PY311) } UnsupportedSyntaxErrorKind::StarAnnotation => Change::Added(PythonVersion::PY311), - UnsupportedSyntaxErrorKind::IterableUnpackingInListComprehension => { - Change::Added(PythonVersion::PY315) - } - UnsupportedSyntaxErrorKind::IterableUnpackingInSetComprehension => { - Change::Added(PythonVersion::PY315) - } - UnsupportedSyntaxErrorKind::IterableUnpackingInGeneratorExpression => { - Change::Added(PythonVersion::PY315) - } - UnsupportedSyntaxErrorKind::DictUnpackingInDictComprehension => { + UnsupportedSyntaxErrorKind::UnpackingInComprehension(_) => { Change::Added(PythonVersion::PY315) } UnsupportedSyntaxErrorKind::UnparenthesizedUnpackInFor => { diff --git a/crates/ruff_python_parser/src/parser/expression.rs b/crates/ruff_python_parser/src/parser/expression.rs index c8238b9edd9d53..78288479edc41a 100644 --- a/crates/ruff_python_parser/src/parser/expression.rs +++ b/crates/ruff_python_parser/src/parser/expression.rs @@ -12,7 +12,9 @@ use ruff_python_ast::{ }; use ruff_text_size::{Ranged, TextLen, TextRange, TextSize}; -use crate::error::{FStringKind, StarTupleKind, UnparenthesizedNamedExprKind}; +use crate::error::{ + ComprehensionUnpackingKind, FStringKind, StarTupleKind, UnparenthesizedNamedExprKind, +}; use crate::parser::progress::ParserProgress; use crate::parser::{FunctionKind, Parser, helpers}; use crate::string::{ @@ -731,7 +733,9 @@ impl<'src> Parser<'src> { TokenKind::Async | TokenKind::For => { if parsed_expr.is_unparenthesized_starred_expr() { parser.add_unsupported_syntax_error( - UnsupportedSyntaxErrorKind::IterableUnpackingInGeneratorExpression, + UnsupportedSyntaxErrorKind::UnpackingInComprehension( + ComprehensionUnpackingKind::IterableInGenerator, + ), parsed_expr.range(), ); } @@ -2036,7 +2040,9 @@ impl<'src> Parser<'src> { // [*x for x in y] if first_element.is_unparenthesized_starred_expr() { self.add_unsupported_syntax_error( - UnsupportedSyntaxErrorKind::IterableUnpackingInListComprehension, + UnsupportedSyntaxErrorKind::UnpackingInComprehension( + ComprehensionUnpackingKind::IterableInList, + ), first_element.range(), ); } @@ -2105,7 +2111,9 @@ impl<'src> Parser<'src> { if matches!(self.current_token_kind(), TokenKind::Async | TokenKind::For) { self.add_unsupported_syntax_error( - UnsupportedSyntaxErrorKind::DictUnpackingInDictComprehension, + UnsupportedSyntaxErrorKind::UnpackingInComprehension( + ComprehensionUnpackingKind::DictInDict, + ), TextRange::new(start, value.range().end()), ); @@ -2127,7 +2135,9 @@ impl<'src> Parser<'src> { TokenKind::Async | TokenKind::For => { if key_or_element.is_unparenthesized_starred_expr() { self.add_unsupported_syntax_error( - UnsupportedSyntaxErrorKind::IterableUnpackingInSetComprehension, + UnsupportedSyntaxErrorKind::UnpackingInComprehension( + ComprehensionUnpackingKind::IterableInSet, + ), key_or_element.range(), ); } else if key_or_element.is_unparenthesized_named_expr() { @@ -2244,7 +2254,9 @@ impl<'src> Parser<'src> { // grammar: `genexp` if parsed_expr.is_unparenthesized_starred_expr() { self.add_unsupported_syntax_error( - UnsupportedSyntaxErrorKind::IterableUnpackingInGeneratorExpression, + UnsupportedSyntaxErrorKind::UnpackingInComprehension( + ComprehensionUnpackingKind::IterableInGenerator, + ), parsed_expr.range(), ); } From d9d0bf99b75d020313732af0e6c6b5bcf2b21bbe Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 12 May 2026 21:37:28 -0700 Subject: [PATCH 4/7] cover more rules --- .../flake8_comprehensions/C403_py315.py | 3 ++ .../flake8_comprehensions/C409_py315.py | 3 ++ .../test/fixtures/ruff/RUF015_py315.py | 4 +++ .../src/rules/flake8_comprehensions/mod.rs | 19 +++++++++++ .../unnecessary_literal_within_tuple_call.rs | 14 +++++--- ...rehensions__tests__C403_C403_py315.py.snap | 17 ++++++++++ ...s__tests__preview__C409_C409_py315.py.snap | 12 +++++++ crates/ruff_linter/src/rules/ruff/mod.rs | 15 ++++++++ ...__tests__PY315_RUF015_RUF015_py315.py.snap | 34 +++++++++++++++++++ 9 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C403_py315.py create mode 100644 crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C409_py315.py create mode 100644 crates/ruff_linter/resources/test/fixtures/ruff/RUF015_py315.py create mode 100644 crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C403_C403_py315.py.snap create mode 100644 crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__preview__C409_C409_py315.py.snap create mode 100644 crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__PY315_RUF015_RUF015_py315.py.snap diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C403_py315.py b/crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C403_py315.py new file mode 100644 index 00000000000000..d5bb49842506e9 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C403_py315.py @@ -0,0 +1,3 @@ +xs = [[1], [2]] + +set([*x for x in xs]) diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C409_py315.py b/crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C409_py315.py new file mode 100644 index 00000000000000..c31654483296d7 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C409_py315.py @@ -0,0 +1,3 @@ +xs = [[1], [2]] + +tuple([*x for x in xs]) diff --git a/crates/ruff_linter/resources/test/fixtures/ruff/RUF015_py315.py b/crates/ruff_linter/resources/test/fixtures/ruff/RUF015_py315.py new file mode 100644 index 00000000000000..09136295e09bbc --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/ruff/RUF015_py315.py @@ -0,0 +1,4 @@ +xs = [[1], [2]] + +[*x for x in xs][0] +list(*x for x in xs)[0] diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/mod.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/mod.rs index 36dfc71f999bb4..7817906b90358c 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/mod.rs @@ -54,6 +54,7 @@ mod tests { #[test_case(Rule::UnnecessaryGeneratorList, Path::new("C400_py315.py"))] #[test_case(Rule::UnnecessaryGeneratorSet, Path::new("C401_py315.py"))] + #[test_case(Rule::UnnecessaryListComprehensionSet, Path::new("C403_py315.py"))] #[test_case(Rule::UnnecessaryListCall, Path::new("C411_py315.py"))] #[test_case(Rule::UnnecessaryLiteralWithinDictCall, Path::new("C418_py315.py"))] #[test_case(Rule::UnnecessaryComprehensionInCall, Path::new("C419_py315.py"))] @@ -86,6 +87,24 @@ mod tests { Ok(()) } + #[test_case(Rule::UnnecessaryLiteralWithinTupleCall, Path::new("C409_py315.py"))] + fn preview_rules_py315(rule_code: Rule, path: &Path) -> Result<()> { + let snapshot = format!( + "preview__{}_{}", + rule_code.noqa_code(), + path.to_string_lossy() + ); + let diagnostics = test_path( + Path::new("flake8_comprehensions").join(path).as_path(), + &LinterSettings { + preview: PreviewMode::Enabled, + ..LinterSettings::for_rule(rule_code).with_target_version(PythonVersion::PY315) + }, + )?; + assert_diagnostics!(snapshot, diagnostics); + Ok(()) + } + #[test_case(Rule::UnnecessaryCollectionCall, Path::new("C408.py"))] fn allow_dict_calls_with_keyword_arguments(rule_code: Rule, path: &Path) -> Result<()> { let snapshot = format!( diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_literal_within_tuple_call.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_literal_within_tuple_call.rs index c932878d1bbe9a..b7ed6f0a983135 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_literal_within_tuple_call.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/rules/unnecessary_literal_within_tuple_call.rs @@ -7,7 +7,7 @@ use ruff_text_size::{Ranged, TextRange, TextSize}; use crate::checkers::ast::Checker; use crate::preview::is_check_comprehensions_in_tuple_call_enabled; use crate::rules::flake8_comprehensions::fixes; -use crate::{AlwaysFixableViolation, Edit, Fix}; +use crate::{Edit, Fix, FixAvailability, Violation}; use crate::rules::flake8_comprehensions::helpers; @@ -53,7 +53,9 @@ pub(crate) struct UnnecessaryLiteralWithinTupleCall { literal_kind: TupleLiteralKind, } -impl AlwaysFixableViolation for UnnecessaryLiteralWithinTupleCall { +impl Violation for UnnecessaryLiteralWithinTupleCall { + const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes; + #[derive_message_formats] fn message(&self) -> String { match self.literal_kind { @@ -72,13 +74,13 @@ impl AlwaysFixableViolation for UnnecessaryLiteralWithinTupleCall { } } - fn fix_title(&self) -> String { + fn fix_title(&self) -> Option { let title = match self.literal_kind { TupleLiteralKind::List => "Rewrite as a tuple literal", TupleLiteralKind::Tuple => "Remove the outer call to `tuple()`", TupleLiteralKind::ListComp => "Rewrite as a generator", }; - title.to_string() + Some(title.to_string()) } } @@ -156,6 +158,10 @@ pub(crate) fn unnecessary_literal_within_tuple_call( if any_over_expr(elt, &Expr::is_await_expr) { return; } + if elt.is_starred_expr() { + // The LibCST-based fixer does not yet support PEP 798 unpacking comprehensions. + return; + } // Convert `tuple([x for x in range(10)])` to `tuple(x for x in range(10))` diagnostic.try_set_fix(|| { fixes::fix_unnecessary_comprehension_in_call( diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C403_C403_py315.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C403_C403_py315.py.snap new file mode 100644 index 00000000000000..9fd6362719dc52 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C403_C403_py315.py.snap @@ -0,0 +1,17 @@ +--- +source: crates/ruff_linter/src/rules/flake8_comprehensions/mod.rs +--- +C403 [*] Unnecessary list comprehension (rewrite as a set comprehension) + --> C403_py315.py:3:1 + | +1 | xs = [[1], [2]] +2 | +3 | set([*x for x in xs]) + | ^^^^^^^^^^^^^^^^^^^^^ + | +help: Rewrite as a set comprehension +1 | xs = [[1], [2]] +2 | + - set([*x for x in xs]) +3 + {*x for x in xs} +note: This is an unsafe fix and may change runtime behavior diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__preview__C409_C409_py315.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__preview__C409_C409_py315.py.snap new file mode 100644 index 00000000000000..09f30140447be3 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__preview__C409_C409_py315.py.snap @@ -0,0 +1,12 @@ +--- +source: crates/ruff_linter/src/rules/flake8_comprehensions/mod.rs +--- +C409 Unnecessary list comprehension passed to `tuple()` (rewrite as a generator) + --> C409_py315.py:3:1 + | +1 | xs = [[1], [2]] +2 | +3 | tuple([*x for x in xs]) + | ^^^^^^^^^^^^^^^^^^^^^^^ + | +help: Rewrite as a generator diff --git a/crates/ruff_linter/src/rules/ruff/mod.rs b/crates/ruff_linter/src/rules/ruff/mod.rs index 624637058c0b62..59d8e728603e2b 100644 --- a/crates/ruff_linter/src/rules/ruff/mod.rs +++ b/crates/ruff_linter/src/rules/ruff/mod.rs @@ -365,6 +365,21 @@ mod tests { Ok(()) } + #[test] + fn unnecessary_iterable_allocation_for_first_element_py315() -> Result<()> { + let diagnostics = test_path( + Path::new("ruff/RUF015_py315.py"), + &settings::LinterSettings { + unresolved_target_version: PythonVersion::PY315.into(), + ..settings::LinterSettings::for_rule( + Rule::UnnecessaryIterableAllocationForFirstElement, + ) + }, + )?; + assert_diagnostics!("PY315_RUF015_RUF015_py315.py", diagnostics); + Ok(()) + } + #[test] fn access_annotations_from_class_dict_py310() -> Result<()> { let diagnostics = test_path( diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__PY315_RUF015_RUF015_py315.py.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__PY315_RUF015_RUF015_py315.py.snap new file mode 100644 index 00000000000000..c3e3738cd4b67a --- /dev/null +++ b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__PY315_RUF015_RUF015_py315.py.snap @@ -0,0 +1,34 @@ +--- +source: crates/ruff_linter/src/rules/ruff/mod.rs +--- +RUF015 [*] Prefer `next(*x for x in xs)` over single element slice + --> RUF015_py315.py:3:1 + | +1 | xs = [[1], [2]] +2 | +3 | [*x for x in xs][0] + | ^^^^^^^^^^^^^^^^^^^ +4 | list(*x for x in xs)[0] + | +help: Replace with `next(*x for x in xs)` +1 | xs = [[1], [2]] +2 | + - [*x for x in xs][0] +3 + next(*x for x in xs) +4 | list(*x for x in xs)[0] +note: This is an unsafe fix and may change runtime behavior + +RUF015 [*] Prefer `next(*x for x in xs)` over single element slice + --> RUF015_py315.py:4:1 + | +3 | [*x for x in xs][0] +4 | list(*x for x in xs)[0] + | ^^^^^^^^^^^^^^^^^^^^^^^ + | +help: Replace with `next(*x for x in xs)` +1 | xs = [[1], [2]] +2 | +3 | [*x for x in xs][0] + - list(*x for x in xs)[0] +4 + next(*x for x in xs) +note: This is an unsafe fix and may change runtime behavior From 73dbb6879d69ed2ec083547a926428e35288d8d8 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Fri, 15 May 2026 06:40:52 -0700 Subject: [PATCH 5/7] Review feedback --- .../src/rules/flake8_comprehensions/mod.rs | 12 +- ...s__tests__preview__C409_C409_py315.py.snap | 9 + ...lid_dict_unpacking_comprehensions_py315.py | 5 + .../src/parser/expression.rs | 44 ++- ...ict_unpacking_comprehensions_py315.py.snap | 354 ++++++++++++++++++ 5 files changed, 418 insertions(+), 6 deletions(-) create mode 100644 crates/ruff_python_parser/resources/inline/err/pep_798_invalid_dict_unpacking_comprehensions_py315.py create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@pep_798_invalid_dict_unpacking_comprehensions_py315.py.snap diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/mod.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/mod.rs index 7817906b90358c..23447713ddf668 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/mod.rs @@ -12,11 +12,11 @@ mod tests { use ruff_python_ast::PythonVersion; use test_case::test_case; - use crate::assert_diagnostics; use crate::registry::Rule; use crate::settings::LinterSettings; use crate::settings::types::PreviewMode; use crate::test::test_path; + use crate::{assert_diagnostics, assert_diagnostics_diff}; #[test_case(Rule::UnnecessaryCallAroundSorted, Path::new("C413.py"))] #[test_case(Rule::UnnecessaryCollectionCall, Path::new("C408.py"))] @@ -94,14 +94,18 @@ mod tests { rule_code.noqa_code(), path.to_string_lossy() ); - let diagnostics = test_path( + assert_diagnostics_diff!( + snapshot, Path::new("flake8_comprehensions").join(path).as_path(), + &LinterSettings { + preview: PreviewMode::Disabled, + ..LinterSettings::for_rule(rule_code).with_target_version(PythonVersion::PY315) + }, &LinterSettings { preview: PreviewMode::Enabled, ..LinterSettings::for_rule(rule_code).with_target_version(PythonVersion::PY315) }, - )?; - assert_diagnostics!(snapshot, diagnostics); + ); Ok(()) } diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__preview__C409_C409_py315.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__preview__C409_C409_py315.py.snap index 09f30140447be3..a580bc898f2acb 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__preview__C409_C409_py315.py.snap +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__preview__C409_C409_py315.py.snap @@ -1,6 +1,15 @@ --- source: crates/ruff_linter/src/rules/flake8_comprehensions/mod.rs --- +--- Linter settings --- +-linter.preview = disabled ++linter.preview = enabled + +--- Summary --- +Removed: 0 +Added: 1 + +--- Added --- C409 Unnecessary list comprehension passed to `tuple()` (rewrite as a generator) --> C409_py315.py:3:1 | diff --git a/crates/ruff_python_parser/resources/inline/err/pep_798_invalid_dict_unpacking_comprehensions_py315.py b/crates/ruff_python_parser/resources/inline/err/pep_798_invalid_dict_unpacking_comprehensions_py315.py new file mode 100644 index 00000000000000..34e6434885904b --- /dev/null +++ b/crates/ruff_python_parser/resources/inline/err/pep_798_invalid_dict_unpacking_comprehensions_py315.py @@ -0,0 +1,5 @@ +# parse_options: {"target-version": "3.15"} +{*k: v for k, v in items} +{k: *v for k, v in items} +{**k: v for k, v in items} +{k: **v for k, v in items} diff --git a/crates/ruff_python_parser/src/parser/expression.rs b/crates/ruff_python_parser/src/parser/expression.rs index 78288479edc41a..224209367006f8 100644 --- a/crates/ruff_python_parser/src/parser/expression.rs +++ b/crates/ruff_python_parser/src/parser/expression.rs @@ -2084,6 +2084,13 @@ impl<'src> Parser<'src> { // (*x for x in y) // f(*x for x in y) + // test_err pep_798_invalid_dict_unpacking_comprehensions_py315 + // # parse_options: {"target-version": "3.15"} + // {*k: v for k, v in items} + // {k: *v for k, v in items} + // {**k: v for k, v in items} + // {k: **v for k, v in items} + let start = self.node_start(); self.bump(TokenKind::Lbrace); @@ -2108,13 +2115,14 @@ impl<'src> Parser<'src> { // Handle dictionary unpacking. Here, the grammar is `'**' bitwise_or` // which requires limiting the expression. let value = self.parse_expression_with_bitwise_or_precedence(); + let unpack_range = TextRange::new(start, value.range().end()); if matches!(self.current_token_kind(), TokenKind::Async | TokenKind::For) { self.add_unsupported_syntax_error( UnsupportedSyntaxErrorKind::UnpackingInComprehension( ComprehensionUnpackingKind::DictInDict, ), - TextRange::new(start, value.range().end()), + unpack_range, ); return Expr::DictComp( @@ -2122,6 +2130,27 @@ impl<'src> Parser<'src> { ); } + if self.at(TokenKind::Colon) { + self.add_error(ParseErrorType::InvalidStarredExpressionUsage, unpack_range); + + self.bump(TokenKind::Colon); + let dict_value = self.parse_conditional_expression_or_higher(); + + if matches!(self.current_token_kind(), TokenKind::Async | TokenKind::For) { + return Expr::DictComp(self.parse_dictionary_comprehension_expression( + Some(value.expr), + dict_value.expr, + start, + )); + } + + return Expr::Dict(self.parse_dictionary_expression( + Some(value.expr), + dict_value.expr, + start, + )); + } + return Expr::Dict(self.parse_dictionary_expression(None, value.expr, start)); } @@ -2182,7 +2211,18 @@ impl<'src> Parser<'src> { } self.bump(TokenKind::Colon); - let value = self.parse_conditional_expression_or_higher(); + let value = if self.at(TokenKind::DoubleStar) { + let unpack_start = self.node_start(); + self.bump(TokenKind::DoubleStar); + let value = self.parse_expression_with_bitwise_or_precedence(); + self.add_error( + ParseErrorType::InvalidStarredExpressionUsage, + TextRange::new(unpack_start, value.range().end()), + ); + value + } else { + self.parse_conditional_expression_or_higher() + }; if matches!(self.current_token_kind(), TokenKind::Async | TokenKind::For) { Expr::DictComp(self.parse_dictionary_comprehension_expression( diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@pep_798_invalid_dict_unpacking_comprehensions_py315.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@pep_798_invalid_dict_unpacking_comprehensions_py315.py.snap new file mode 100644 index 00000000000000..a9bcd8bd4cde19 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@pep_798_invalid_dict_unpacking_comprehensions_py315.py.snap @@ -0,0 +1,354 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/inline/err/pep_798_invalid_dict_unpacking_comprehensions_py315.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..150, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 44..69, + value: DictComp( + ExprDictComp { + node_index: NodeIndex(None), + range: 44..69, + key: Some( + Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 45..47, + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 46..47, + id: Name("k"), + ctx: Load, + }, + ), + ctx: Load, + }, + ), + ), + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 49..50, + id: Name("v"), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 51..68, + node_index: NodeIndex(None), + target: Tuple( + ExprTuple { + node_index: NodeIndex(None), + range: 55..59, + elts: [ + Name( + ExprName { + node_index: NodeIndex(None), + range: 55..56, + id: Name("k"), + ctx: Store, + }, + ), + Name( + ExprName { + node_index: NodeIndex(None), + range: 58..59, + id: Name("v"), + ctx: Store, + }, + ), + ], + ctx: Store, + parenthesized: false, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 63..68, + id: Name("items"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + }, + ], + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 70..95, + value: DictComp( + ExprDictComp { + node_index: NodeIndex(None), + range: 70..95, + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 71..72, + id: Name("k"), + ctx: Load, + }, + ), + ), + value: Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 74..76, + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 75..76, + id: Name("v"), + ctx: Load, + }, + ), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 77..94, + node_index: NodeIndex(None), + target: Tuple( + ExprTuple { + node_index: NodeIndex(None), + range: 81..85, + elts: [ + Name( + ExprName { + node_index: NodeIndex(None), + range: 81..82, + id: Name("k"), + ctx: Store, + }, + ), + Name( + ExprName { + node_index: NodeIndex(None), + range: 84..85, + id: Name("v"), + ctx: Store, + }, + ), + ], + ctx: Store, + parenthesized: false, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 89..94, + id: Name("items"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + }, + ], + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 96..122, + value: DictComp( + ExprDictComp { + node_index: NodeIndex(None), + range: 96..122, + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 99..100, + id: Name("k"), + ctx: Load, + }, + ), + ), + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 102..103, + id: Name("v"), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 104..121, + node_index: NodeIndex(None), + target: Tuple( + ExprTuple { + node_index: NodeIndex(None), + range: 108..112, + elts: [ + Name( + ExprName { + node_index: NodeIndex(None), + range: 108..109, + id: Name("k"), + ctx: Store, + }, + ), + Name( + ExprName { + node_index: NodeIndex(None), + range: 111..112, + id: Name("v"), + ctx: Store, + }, + ), + ], + ctx: Store, + parenthesized: false, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 116..121, + id: Name("items"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + }, + ], + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 123..149, + value: DictComp( + ExprDictComp { + node_index: NodeIndex(None), + range: 123..149, + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 124..125, + id: Name("k"), + ctx: Load, + }, + ), + ), + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 129..130, + id: Name("v"), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 131..148, + node_index: NodeIndex(None), + target: Tuple( + ExprTuple { + node_index: NodeIndex(None), + range: 135..139, + elts: [ + Name( + ExprName { + node_index: NodeIndex(None), + range: 135..136, + id: Name("k"), + ctx: Store, + }, + ), + Name( + ExprName { + node_index: NodeIndex(None), + range: 138..139, + id: Name("v"), + ctx: Store, + }, + ), + ], + ctx: Store, + parenthesized: false, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 143..148, + id: Name("items"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + }, + ], + }, + ), + }, + ), + ], + }, +) +``` +## Errors + + | +1 | # parse_options: {"target-version": "3.15"} +2 | {*k: v for k, v in items} + | ^^ Syntax Error: Starred expression cannot be used here +3 | {k: *v for k, v in items} +4 | {**k: v for k, v in items} + | + + + | +1 | # parse_options: {"target-version": "3.15"} +2 | {*k: v for k, v in items} +3 | {k: *v for k, v in items} + | ^^ Syntax Error: Starred expression cannot be used here +4 | {**k: v for k, v in items} +5 | {k: **v for k, v in items} + | + + + | +2 | {*k: v for k, v in items} +3 | {k: *v for k, v in items} +4 | {**k: v for k, v in items} + | ^^^^ Syntax Error: Starred expression cannot be used here +5 | {k: **v for k, v in items} + | + + + | +3 | {k: *v for k, v in items} +4 | {**k: v for k, v in items} +5 | {k: **v for k, v in items} + | ^^^ Syntax Error: Starred expression cannot be used here + | From f05a33372e40f0d8f22c6561e5b723c74d7b2467 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Fri, 15 May 2026 21:22:16 -0700 Subject: [PATCH 6/7] update snap --- ...s__dict__double_star_comprehension.py.snap | 168 +++++------------- 1 file changed, 48 insertions(+), 120 deletions(-) diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__dict__double_star_comprehension.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__dict__double_star_comprehension.py.snap index e5e7f7e3ee9a48..c0c6026b042e9d 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__dict__double_star_comprehension.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__dict__double_star_comprehension.py.snap @@ -14,100 +14,68 @@ Module( StmtExpr { node_index: NodeIndex(None), range: 122..147, - value: Dict( - ExprDict { + value: DictComp( + ExprDictComp { node_index: NodeIndex(None), range: 122..147, - items: [ - DictItem { - key: None, - value: Name( - ExprName { - node_index: NodeIndex(None), - range: 125..126, - id: Name("x"), - ctx: Load, - }, - ), + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 125..126, + id: Name("x"), + ctx: Load, + }, + ), + ), + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 128..129, + id: Name("y"), + ctx: Load, }, - DictItem { - key: Some( - Name( - ExprName { - node_index: NodeIndex(None), - range: 128..129, - id: Name("y"), - ctx: Load, - }, - ), - ), - value: Name( - ExprName { + ), + generators: [ + Comprehension { + range: 130..146, + node_index: NodeIndex(None), + target: Tuple( + ExprTuple { node_index: NodeIndex(None), - range: 130..133, - id: Name("for"), - ctx: Load, - }, - ), - }, - DictItem { - key: Some( - Name( - ExprName { - node_index: NodeIndex(None), - range: 134..135, - id: Name("x"), - ctx: Load, - }, - ), - ), - value: Name( - ExprName { - node_index: NodeIndex(None), - range: 135..135, - id: Name(""), - ctx: Invalid, - }, - ), - }, - DictItem { - key: Some( - Compare( - ExprCompare { - node_index: NodeIndex(None), - range: 137..146, - left: Name( + range: 134..138, + elts: [ + Name( + ExprName { + node_index: NodeIndex(None), + range: 134..135, + id: Name("x"), + ctx: Store, + }, + ), + Name( ExprName { node_index: NodeIndex(None), range: 137..138, id: Name("y"), - ctx: Load, + ctx: Store, }, ), - ops: [ - In, - ], - comparators: [ - Name( - ExprName { - node_index: NodeIndex(None), - range: 142..146, - id: Name("data"), - ctx: Load, - }, - ), - ], - }, - ), + ], + ctx: Store, + parenthesized: false, + }, ), - value: Name( + iter: Name( ExprName { node_index: NodeIndex(None), - range: 146..146, - id: Name(""), - ctx: Invalid, + range: 142..146, + id: Name("data"), + ctx: Load, }, ), + ifs: [], + is_async: false, }, ], }, @@ -124,47 +92,7 @@ Module( 2 | # it's actually a comprehension. 3 | 4 | {**x: y for x, y in data} - | ^ Syntax Error: Expected an expression or a '}' -5 | -6 | # TODO(dhruvmanila): This test case fails because there's no way to represent `**y` - | - - - | -2 | # it's actually a comprehension. -3 | -4 | {**x: y for x, y in data} - | ^^^ Syntax Error: Expected `:`, found `for` -5 | -6 | # TODO(dhruvmanila): This test case fails because there's no way to represent `**y` - | - - - | -2 | # it's actually a comprehension. -3 | -4 | {**x: y for x, y in data} - | ^ Syntax Error: Expected `,`, found name -5 | -6 | # TODO(dhruvmanila): This test case fails because there's no way to represent `**y` - | - - - | -2 | # it's actually a comprehension. -3 | -4 | {**x: y for x, y in data} - | ^ Syntax Error: Expected `:`, found `,` -5 | -6 | # TODO(dhruvmanila): This test case fails because there's no way to represent `**y` - | - - - | -2 | # it's actually a comprehension. -3 | -4 | {**x: y for x, y in data} - | ^ Syntax Error: Expected `:`, found `}` + | ^^^^ Syntax Error: Starred expression cannot be used here 5 | 6 | # TODO(dhruvmanila): This test case fails because there's no way to represent `**y` | From 1cbda8441a52da607e5f40bbb4052803d44a1906 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Fri, 15 May 2026 21:26:42 -0700 Subject: [PATCH 7/7] fix range --- crates/ruff_python_parser/src/parser/expression.rs | 4 +++- ...yntax@expressions__dict__double_star_comprehension.py.snap | 2 +- ...ep_798_invalid_dict_unpacking_comprehensions_py315.py.snap | 2 +- ...alid_syntax@pep_798_unpacking_comprehensions_py314.py.snap | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/ruff_python_parser/src/parser/expression.rs b/crates/ruff_python_parser/src/parser/expression.rs index 224209367006f8..3244d0f11ff68b 100644 --- a/crates/ruff_python_parser/src/parser/expression.rs +++ b/crates/ruff_python_parser/src/parser/expression.rs @@ -2111,11 +2111,13 @@ impl<'src> Parser<'src> { }); } + let after_brace = self.node_start(); + if self.eat(TokenKind::DoubleStar) { // Handle dictionary unpacking. Here, the grammar is `'**' bitwise_or` // which requires limiting the expression. let value = self.parse_expression_with_bitwise_or_precedence(); - let unpack_range = TextRange::new(start, value.range().end()); + let unpack_range = TextRange::new(after_brace, value.range().end()); if matches!(self.current_token_kind(), TokenKind::Async | TokenKind::For) { self.add_unsupported_syntax_error( diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__dict__double_star_comprehension.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__dict__double_star_comprehension.py.snap index c0c6026b042e9d..0bed84d9347312 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__dict__double_star_comprehension.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__dict__double_star_comprehension.py.snap @@ -92,7 +92,7 @@ Module( 2 | # it's actually a comprehension. 3 | 4 | {**x: y for x, y in data} - | ^^^^ Syntax Error: Starred expression cannot be used here + | ^^^ Syntax Error: Starred expression cannot be used here 5 | 6 | # TODO(dhruvmanila): This test case fails because there's no way to represent `**y` | diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@pep_798_invalid_dict_unpacking_comprehensions_py315.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@pep_798_invalid_dict_unpacking_comprehensions_py315.py.snap index a9bcd8bd4cde19..f4b8413ff6a2d8 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@pep_798_invalid_dict_unpacking_comprehensions_py315.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@pep_798_invalid_dict_unpacking_comprehensions_py315.py.snap @@ -341,7 +341,7 @@ Module( 2 | {*k: v for k, v in items} 3 | {k: *v for k, v in items} 4 | {**k: v for k, v in items} - | ^^^^ Syntax Error: Starred expression cannot be used here + | ^^^ Syntax Error: Starred expression cannot be used here 5 | {k: **v for k, v in items} | diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@pep_798_unpacking_comprehensions_py314.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@pep_798_unpacking_comprehensions_py314.py.snap index 36a931dfca90d1..f388ce286994d7 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@pep_798_unpacking_comprehensions_py314.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@pep_798_unpacking_comprehensions_py314.py.snap @@ -311,7 +311,7 @@ Module( 2 | [*x for x in y] 3 | {*x for x in y} 4 | {**x for x in y} - | ^^^^ Syntax Error: Cannot use dictionary unpacking in a dict comprehension on Python 3.14 (syntax was added in Python 3.15) + | ^^^ Syntax Error: Cannot use dictionary unpacking in a dict comprehension on Python 3.14 (syntax was added in Python 3.15) 5 | (*x for x in y) 6 | f(*x for x in y) |